Compare commits
3 Commits
78476e166f
...
aac0923351
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac0923351 | ||
|
|
54218894c0 | ||
|
|
66a95f4479 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,5 +5,5 @@ data/
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
/.claude
|
||||
.claude/settings.local.json
|
||||
EOF
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -43,14 +43,14 @@
|
||||
*Target: Next development session (Saturday)*
|
||||
|
||||
### Bug Fixes
|
||||
- [ ] **Entity extraction JSON parsing** — robustify response parser in `extraction.js` to handle model returning markdown fences or preamble around JSON
|
||||
- [x] **Entity extraction JSON parsing** — robustify response parser in `extraction.js` to handle model returning markdown fences or preamble around JSON
|
||||
- [ ] **Qdrant entity search empty results** — verify entities embedded post-isolation-fix are surfacing correctly in project session searches
|
||||
|
||||
### Tech Debt
|
||||
- [ ] **Logging** — introduce `LOG_LEVEL` env var across all services; reduce noise in production
|
||||
- [ ] **Error response consistency** — audit all endpoints for uniform `{ error, detail }` shape
|
||||
- [ ] **Constants audit** — move any remaining inline magic numbers (limits, thresholds, timeouts) to shared config
|
||||
- [ ] **Orchestration `chat/index.js` review** — extract any logic that has grown beyond its intended scope into dedicated modules
|
||||
- [x] **Logging** — introduce `LOG_LEVEL` env var across all services; reduce noise in production
|
||||
- [x] **Error response consistency** — audit all endpoints for uniform `{ error, detail }` shape
|
||||
- [x] **Constants audit** — move any remaining inline magic numbers (limits, thresholds, timeouts) to shared config
|
||||
- [x] **Orchestration `chat/index.js` review** — extract any logic that has grown beyond its intended scope into dedicated modules
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_DEFAULTS } from "../config/constants";
|
||||
import { logger } from "@nexusai/shared";
|
||||
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_ORCHESTRATION_URL ?? '';
|
||||
|
||||
@@ -79,7 +79,7 @@ export function streamMessage(sessionId, message, model, { onChunk, onDone, onEr
|
||||
if (data.done) onDone({ model: data.model ?? model, tokenCount: data.tokenCount ?? 0 });
|
||||
if (data.error) onError(new Error(data.error));
|
||||
} catch (err) {
|
||||
logger.error('[chat-client] Failed to parse SSE event:', raw, err);
|
||||
console.error('[chat-client] Failed to parse SSE event:', raw, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchSessions, deleteSession } from '../api/orchestration';
|
||||
import { CLIENT_DEFAULTS } from '../config/constants';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
|
||||
const PAGE_SIZE = CLIENT_DEFAULTS.PAGE_SIZE;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AllChatsView({ onSelectSession, onBack, projects }) {
|
||||
setSessions(data);
|
||||
setTotal(data.length === PAGE_SIZE ? (p + 2) * PAGE_SIZE : p * PAGE_SIZE + data.length);
|
||||
} catch (err) {
|
||||
logger.error('[AllChatsView] Failed to load sessions:', err.message);
|
||||
console.error('[AllChatsView] Failed to load sessions:', err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function AllChatsView({ onSelectSession, onBack, projects }) {
|
||||
setConfirmOpen(false);
|
||||
await loadPage(page);
|
||||
} catch (err) {
|
||||
logger.error('[AllChatsView] Bulk delete failed:', err.message);
|
||||
console.error('[AllChatsView] Bulk delete failed:', err.message);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ProjectModal from './ProjectModal';
|
||||
import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
|
||||
export default function AllProjectsView({ onProjectsChange, onBack, onSelectProject, onNavigate }) {
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -15,7 +15,7 @@ export default function AllProjectsView({ onProjectsChange, onBack, onSelectProj
|
||||
try {
|
||||
setProjects(await fetchProjects());
|
||||
} catch (err) {
|
||||
logger.error('[AllProjectsView] Failed to load:', err.message);
|
||||
console.error('[AllProjectsView] Failed to load:', err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ async function handleSave({ name, description, colour, icon }) {
|
||||
await load();
|
||||
onProjectsChange?.(); // add this
|
||||
} catch (err) {
|
||||
logger.error('[AllProjectsView] Save failed:', err.message);
|
||||
console.error('[AllProjectsView] Save failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ async function handleDelete(id) {
|
||||
await load();
|
||||
onProjectsChange?.(); // add this
|
||||
} catch (err) {
|
||||
logger.error('[AllProjectsView] Delete failed:', err.message);
|
||||
console.error('[AllProjectsView] Delete failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration';
|
||||
import ProjectModal from './ProjectModal';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
@@ -22,7 +21,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
|
||||
try {
|
||||
setOverview(await fetchProjectOverviewSummary(project.id));
|
||||
} catch (err) {
|
||||
logger.error('[ProjectView] Failed to load overview:', err.message);
|
||||
console.error('[ProjectView] Failed to load overview:', err.message);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
@@ -35,7 +34,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
|
||||
try {
|
||||
setSessions(await fetchSessions(50, 0, project.id));
|
||||
} catch (err) {
|
||||
logger.error('[ProjectView] Failed to load sessions:', err.message);
|
||||
console.error('[ProjectView] Failed to load sessions:', err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,7 +60,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
|
||||
onProjectsChange?.();
|
||||
setModal(null);
|
||||
} catch (err) {
|
||||
logger.error('[ProjectView] Update failed:', err.message);
|
||||
console.error('[ProjectView] Update failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +70,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
|
||||
onProjectsChange?.();
|
||||
onBack();
|
||||
} catch (err) {
|
||||
logger.error('[ProjectView] Delete failed:', err.message);
|
||||
console.error('[ProjectView] Delete failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +375,7 @@ function NotesSection({ projectId, initialNotes }) {
|
||||
await updateProject(projectId, { notes });
|
||||
setSavedNotes(notes);
|
||||
} catch (err) {
|
||||
logger.error('[NotesSection] Save failed:', err.message);
|
||||
console.error('[NotesSection] Save failed:', err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useModels } from '../hooks/useModels';
|
||||
import { getServiceHealth } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
|
||||
export default function SettingsView({ onNavigate, onBack, modelProps }) {
|
||||
const { settings, saveSetting, saving } = useSettings();
|
||||
@@ -277,7 +277,7 @@ function ServiceHealth() {
|
||||
setServices(await getServiceHealth());
|
||||
setLastChecked(new Date());
|
||||
} catch (err) {
|
||||
logger.error('[ServiceHealth]', err.message);
|
||||
console.error('[ServiceHealth]', err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
||||
import SessionModal from './SessionModal';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { renameSession, deleteSession, updateSession } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
|
||||
export default function Sidebar({
|
||||
@@ -33,7 +32,7 @@ export default function Sidebar({
|
||||
await updateSession(session.external_id, { name, projectId });
|
||||
onSessionsChange();
|
||||
} catch (err) {
|
||||
logger.error('[Sidebar] Rename failed:', err.message);
|
||||
console.error('[Sidebar] Rename failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +41,7 @@ export default function Sidebar({
|
||||
await deleteSession(session.external_id);
|
||||
onSessionsChange(session);
|
||||
} catch (err) {
|
||||
logger.error('[Sidebar] Delete failed:', err.message);
|
||||
console.error('[Sidebar] Delete failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { streamMessage, updateSession } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) {
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
@@ -74,7 +73,7 @@ export function useChat({ activeSession, appendMessage, updateLastMessage, refre
|
||||
// Assign project after first message if one was set
|
||||
if (projectId) {
|
||||
updateSession(targetSession.external_id, { projectId })
|
||||
.catch(err => logger.warn('[useChat] Failed to assign project:', err.message));
|
||||
.catch(err => console.warn('[useChat] Failed to assign project:', err.message));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchModels } from '../api/orchestration';
|
||||
import { FALLBACK_MODELS, DEFAULT_MODEL } from '../config/constants';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
export function useModels() {
|
||||
const [models, setModels] = useState(FALLBACK_MODELS);
|
||||
@@ -16,7 +15,7 @@ export function useModels() {
|
||||
setSelectedModel(data[0]?.value ?? DEFAULT_MODEL);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.warn('[useModels] Falling back to static list:', err.message);
|
||||
console.warn('[useModels] Falling back to static list:', err.message);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchProjects } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
|
||||
export function useProjects() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
@@ -9,7 +9,7 @@ export function useProjects() {
|
||||
try {
|
||||
setProjects(await fetchProjects());
|
||||
} catch (err) {
|
||||
logger.warn('[useProjects] Failed to load projects:', err.message);
|
||||
console.warn('[useProjects] Failed to load projects:', err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSettings, updateSettings } from '../api/orchestration';
|
||||
const { logger } = require('@nexusai/shared');
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings().then(setSettings).catch(logger.error);
|
||||
getSettings().then(setSettings).catch(console.error);
|
||||
}, []);
|
||||
|
||||
async function saveSetting(key, value) {
|
||||
@@ -16,7 +15,7 @@ export function useSettings() {
|
||||
const updated = await updateSettings({ [key]: value });
|
||||
setSettings(updated);
|
||||
} catch (err) {
|
||||
logger.error('[useSettings] Save failed:', err.message);
|
||||
console.error('[useSettings] Save failed:', err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
88
packages/memory-service/CLAUDE.md
Normal file
88
packages/memory-service/CLAUDE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
See the root [CLAUDE.md](../../CLAUDE.md) for overall architecture, service roles, and the dual-store memory model.
|
||||
|
||||
## Running This Service
|
||||
|
||||
```bash
|
||||
npm run memory # From repo root (node src/index.js)
|
||||
npm -w packages/memory-service run dev # With --watch
|
||||
```
|
||||
|
||||
Default port: **3002**. Requires Qdrant and the embedding-service to be reachable on startup.
|
||||
|
||||
## SQLite Schema
|
||||
|
||||
`src/db/schema.js` is the source of truth for the data model. Key schema facts:
|
||||
|
||||
- `sessions` and `episodes` are linked by FK with cascade delete — deleting a session removes all its episodes automatically.
|
||||
- `episodes_fts` is an FTS5 virtual table that mirrors `user_message` and `ai_response`. It is kept in sync via SQL triggers on INSERT/UPDATE/DELETE. On service startup, the FTS index is fully rebuilt from live episode data.
|
||||
- Several columns (`sessions.name`, `sessions.project_id`, `projects.isolated`, etc.) were added as migrations using `ALTER TABLE` wrapped in individual try-catch blocks. Failures are silently swallowed — if a column already exists, the alter fails and the service continues. The `idx_summaries_project` index is defined twice (benign duplicate).
|
||||
- `summaries` rows with `session_id IS NULL` and a `project_id` represent project-level overviews, not session summaries. This distinction is how `GET /projects/:id/overview` works.
|
||||
|
||||
## Async Pipeline: Episode Creation
|
||||
|
||||
`POST /episodes` returns a 201 as soon as the SQLite insert succeeds. Two background tasks run after without blocking the response:
|
||||
|
||||
1. **Embedding** — Fetches a vector from embedding-service, stores to Qdrant with `{sessionId, createdAt}` as payload metadata.
|
||||
2. **Entity extraction** — Sends the episode text to Ollama (`qwen2.5:3b`, temp 0.1, 200 tokens) and upserts any recognized entities to both SQLite and Qdrant.
|
||||
|
||||
Both tasks catch and log errors silently. An episode can exist in SQLite with no corresponding Qdrant point if either step fails.
|
||||
|
||||
## Entity Extraction Details
|
||||
|
||||
`src/entities/extraction.js`:
|
||||
|
||||
- Fetches the last 20 known entities from SQLite before prompting the model, so the prompt can ask for name/type consistency with existing entries.
|
||||
- Recognized types: `person`, `place`, `project`, `technology`, `concept`, `organization` — anything else is discarded.
|
||||
- Ignores a hardcoded list of low-value names (`hello`, `thanks`, `good morning`, etc.).
|
||||
- Extracts JSON using a regex (`{...}`) applied to raw model output, so surrounding prose doesn't break parsing.
|
||||
- Entity upsert uses `ON CONFLICT(name, type) DO UPDATE` — preserves existing `notes` if the new extraction returns null (`COALESCE(entities.notes, excluded.notes)`).
|
||||
- After upsert, embeds each entity as `"${name} (${type}): ${notes}"` and stores to Qdrant with `projectId` in the payload for project-scoped filtering.
|
||||
|
||||
## Summarization Strategy
|
||||
|
||||
`src/summarization/project.js`:
|
||||
|
||||
- Preferred path: generate a project overview from existing **session-level summaries** (higher-level abstraction, shorter input).
|
||||
- Fallback path: if no session summaries exist, summarize raw episodes directly (up to `SUMMARIES.MAX_PROJECT_EPISODE_LIMIT`).
|
||||
- Both paths truncate input at `SUMMARIES.MAX_SUMMARY_CHARS` (30,000 chars) by slicing from the end (most recent content wins).
|
||||
- Strips ChatML tokens from the Ollama response (`<|im_start|>`, `<|im_end|>`).
|
||||
- Uses temp 0.2 and `num_predict 1200`.
|
||||
|
||||
## Known Quirk: `getRecentEpisodes`
|
||||
|
||||
`src/episodic/index.js` `getRecentEpisodes(sessionId, limit)` has a parameter mismatch — the SQLite query binds only `limit`, not `sessionId`, so it returns recent episodes across **all sessions**. Orchestration-service uses `getEpisodesBySession()` (the paginated route) instead, so this bug is not visible in normal operation. Don't rely on `getRecentEpisodes` when you need session-scoped results.
|
||||
|
||||
## Qdrant Client
|
||||
|
||||
`src/semantic/index.js` creates the Qdrant client lazily on first use and reuses it. All three collections (`episodes`, `entities`, `summaries`) are created at startup if missing. There is no connection health check — if Qdrant is unreachable, semantic operations throw at call time.
|
||||
|
||||
## API Endpoints Quick Reference
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Static response, no dependency checks |
|
||||
| GET/POST | `/sessions` | POST requires `externalId`; duplicate → 409 |
|
||||
| GET/PATCH | `/sessions/by-external/:externalId` | PATCH accepts `name`, `projectId` |
|
||||
| DELETE | `/sessions/by-external/:externalId` | Cascades to episodes, summaries, relationships |
|
||||
| GET/POST | `/episodes` | POST triggers async embedding + entity extraction |
|
||||
| GET | `/episodes/search` | FTS5 search; route must precede `/:id` |
|
||||
| GET | `/sessions/:id/episodes` | Paginated, ordered `created_at DESC` |
|
||||
| DELETE | `/episodes/:id` | Removes from SQLite + async Qdrant delete |
|
||||
| POST | `/entities` | Upsert by `(name, type)` |
|
||||
| GET | `/entities/by-type/:type` | All entities of given type |
|
||||
| GET/DELETE | `/entities/:id` | |
|
||||
| POST | `/relationships` | Upsert by `(fromId, toId, label)`; conflict = no-op |
|
||||
| GET | `/entities/:id/relationships` | Outbound only |
|
||||
| DELETE | `/relationships` | Body: `fromId`, `toId`, `label` |
|
||||
| GET/POST | `/projects` | POST requires non-empty `name` |
|
||||
| GET/PATCH/DELETE | `/projects/:id` | |
|
||||
| POST | `/projects/:id/summarize` | On-demand overview generation; 422 if no data |
|
||||
| GET | `/projects/:id/overview` | Returns null (not 404) if no overview exists |
|
||||
| GET | `/projects/:id/summaries` | All summaries for project |
|
||||
| POST | `/summaries` | Requires `content` + at least one of `sessionId`/`projectId` |
|
||||
| GET | `/sessions/:id/summaries` | |
|
||||
| PATCH/DELETE | `/summaries/:id` | |
|
||||
@@ -163,7 +163,7 @@ function getRecentEpisodes(sessionId, limit = EPISODIC.DEFAULT_RECENT_LIMIT) {
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit).map(parseRow);
|
||||
return stmt.all(sessionId, limit).map(parseRow);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user