Compare commits

...

3 Commits

Author SHA1 Message Date
Storme-bit
aac0923351 Merge branch 'main' of http://192.168.0.205:3100/storme/nexusai 2026-04-27 00:10:16 -07:00
Storme-bit
54218894c0 logger clean up 2026-04-27 00:09:16 -07:00
Storme-bit
66a95f4479 logger clean up 2026-04-27 00:07:51 -07:00
15 changed files with 119 additions and 38 deletions

2
.gitignore vendored
View File

@@ -5,5 +5,5 @@ data/
.env .env
.env.* .env.*
*.db *.db
/.claude .claude/settings.local.json
EOF EOF

View File

@@ -1,2 +0,0 @@
{
}

View File

@@ -43,14 +43,14 @@
*Target: Next development session (Saturday)* *Target: Next development session (Saturday)*
### Bug Fixes ### 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 - [ ] **Qdrant entity search empty results** — verify entities embedded post-isolation-fix are surfacing correctly in project session searches
### Tech Debt ### Tech Debt
- [ ] **Logging** — introduce `LOG_LEVEL` env var across all services; reduce noise in production - [x] **Logging** — introduce `LOG_LEVEL` env var across all services; reduce noise in production
- [ ] **Error response consistency** — audit all endpoints for uniform `{ error, detail }` shape - [x] **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 - [x] **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] **Orchestration `chat/index.js` review** — extract any logic that has grown beyond its intended scope into dedicated modules
--- ---

View File

@@ -1,5 +1,5 @@
import { API_DEFAULTS } from "../config/constants"; import { API_DEFAULTS } from "../config/constants";
import { logger } from "@nexusai/shared";
const BASE_URL = import.meta.env.VITE_ORCHESTRATION_URL ?? ''; 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.done) onDone({ model: data.model ?? model, tokenCount: data.tokenCount ?? 0 });
if (data.error) onError(new Error(data.error)); if (data.error) onError(new Error(data.error));
} catch (err) { } catch (err) {
logger.error('[chat-client] Failed to parse SSE event:', raw, err); console.error('[chat-client] Failed to parse SSE event:', raw, err);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { fetchSessions, deleteSession } from '../api/orchestration'; import { fetchSessions, deleteSession } from '../api/orchestration';
import { CLIENT_DEFAULTS } from '../config/constants'; import { CLIENT_DEFAULTS } from '../config/constants';
const { logger } = require('@nexusai/shared');
const PAGE_SIZE = CLIENT_DEFAULTS.PAGE_SIZE; const PAGE_SIZE = CLIENT_DEFAULTS.PAGE_SIZE;
@@ -26,7 +26,7 @@ export default function AllChatsView({ onSelectSession, onBack, projects }) {
setSessions(data); setSessions(data);
setTotal(data.length === PAGE_SIZE ? (p + 2) * PAGE_SIZE : p * PAGE_SIZE + data.length); setTotal(data.length === PAGE_SIZE ? (p + 2) * PAGE_SIZE : p * PAGE_SIZE + data.length);
} catch (err) { } catch (err) {
logger.error('[AllChatsView] Failed to load sessions:', err.message); console.error('[AllChatsView] Failed to load sessions:', err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -55,7 +55,7 @@ export default function AllChatsView({ onSelectSession, onBack, projects }) {
setConfirmOpen(false); setConfirmOpen(false);
await loadPage(page); await loadPage(page);
} catch (err) { } catch (err) {
logger.error('[AllChatsView] Bulk delete failed:', err.message); console.error('[AllChatsView] Bulk delete failed:', err.message);
} finally { } finally {
setDeleting(false); setDeleting(false);
} }

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import ProjectModal from './ProjectModal'; import ProjectModal from './ProjectModal';
import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration'; import { fetchProjects, createProject, updateProject, deleteProject } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export default function AllProjectsView({ onProjectsChange, onBack, onSelectProject, onNavigate }) { export default function AllProjectsView({ onProjectsChange, onBack, onSelectProject, onNavigate }) {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
@@ -15,7 +15,7 @@ export default function AllProjectsView({ onProjectsChange, onBack, onSelectProj
try { try {
setProjects(await fetchProjects()); setProjects(await fetchProjects());
} catch (err) { } catch (err) {
logger.error('[AllProjectsView] Failed to load:', err.message); console.error('[AllProjectsView] Failed to load:', err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -31,7 +31,7 @@ async function handleSave({ name, description, colour, icon }) {
await load(); await load();
onProjectsChange?.(); // add this onProjectsChange?.(); // add this
} catch (err) { } 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(); await load();
onProjectsChange?.(); // add this onProjectsChange?.(); // add this
} catch (err) { } catch (err) {
logger.error('[AllProjectsView] Delete failed:', err.message); console.error('[AllProjectsView] Delete failed:', err.message);
} }
} }

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration'; import { fetchSessions, updateProject, deleteProject, generateProjectSummary, fetchProjectOverviewSummary } from '../api/orchestration';
import ProjectModal from './ProjectModal'; import ProjectModal from './ProjectModal';
const { logger } = require('@nexusai/shared');
export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) { export default function ProjectView({ project, onNavigate, onBack, onSelectSession, onNewProjectChat, onProjectsChange }) {
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
@@ -22,7 +21,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
try { try {
setOverview(await fetchProjectOverviewSummary(project.id)); setOverview(await fetchProjectOverviewSummary(project.id));
} catch (err) { } catch (err) {
logger.error('[ProjectView] Failed to load overview:', err.message); console.error('[ProjectView] Failed to load overview:', err.message);
} finally { } finally {
setOverviewLoading(false); setOverviewLoading(false);
} }
@@ -35,7 +34,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
try { try {
setSessions(await fetchSessions(50, 0, project.id)); setSessions(await fetchSessions(50, 0, project.id));
} catch (err) { } catch (err) {
logger.error('[ProjectView] Failed to load sessions:', err.message); console.error('[ProjectView] Failed to load sessions:', err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -61,7 +60,7 @@ export default function ProjectView({ project, onNavigate, onBack, onSelectSessi
onProjectsChange?.(); onProjectsChange?.();
setModal(null); setModal(null);
} catch (err) { } 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?.(); onProjectsChange?.();
onBack(); onBack();
} catch (err) { } 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 }); await updateProject(projectId, { notes });
setSavedNotes(notes); setSavedNotes(notes);
} catch (err) { } catch (err) {
logger.error('[NotesSection] Save failed:', err.message); console.error('[NotesSection] Save failed:', err.message);
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useModels } from '../hooks/useModels'; import { useModels } from '../hooks/useModels';
import { getServiceHealth } from '../api/orchestration'; import { getServiceHealth } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export default function SettingsView({ onNavigate, onBack, modelProps }) { export default function SettingsView({ onNavigate, onBack, modelProps }) {
const { settings, saveSetting, saving } = useSettings(); const { settings, saveSetting, saving } = useSettings();
@@ -277,7 +277,7 @@ function ServiceHealth() {
setServices(await getServiceHealth()); setServices(await getServiceHealth());
setLastChecked(new Date()); setLastChecked(new Date());
} catch (err) { } catch (err) {
logger.error('[ServiceHealth]', err.message); console.error('[ServiceHealth]', err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import SessionModal from './SessionModal'; import SessionModal from './SessionModal';
import { useContextMenu } from '../hooks/useContextMenu'; import { useContextMenu } from '../hooks/useContextMenu';
import { renameSession, deleteSession, updateSession } from '../api/orchestration'; import { renameSession, deleteSession, updateSession } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export default function Sidebar({ export default function Sidebar({
@@ -33,7 +32,7 @@ export default function Sidebar({
await updateSession(session.external_id, { name, projectId }); await updateSession(session.external_id, { name, projectId });
onSessionsChange(); onSessionsChange();
} catch (err) { } 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); await deleteSession(session.external_id);
onSessionsChange(session); onSessionsChange(session);
} catch (err) { } catch (err) {
logger.error('[Sidebar] Delete failed:', err.message); console.error('[Sidebar] Delete failed:', err.message);
} }
} }

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { streamMessage, updateSession } from '../api/orchestration'; import { streamMessage, updateSession } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) { export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) {
const [streaming, setStreaming] = useState(false); 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 // Assign project after first message if one was set
if (projectId) { if (projectId) {
updateSession(targetSession.external_id, { 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));
} }
}, },

View File

@@ -2,7 +2,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { fetchModels } from '../api/orchestration'; import { fetchModels } from '../api/orchestration';
import { FALLBACK_MODELS, DEFAULT_MODEL } from '../config/constants'; import { FALLBACK_MODELS, DEFAULT_MODEL } from '../config/constants';
const { logger } = require('@nexusai/shared');
export function useModels() { export function useModels() {
const [models, setModels] = useState(FALLBACK_MODELS); const [models, setModels] = useState(FALLBACK_MODELS);
@@ -16,7 +15,7 @@ export function useModels() {
setSelectedModel(data[0]?.value ?? DEFAULT_MODEL); setSelectedModel(data[0]?.value ?? DEFAULT_MODEL);
}) })
.catch(err => { .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)); .finally(() => setLoading(false));
}, []); }, []);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { fetchProjects } from '../api/orchestration'; import { fetchProjects } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export function useProjects() { export function useProjects() {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
@@ -9,7 +9,7 @@ export function useProjects() {
try { try {
setProjects(await fetchProjects()); setProjects(await fetchProjects());
} catch (err) { } catch (err) {
logger.warn('[useProjects] Failed to load projects:', err.message); console.warn('[useProjects] Failed to load projects:', err.message);
} }
}, []); }, []);

View File

@@ -1,13 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getSettings, updateSettings } from '../api/orchestration'; import { getSettings, updateSettings } from '../api/orchestration';
const { logger } = require('@nexusai/shared');
export function useSettings() { export function useSettings() {
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
getSettings().then(setSettings).catch(logger.error); getSettings().then(setSettings).catch(console.error);
}, []); }, []);
async function saveSetting(key, value) { async function saveSetting(key, value) {
@@ -16,7 +15,7 @@ export function useSettings() {
const updated = await updateSettings({ [key]: value }); const updated = await updateSettings({ [key]: value });
setSettings(updated); setSettings(updated);
} catch (err) { } catch (err) {
logger.error('[useSettings] Save failed:', err.message); console.error('[useSettings] Save failed:', err.message);
} finally { } finally {
setSaving(false); setSaving(false);
} }

View 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` | |

View File

@@ -163,7 +163,7 @@ function getRecentEpisodes(sessionId, limit = EPISODIC.DEFAULT_RECENT_LIMIT) {
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
`); `);
return stmt.all(limit).map(parseRow); return stmt.all(sessionId, limit).map(parseRow);
} }