nlm-memory 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -34
- package/dist/cli/nlm.js +223 -33
- package/dist/cli/nlm.js.map +1 -1
- package/dist/core/adapters/cursor.d.ts +45 -0
- package/dist/core/adapters/cursor.js +397 -0
- package/dist/core/adapters/cursor.js.map +1 -0
- package/dist/core/adapters/from-source.js +10 -0
- package/dist/core/adapters/from-source.js.map +1 -1
- package/dist/core/adapters/windsurf.d.ts +44 -0
- package/dist/core/adapters/windsurf.js +299 -0
- package/dist/core/adapters/windsurf.js.map +1 -0
- package/dist/core/hook/claude-settings.d.ts +12 -5
- package/dist/core/hook/claude-settings.js +21 -6
- package/dist/core/hook/claude-settings.js.map +1 -1
- package/dist/core/sources/source-registry.d.ts +1 -1
- package/dist/core/sources/source-registry.js +18 -0
- package/dist/core/sources/source-registry.js.map +1 -1
- package/dist/core/storage/sqlite-session-store.d.ts +2 -0
- package/dist/core/storage/sqlite-session-store.js +38 -2
- package/dist/core/storage/sqlite-session-store.js.map +1 -1
- package/dist/hook/hook-auth.d.ts +13 -0
- package/dist/hook/hook-auth.js +19 -0
- package/dist/hook/hook-auth.js.map +1 -0
- package/dist/hook/prompt-recall-hook.js +7 -1
- package/dist/hook/prompt-recall-hook.js.map +1 -1
- package/dist/hook/session-start-hook.js +4 -1
- package/dist/hook/session-start-hook.js.map +1 -1
- package/dist/hook/stop-hook.js +4 -1
- package/dist/hook/stop-hook.js.map +1 -1
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.js +76 -1
- package/dist/http/app.js.map +1 -1
- package/dist/install/claude-code.js +1 -1
- package/dist/install/claude-code.js.map +1 -1
- package/dist/install/cursor.d.ts +25 -0
- package/dist/install/cursor.js +43 -0
- package/dist/install/cursor.js.map +1 -0
- package/dist/install/nlm-dir-perms.d.ts +19 -0
- package/dist/install/nlm-dir-perms.js +43 -0
- package/dist/install/nlm-dir-perms.js.map +1 -0
- package/dist/install/ollama.d.ts +18 -1
- package/dist/install/ollama.js +62 -7
- package/dist/install/ollama.js.map +1 -1
- package/dist/install/setup.d.ts +4 -0
- package/dist/install/setup.js +141 -18
- package/dist/install/setup.js.map +1 -1
- package/dist/install/windsurf.d.ts +25 -0
- package/dist/install/windsurf.js +43 -0
- package/dist/install/windsurf.js.map +1 -0
- package/dist/mcp/server.js +20 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/types.d.ts +4 -0
- package/dist/ui/assets/{index-BA6IpU8g.css → index-Beo8psd-.css} +1 -1
- package/dist/ui/assets/index-CSPTTeeM.js +69 -0
- package/dist/ui/index.html +2 -2
- package/package.json +26 -1
- package/plugin/scripts/prompt-recall-hook.mjs +55 -4
- package/plugin/scripts/stop-hook.mjs +57 -6
- package/.agents/plugins/marketplace.json +0 -20
- package/.github/workflows/ci.yml +0 -30
- package/dist/ui/assets/index-B_qIVV0k.js +0 -69
- package/docs/methodology/re-derivation-rate.md +0 -112
- package/docs/methodology/useful-hit-rate.md +0 -79
- package/docs/plans/2026-05-20-fts5-lexical-recall.md +0 -1088
- package/docs/plans/2026-05-20-recall-daemon-wedge-fix.md +0 -662
- package/docs/plans/2026-05-20-recall-hook-design.md +0 -131
- package/docs/plans/2026-05-20-recall-hook-implementation.md +0 -1222
- package/docs/plans/desktop-product.md +0 -69
- package/docs/plans/factstore-design.md +0 -236
- package/logs/CHANGELOG/CHANGELOG-2026.md +0 -1389
- package/logs/CHANGELOG/CHANGELOG.md +0 -337
- package/migrations/000_initial_schema.sql +0 -174
- package/migrations/001_entity_type_rename.sql +0 -17
- package/migrations/002_adapter_state_extend.sql +0 -12
- package/migrations/003_session_embeddings.sql +0 -11
- package/migrations/004_facts.sql +0 -46
- package/migrations/005_sources.sql +0 -31
- package/migrations/006_providers.sql +0 -33
- package/migrations/007_source_tokens.sql +0 -17
- package/migrations/008_fts_rebuild.sql +0 -9
- package/migrations/009_session_embedding_chunks.sql +0 -46
- package/migrations/010_sources_opencode.sql +0 -30
- package/migrations/011_sources_hermes_agent.sql +0 -30
- package/migrations/012_sources_aider.sql +0 -30
- package/migrations/013_adapter_state_failure_count.sql +0 -12
- package/plugin-hermes-agent/README.md +0 -49
- package/plugin-hermes-agent/__init__.py +0 -75
- package/plugin-hermes-agent/plugin.yaml +0 -15
- package/scripts/backfill-citations.mjs +0 -0
- package/scripts/build-codex-plugin.mjs +0 -61
- package/scripts/deepseek-probe.mjs +0 -67
- package/scripts/extract-triples.mjs +0 -207
- package/scripts/longmemeval/embedding-cache.ts +0 -77
- package/scripts/longmemeval/fetch-dataset.sh +0 -25
- package/scripts/longmemeval/run-harness.ts +0 -315
- package/scripts/longmemeval/scorer.ts +0 -99
- package/scripts/longmemeval/tsconfig.json +0 -9
- package/scripts/longmemeval/types.ts +0 -35
- package/scripts/nlm-daily-digest.py +0 -239
- package/scripts/nlm-daily-digest.sh +0 -28
- package/src/cli/classify-parity.ts +0 -257
- package/src/cli/launchctl-helpers.ts +0 -49
- package/src/cli/nlm.ts +0 -885
- package/src/core/actions/actions-log.ts +0 -118
- package/src/core/actions/overlay.ts +0 -117
- package/src/core/adapters/aider.ts +0 -205
- package/src/core/adapters/claude-code.ts +0 -293
- package/src/core/adapters/common.ts +0 -54
- package/src/core/adapters/from-source.ts +0 -57
- package/src/core/adapters/hermes-agent.ts +0 -240
- package/src/core/adapters/hermes.ts +0 -277
- package/src/core/adapters/jsonl-generic.ts +0 -208
- package/src/core/adapters/opencode.ts +0 -281
- package/src/core/adapters/pi.ts +0 -264
- package/src/core/classifier/prompt.ts +0 -200
- package/src/core/dataset/build-dataset.ts +0 -463
- package/src/core/embedding/chunk-body.ts +0 -76
- package/src/core/embedding/embed-backfill.ts +0 -210
- package/src/core/embedding/embed-normalize.ts +0 -135
- package/src/core/facts/backfill-facts.ts +0 -254
- package/src/core/facts/extract-facts.ts +0 -50
- package/src/core/hook/citation-detect.ts +0 -124
- package/src/core/hook/cite-memo.ts +0 -68
- package/src/core/hook/claude-settings.ts +0 -166
- package/src/core/hook/gate.ts +0 -25
- package/src/core/hook/hook-log.ts +0 -41
- package/src/core/hook/memo-sweep.ts +0 -164
- package/src/core/hook/memo.ts +0 -67
- package/src/core/hook/pointer-block.ts +0 -26
- package/src/core/hook/select.ts +0 -32
- package/src/core/hook/transcript.ts +0 -121
- package/src/core/ingest/ingest-session.ts +0 -111
- package/src/core/providers/provider-models.ts +0 -100
- package/src/core/providers/provider-registry.ts +0 -196
- package/src/core/recall/citation-log.ts +0 -108
- package/src/core/recall/filter.ts +0 -27
- package/src/core/recall/index.ts +0 -6
- package/src/core/recall/match-fields.ts +0 -40
- package/src/core/recall/query-log.ts +0 -149
- package/src/core/recall/query-shape.ts +0 -66
- package/src/core/recall/recall-service.ts +0 -320
- package/src/core/recall/recent-log.ts +0 -59
- package/src/core/recall/tokenize.ts +0 -18
- package/src/core/recall/useful-scan.ts +0 -336
- package/src/core/recall-facts/fact-query-log.ts +0 -150
- package/src/core/recall-facts/fact-recall-service.ts +0 -327
- package/src/core/scheduler/scan-once.ts +0 -142
- package/src/core/scheduler/scheduler.ts +0 -225
- package/src/core/sources/source-registry.ts +0 -260
- package/src/core/storage/db-restore.ts +0 -133
- package/src/core/storage/live-status.ts +0 -45
- package/src/core/storage/migrate.ts +0 -72
- package/src/core/storage/sqlite-fact-store.ts +0 -304
- package/src/core/storage/sqlite-session-store.ts +0 -765
- package/src/hook/prompt-recall-hook.ts +0 -174
- package/src/hook/session-end-hook.ts +0 -81
- package/src/hook/session-start-hook.ts +0 -165
- package/src/hook/stop-hook.ts +0 -236
- package/src/http/app.ts +0 -1137
- package/src/install/claude-code.ts +0 -128
- package/src/install/codex.ts +0 -367
- package/src/install/hermes-agent.ts +0 -76
- package/src/install/hermes.ts +0 -78
- package/src/install/ollama.ts +0 -211
- package/src/install/setup.ts +0 -368
- package/src/llm/classifier-box.ts +0 -64
- package/src/llm/deepseek-client.ts +0 -150
- package/src/llm/env-autoload.ts +0 -55
- package/src/llm/ollama-client.ts +0 -189
- package/src/mcp/server.ts +0 -534
- package/src/ports/fact-store.ts +0 -102
- package/src/ports/llm-client.ts +0 -52
- package/src/ports/logger.ts +0 -16
- package/src/ports/session-store.ts +0 -45
- package/src/ports/transcript-adapter.ts +0 -55
- package/src/shared/types.ts +0 -145
- package/src/ui/App.tsx +0 -58
- package/src/ui/components/PromoteOpenButton.tsx +0 -65
- package/src/ui/components/SessionDrawer.tsx +0 -136
- package/src/ui/components/SideNav.tsx +0 -162
- package/src/ui/components/Skeleton.tsx +0 -107
- package/src/ui/index.html +0 -13
- package/src/ui/lib/actions.ts +0 -30
- package/src/ui/lib/api.ts +0 -92
- package/src/ui/lib/dataset.ts +0 -141
- package/src/ui/lib/registries.ts +0 -155
- package/src/ui/lib/view-settings.ts +0 -41
- package/src/ui/main.tsx +0 -15
- package/src/ui/pages/Live.tsx +0 -229
- package/src/ui/pages/Pulse.tsx +0 -415
- package/src/ui/pages/Recall.tsx +0 -190
- package/src/ui/pages/River.tsx +0 -308
- package/src/ui/pages/Search.tsx +0 -93
- package/src/ui/pages/Stub.tsx +0 -9
- package/src/ui/pages/Thread.tsx +0 -262
- package/src/ui/pages/settings/Classifier.tsx +0 -227
- package/src/ui/pages/settings/Data.tsx +0 -190
- package/src/ui/pages/settings/Index.tsx +0 -65
- package/src/ui/pages/settings/Labels.tsx +0 -224
- package/src/ui/pages/settings/Providers.tsx +0 -305
- package/src/ui/pages/settings/SettingsSubnav.tsx +0 -28
- package/src/ui/pages/settings/Sources.tsx +0 -326
- package/src/ui/pages/settings/Views.tsx +0 -96
- package/src/ui/styles.css +0 -1766
- package/src/ui/tsconfig.json +0 -21
- package/src/ui/vite.config.ts +0 -19
- package/tests/fixtures/claude_code/short_session.jsonl +0 -2
- package/tests/fixtures/claude_code/standard_iso.jsonl +0 -4
- package/tests/fixtures/claude_code/tool_heavy.jsonl +0 -8
- package/tests/fixtures/claude_code/with_subagent.jsonl +0 -7
- package/tests/fixtures/facts.ts +0 -17
- package/tests/fixtures/golden-corpus.ts +0 -85
- package/tests/fixtures/hermes/paired_request_dump.json +0 -24
- package/tests/fixtures/hermes/paired_session.json +0 -23
- package/tests/fixtures/hermes/request_dump.json +0 -28
- package/tests/fixtures/hermes/session_iso.json +0 -38
- package/tests/fixtures/hermes/session_unix.json +0 -38
- package/tests/fixtures/hermes/system_only.json +0 -18
- package/tests/fixtures/pi/error-connection-abort.jsonl +0 -8
- package/tests/fixtures/pi/short-successful.jsonl +0 -5
- package/tests/fixtures/pi/with-custom-message.jsonl +0 -6
- package/tests/fixtures/sessions.ts +0 -22
- package/tests/integration/backfill-facts.test.ts +0 -362
- package/tests/integration/citation-explicit.test.ts +0 -111
- package/tests/integration/cite-event.test.ts +0 -169
- package/tests/integration/cite-memo.test.ts +0 -87
- package/tests/integration/db-restore.test.ts +0 -153
- package/tests/integration/embed-backfill.test.ts +0 -176
- package/tests/integration/fact-supersedence.test.ts +0 -313
- package/tests/integration/fts-index.test.ts +0 -60
- package/tests/integration/getbyids-sqlite.test.ts +0 -60
- package/tests/integration/hermes-agent-hooks.test.ts +0 -248
- package/tests/integration/hook-claude-settings.test.ts +0 -205
- package/tests/integration/hook-log.test.ts +0 -54
- package/tests/integration/hook-memo.test.ts +0 -68
- package/tests/integration/hook-pre-compact.test.ts +0 -105
- package/tests/integration/hook-subagent-start.test.ts +0 -102
- package/tests/integration/http.test.ts +0 -401
- package/tests/integration/keyword-search-fts.test.ts +0 -66
- package/tests/integration/mcp-recall-logging.test.ts +0 -88
- package/tests/integration/mcp.test.ts +0 -248
- package/tests/integration/memo-sweep.test.ts +0 -91
- package/tests/integration/prompt-recall-hook.test.ts +0 -88
- package/tests/integration/provider-registry.test.ts +0 -107
- package/tests/integration/recall-golden.test.ts +0 -59
- package/tests/integration/recall-sqlite.test.ts +0 -169
- package/tests/integration/scheduler.test.ts +0 -391
- package/tests/integration/session-end-hook.test.ts +0 -48
- package/tests/integration/session-start-hook.test.ts +0 -126
- package/tests/integration/source-registry.test.ts +0 -120
- package/tests/integration/sqlite-fact-store.test.ts +0 -346
- package/tests/integration/stop-hook.test.ts +0 -560
- package/tests/integration/wal-checkpoint.test.ts +0 -49
- package/tests/unit/cli/launchctl-helpers.test.ts +0 -60
- package/tests/unit/core/adapters/aider.test.ts +0 -230
- package/tests/unit/core/adapters/claude-code.test.ts +0 -118
- package/tests/unit/core/adapters/hermes-agent.test.ts +0 -329
- package/tests/unit/core/adapters/hermes.test.ts +0 -81
- package/tests/unit/core/adapters/jsonl-generic.test.ts +0 -142
- package/tests/unit/core/adapters/opencode.test.ts +0 -354
- package/tests/unit/core/adapters/pi.test.ts +0 -110
- package/tests/unit/core/classifier/prompt.test.ts +0 -126
- package/tests/unit/core/embedding/chunk-body.test.ts +0 -100
- package/tests/unit/core/facts/extract-facts.test.ts +0 -117
- package/tests/unit/core/filter.test.ts +0 -40
- package/tests/unit/core/hook/citation-detect-cite-session.test.ts +0 -96
- package/tests/unit/core/hook/citation-detect.test.ts +0 -124
- package/tests/unit/core/hook/gate.test.ts +0 -29
- package/tests/unit/core/hook/pointer-block.test.ts +0 -22
- package/tests/unit/core/hook/select.test.ts +0 -66
- package/tests/unit/core/match-fields.test.ts +0 -39
- package/tests/unit/core/mcp-cite-session.test.ts +0 -51
- package/tests/unit/core/providers/provider-models.test.ts +0 -101
- package/tests/unit/core/query-shape.test.ts +0 -92
- package/tests/unit/core/recall-facts/fact-recall-service.test.ts +0 -258
- package/tests/unit/core/recall-service.test.ts +0 -200
- package/tests/unit/core/storage/live-status.test.ts +0 -54
- package/tests/unit/core/tokenize.test.ts +0 -32
- package/tests/unit/core/useful-scan.test.ts +0 -537
- package/tests/unit/llm/embed.test.ts +0 -93
- package/tests/unit/llm/ollama-client.test.ts +0 -124
- package/tests/unit/scripts/longmemeval-scorer.test.ts +0 -114
- package/tsconfig.json +0 -31
- package/tsconfig.test.json +0 -11
- package/vitest.config.ts +0 -22
|
@@ -1,765 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SqliteSessionStore — the canonical SessionStore implementation backed by
|
|
3
|
-
* better-sqlite3 with the sqlite-vec extension loaded for KNN search.
|
|
4
|
-
*
|
|
5
|
-
* Layering note: core/ imports this concrete class only at the composition
|
|
6
|
-
* root (CLI / server bootstrap). The recall use case and every other piece
|
|
7
|
-
* of core depends on the SessionStore *port*, never on this file.
|
|
8
|
-
*
|
|
9
|
-
* Schema parity with the Python daemon: sessions row + session_entities +
|
|
10
|
-
* markers + session_embeddings (vec0). Idle-status overlay (computed from
|
|
11
|
-
* transcript mtime) is deferred to a later phase — A.2 returns the persisted
|
|
12
|
-
* status verbatim.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
16
|
-
import { dirname, resolve } from "node:path";
|
|
17
|
-
import Database from "better-sqlite3";
|
|
18
|
-
import * as sqliteVec from "sqlite-vec";
|
|
19
|
-
import type {
|
|
20
|
-
KeywordNeighbor,
|
|
21
|
-
SemanticNeighbor,
|
|
22
|
-
SessionFilter,
|
|
23
|
-
SessionStore,
|
|
24
|
-
} from "@ports/session-store.js";
|
|
25
|
-
import type {
|
|
26
|
-
Session,
|
|
27
|
-
SessionStatus,
|
|
28
|
-
} from "@shared/types.js";
|
|
29
|
-
import { liveSessionStatus } from "./live-status.js";
|
|
30
|
-
import { loadActionOverlay, openQuestionId } from "@core/actions/overlay.js";
|
|
31
|
-
import type { ActionOverlay } from "@core/actions/overlay.js";
|
|
32
|
-
import type { Fact } from "@shared/types.js";
|
|
33
|
-
import { runMigrations } from "./migrate.js";
|
|
34
|
-
import type { SqliteFactStore } from "./sqlite-fact-store.js";
|
|
35
|
-
import { tokenize } from "@core/recall/tokenize.js";
|
|
36
|
-
import { chunkSessionText } from "@core/embedding/chunk-body.js";
|
|
37
|
-
|
|
38
|
-
export interface SqliteSessionStoreOptions {
|
|
39
|
-
readonly dbPath: string;
|
|
40
|
-
readonly migrationsDir: string;
|
|
41
|
-
readonly readonly?: boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Full ingest payload for SqliteSessionStore.insertSession. */
|
|
45
|
-
export interface IngestRecord {
|
|
46
|
-
readonly id: string;
|
|
47
|
-
readonly runtime: string;
|
|
48
|
-
readonly runtimeSessionId: string | null;
|
|
49
|
-
readonly startedAt: string;
|
|
50
|
-
readonly endedAt: string | null;
|
|
51
|
-
readonly durationMin: number | null;
|
|
52
|
-
readonly label: string;
|
|
53
|
-
readonly summary: string;
|
|
54
|
-
readonly body: string | null;
|
|
55
|
-
readonly status: SessionStatus;
|
|
56
|
-
readonly transcriptKind: string | null;
|
|
57
|
-
readonly transcriptPath: string | null;
|
|
58
|
-
readonly transcriptOffset: number | null;
|
|
59
|
-
readonly transcriptLength: number | null;
|
|
60
|
-
readonly entities: ReadonlyArray<string>;
|
|
61
|
-
readonly decisions: ReadonlyArray<string>;
|
|
62
|
-
readonly openQuestions: ReadonlyArray<string>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type SessionRow = {
|
|
66
|
-
id: string;
|
|
67
|
-
runtime: string;
|
|
68
|
-
runtime_session_id: string | null;
|
|
69
|
-
started_at: string;
|
|
70
|
-
ended_at: string | null;
|
|
71
|
-
duration_min: number | null;
|
|
72
|
-
label: string;
|
|
73
|
-
summary: string;
|
|
74
|
-
status: "active" | "closed" | "superseded";
|
|
75
|
-
transcript_kind: string | null;
|
|
76
|
-
transcript_path: string | null;
|
|
77
|
-
body: string | null;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
type EntityRow = { session_id: string; entity_canonical: string };
|
|
81
|
-
type MarkerRow = { session_id: string; kind: "decision" | "open"; text: string };
|
|
82
|
-
type NeighborRow = { session_id: string; distance: number };
|
|
83
|
-
type KeywordRow = { session_id: string; score: number };
|
|
84
|
-
|
|
85
|
-
export interface RecentWrite {
|
|
86
|
-
id: string;
|
|
87
|
-
runtime: string;
|
|
88
|
-
label: string;
|
|
89
|
-
summary: string;
|
|
90
|
-
createdAt: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface RecentMarker {
|
|
94
|
-
sessionId: string;
|
|
95
|
-
kind: "decision" | "open";
|
|
96
|
-
text: string;
|
|
97
|
-
label: string;
|
|
98
|
-
createdAt: string;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export class SqliteSessionStore implements SessionStore {
|
|
102
|
-
private readonly db: Database.Database;
|
|
103
|
-
|
|
104
|
-
constructor(opts: SqliteSessionStoreOptions) {
|
|
105
|
-
const dbPath = resolve(opts.dbPath);
|
|
106
|
-
const parent = dirname(dbPath);
|
|
107
|
-
if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
|
|
108
|
-
|
|
109
|
-
this.db = new Database(dbPath, opts.readonly ? { readonly: true } : {});
|
|
110
|
-
this.db.pragma("foreign_keys = ON");
|
|
111
|
-
this.db.pragma("journal_mode = WAL");
|
|
112
|
-
|
|
113
|
-
sqliteVec.load(this.db);
|
|
114
|
-
|
|
115
|
-
if (!opts.readonly) {
|
|
116
|
-
runMigrations(this.db, opts.migrationsDir);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
close(): void {
|
|
121
|
-
this.db.close();
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Drains the WAL into the main database and truncates the -wal file.
|
|
126
|
-
* WAL mode is on but nothing else checkpoints, so the file grows
|
|
127
|
-
* unbounded under continuous readers. The daemon calls this on an
|
|
128
|
-
* interval. Synchronous — keep the WAL small so each call is cheap.
|
|
129
|
-
*/
|
|
130
|
-
checkpoint(): void {
|
|
131
|
-
this.db.pragma("wal_checkpoint(TRUNCATE)");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Raw db handle for ingest helpers (Scheduler, scanOnce). Avoid using
|
|
135
|
-
* directly from the recall path — it bypasses the SessionStore port. */
|
|
136
|
-
rawDb(): Database.Database {
|
|
137
|
-
return this.db;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Recently-written sessions ordered by created_at desc. Powers /live Writes column. */
|
|
141
|
-
recentWrites(limit: number): RecentWrite[] {
|
|
142
|
-
return this.db
|
|
143
|
-
.prepare<[number], RecentWrite>(
|
|
144
|
-
`SELECT id, runtime, label, summary, created_at AS createdAt
|
|
145
|
-
FROM sessions
|
|
146
|
-
ORDER BY created_at DESC
|
|
147
|
-
LIMIT ?`,
|
|
148
|
-
)
|
|
149
|
-
.all(limit);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Recently-extracted markers ordered by session created_at desc. Powers /live Decisions column. */
|
|
153
|
-
recentMarkers(limit: number): RecentMarker[] {
|
|
154
|
-
return this.db
|
|
155
|
-
.prepare<[number], RecentMarker>(
|
|
156
|
-
`SELECT m.session_id AS sessionId, m.kind, m.text, s.label, s.created_at AS createdAt
|
|
157
|
-
FROM markers m
|
|
158
|
-
JOIN sessions s ON s.id = m.session_id
|
|
159
|
-
ORDER BY s.created_at DESC, m.position ASC
|
|
160
|
-
LIMIT ?`,
|
|
161
|
-
)
|
|
162
|
-
.all(limit);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Atomic ingest: writes the session row, markers, entity rows + links,
|
|
167
|
-
* supersedes edge (if any), and the embedding (best-effort) in one
|
|
168
|
-
* transaction. Idempotent on re-ingest — ON CONFLICT updates the session
|
|
169
|
-
* in place; markers are deleted and rewritten; entity links use INSERT OR
|
|
170
|
-
* IGNORE; embedding row is DELETE+INSERT (vec0 doesn't UPDATE).
|
|
171
|
-
*
|
|
172
|
-
* Mirrors Python's SQLiteStore.insert_session. Markdown projection is not
|
|
173
|
-
* yet ported and skipped here.
|
|
174
|
-
*/
|
|
175
|
-
async insertSession(
|
|
176
|
-
record: IngestRecord,
|
|
177
|
-
embedder: import("@ports/llm-client.js").LLMClient | null = null,
|
|
178
|
-
supersedes: string | null = null,
|
|
179
|
-
factSink: { factStore: SqliteFactStore; facts: ReadonlyArray<Fact> } | null = null,
|
|
180
|
-
): Promise<void> {
|
|
181
|
-
const db = this.db;
|
|
182
|
-
const txn = db.transaction(() => {
|
|
183
|
-
db.prepare(`
|
|
184
|
-
INSERT INTO sessions (
|
|
185
|
-
id, runtime, runtime_session_id, started_at, ended_at, duration_min,
|
|
186
|
-
label, summary, body, status,
|
|
187
|
-
transcript_kind, transcript_path, transcript_offset, transcript_length
|
|
188
|
-
) VALUES (@id, @runtime, @runtimeSessionId, @startedAt, @endedAt, @durationMin,
|
|
189
|
-
@label, @summary, @body, @status,
|
|
190
|
-
@transcriptKind, @transcriptPath, @transcriptOffset, @transcriptLength)
|
|
191
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
192
|
-
ended_at = excluded.ended_at,
|
|
193
|
-
duration_min = excluded.duration_min,
|
|
194
|
-
label = excluded.label,
|
|
195
|
-
summary = excluded.summary,
|
|
196
|
-
body = excluded.body,
|
|
197
|
-
status = excluded.status,
|
|
198
|
-
updated_at = datetime('now')
|
|
199
|
-
`).run({
|
|
200
|
-
id: record.id,
|
|
201
|
-
runtime: record.runtime,
|
|
202
|
-
runtimeSessionId: record.runtimeSessionId,
|
|
203
|
-
startedAt: record.startedAt,
|
|
204
|
-
endedAt: record.endedAt,
|
|
205
|
-
durationMin: record.durationMin,
|
|
206
|
-
label: record.label,
|
|
207
|
-
summary: record.summary,
|
|
208
|
-
body: record.body,
|
|
209
|
-
status: record.status === "idle" ? "active" : record.status,
|
|
210
|
-
transcriptKind: record.transcriptKind,
|
|
211
|
-
transcriptPath: record.transcriptPath,
|
|
212
|
-
transcriptOffset: record.transcriptOffset,
|
|
213
|
-
transcriptLength: record.transcriptLength,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
db.prepare("DELETE FROM markers WHERE session_id = ?").run(record.id);
|
|
217
|
-
const markerStmt = db.prepare(
|
|
218
|
-
"INSERT INTO markers (session_id, kind, text, position) VALUES (?, ?, ?, ?)",
|
|
219
|
-
);
|
|
220
|
-
record.decisions.forEach((d, i) => markerStmt.run(record.id, "decision", d.trim(), i));
|
|
221
|
-
record.openQuestions.forEach((q, i) => markerStmt.run(record.id, "open", q.trim(), i));
|
|
222
|
-
|
|
223
|
-
const insertEnt = db.prepare(`
|
|
224
|
-
INSERT OR IGNORE INTO entities
|
|
225
|
-
(canonical, type, status, source, first_seen_session, last_seen_session, session_count)
|
|
226
|
-
VALUES (?, 'candidate', 'candidate', 'auto-detected', ?, ?, 0)
|
|
227
|
-
`);
|
|
228
|
-
const touchEnt = db.prepare(`
|
|
229
|
-
UPDATE entities
|
|
230
|
-
SET last_seen_session = ?, session_count = session_count + 1, updated_at = datetime('now')
|
|
231
|
-
WHERE canonical = ?
|
|
232
|
-
`);
|
|
233
|
-
const linkEnt = db.prepare(
|
|
234
|
-
"INSERT OR IGNORE INTO session_entities (session_id, entity_canonical) VALUES (?, ?)",
|
|
235
|
-
);
|
|
236
|
-
for (const raw of record.entities) {
|
|
237
|
-
const name = raw.trim();
|
|
238
|
-
if (!name) continue;
|
|
239
|
-
insertEnt.run(name, record.id, record.id);
|
|
240
|
-
touchEnt.run(record.id, name);
|
|
241
|
-
linkEnt.run(record.id, name);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (supersedes) {
|
|
245
|
-
db.prepare(
|
|
246
|
-
`INSERT OR IGNORE INTO session_edges (from_session, to_session, kind)
|
|
247
|
-
VALUES (?, ?, 'supersedes')`,
|
|
248
|
-
).run(record.id, supersedes);
|
|
249
|
-
db.prepare(
|
|
250
|
-
"UPDATE sessions SET status = 'superseded', updated_at = datetime('now') WHERE id = ?",
|
|
251
|
-
).run(supersedes);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Facts ingest is part of the session txn — either both commit or both
|
|
255
|
-
// roll back. On re-ingest (ON CONFLICT updates the session above), we
|
|
256
|
-
// delete prior facts for this source_session_id before re-inserting so
|
|
257
|
-
// the row count matches the latest classifier output. Without this,
|
|
258
|
-
// re-ingest accumulates duplicates.
|
|
259
|
-
//
|
|
260
|
-
// Phase B.4 — deterministic supersedence on (subject, predicate)
|
|
261
|
-
// collision. For each new fact, after insert, look up any OTHER
|
|
262
|
-
// non-superseded fact with the same (subject, predicate). Mark the
|
|
263
|
-
// older one as superseded by the new fact's id. Always-supersede
|
|
264
|
-
// policy applies even when value is unchanged — same-value re-assertion
|
|
265
|
-
// carries new provenance (new source_session_id) and is informative
|
|
266
|
-
// history. See Section 2 of factstore-design.md.
|
|
267
|
-
//
|
|
268
|
-
// Ordering note: insertManyInTxn FIRST so the new fact id exists in
|
|
269
|
-
// facts(id) before any UPDATE sets superseded_by = newId (the FK
|
|
270
|
-
// would reject otherwise). The DELETE above plus the CASCADE-SET-NULL
|
|
271
|
-
// on superseded_by means re-ingest naturally repairs chains: if an
|
|
272
|
-
// earlier ingest of this session superseded a fact from another
|
|
273
|
-
// session, deleting our prior fact unlinks the chain; the loop below
|
|
274
|
-
// re-establishes it with the freshly-inserted row.
|
|
275
|
-
if (factSink !== null) {
|
|
276
|
-
this.applyFactsInTxn(record.id, factSink.factStore, factSink.facts);
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
txn();
|
|
280
|
-
|
|
281
|
-
// Embedding is best-effort and lives outside the txn so a slow Ollama
|
|
282
|
-
// doesn't block the row commit. Body is chunked into ≤MAX_CHUNK_CHARS
|
|
283
|
-
// windows (see chunk-body.ts) and each chunk embedded independently.
|
|
284
|
-
// Per-chunk embedder failures are tolerated; the chunks that did embed
|
|
285
|
-
// still contribute to recall.
|
|
286
|
-
if (embedder) {
|
|
287
|
-
const chunks = chunkSessionText({
|
|
288
|
-
label: record.label,
|
|
289
|
-
summary: record.summary,
|
|
290
|
-
body: record.body,
|
|
291
|
-
});
|
|
292
|
-
this.deleteSessionChunks(record.id);
|
|
293
|
-
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
|
294
|
-
const text = chunks[chunkIdx]!;
|
|
295
|
-
if (!text) continue;
|
|
296
|
-
try {
|
|
297
|
-
const { vector } = await embedder.embed(text, "document");
|
|
298
|
-
this.insertChunkEmbedding(record.id, chunkIdx, vector);
|
|
299
|
-
} catch {
|
|
300
|
-
// Per-chunk embedder failure must not roll the ingest back or
|
|
301
|
-
// abort subsequent chunks.
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (factSink !== null) {
|
|
306
|
-
await this.embedFacts(factSink.factStore, factSink.facts, embedder);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private deleteSessionChunks(sessionId: string): void {
|
|
312
|
-
const db = this.db;
|
|
313
|
-
const rows = db
|
|
314
|
-
.prepare<[string], { chunk_id: number }>(
|
|
315
|
-
"SELECT chunk_id FROM session_chunk_map WHERE session_id = ?",
|
|
316
|
-
)
|
|
317
|
-
.all(sessionId);
|
|
318
|
-
if (rows.length === 0) return;
|
|
319
|
-
const placeholders = rows.map(() => "?").join(",");
|
|
320
|
-
const ids = rows.map((r) => r.chunk_id);
|
|
321
|
-
db.prepare(
|
|
322
|
-
`DELETE FROM session_embedding_chunks WHERE chunk_id IN (${placeholders})`,
|
|
323
|
-
).run(...ids);
|
|
324
|
-
db.prepare("DELETE FROM session_chunk_map WHERE session_id = ?").run(sessionId);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private insertChunkEmbedding(
|
|
328
|
-
sessionId: string,
|
|
329
|
-
chunkIdx: number,
|
|
330
|
-
vector: Float32Array,
|
|
331
|
-
): void {
|
|
332
|
-
const db = this.db;
|
|
333
|
-
const blob = Buffer.from(
|
|
334
|
-
vector.buffer,
|
|
335
|
-
vector.byteOffset,
|
|
336
|
-
vector.byteLength,
|
|
337
|
-
);
|
|
338
|
-
// vec0 enforces strict integer typing on aux columns; better-sqlite3 binds
|
|
339
|
-
// JS numbers as FLOAT, so cast chunk_idx via BigInt to bind as INTEGER.
|
|
340
|
-
const idxInt = BigInt(chunkIdx);
|
|
341
|
-
const info = db
|
|
342
|
-
.prepare(
|
|
343
|
-
"INSERT INTO session_embedding_chunks (embedding, session_id, chunk_idx) VALUES (?, ?, ?)",
|
|
344
|
-
)
|
|
345
|
-
.run(blob, sessionId, idxInt);
|
|
346
|
-
const chunkId = Number(info.lastInsertRowid);
|
|
347
|
-
db.prepare(
|
|
348
|
-
"INSERT INTO session_chunk_map (chunk_id, session_id, chunk_idx) VALUES (?, ?, ?)",
|
|
349
|
-
).run(chunkId, sessionId, chunkIdx);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Phase B.5 — backfill entry point. Writes facts (with deterministic
|
|
354
|
-
* supersedence + best-effort embeddings) for an EXISTING session row
|
|
355
|
-
* without touching it. Opens its own transaction; callers must not be
|
|
356
|
-
* inside one. The session row must already exist in `sessions` or the
|
|
357
|
-
* FK on facts.source_session_id rejects.
|
|
358
|
-
*
|
|
359
|
-
* Use this when ingesting facts after the fact — e.g. running the
|
|
360
|
-
* classifier across a historical corpus that predates the B.2 ingest
|
|
361
|
-
* write path. The live ingest path (`insertSession`) keeps using the
|
|
362
|
-
* internal helpers directly so session+facts commit together.
|
|
363
|
-
*/
|
|
364
|
-
async insertFactsForSession(
|
|
365
|
-
sessionId: string,
|
|
366
|
-
factStore: SqliteFactStore,
|
|
367
|
-
facts: ReadonlyArray<Fact>,
|
|
368
|
-
embedder: import("@ports/llm-client.js").LLMClient | null = null,
|
|
369
|
-
): Promise<void> {
|
|
370
|
-
const db = this.db;
|
|
371
|
-
const txn = db.transaction(() => {
|
|
372
|
-
this.applyFactsInTxn(sessionId, factStore, facts);
|
|
373
|
-
});
|
|
374
|
-
txn();
|
|
375
|
-
if (embedder) {
|
|
376
|
-
await this.embedFacts(factStore, facts, embedder);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Sync core of the fact-ingest block. Runs inside an EXISTING transaction
|
|
382
|
-
* — opens no txn of its own. Used by both `insertSession` (Phase B.2
|
|
383
|
-
* atomic ingest) and `insertFactsForSession` (Phase B.5 backfill).
|
|
384
|
-
*
|
|
385
|
-
* Behavior (mirrored across both callers):
|
|
386
|
-
* 1. DELETE prior facts attributed to this session (idempotent on
|
|
387
|
-
* backfill, drops stale rows on re-ingest).
|
|
388
|
-
* 2. Insert all new facts atomically.
|
|
389
|
-
* 3. For each, mark the prior current (subject, predicate) fact as
|
|
390
|
-
* superseded — Phase B.4 deterministic supersedence policy.
|
|
391
|
-
*
|
|
392
|
-
* Ordering: inserts before updates so the supersedence FK target exists.
|
|
393
|
-
* CASCADE-SET-NULL on `superseded_by` handles chain repair on re-ingest.
|
|
394
|
-
*/
|
|
395
|
-
private applyFactsInTxn(
|
|
396
|
-
sessionId: string,
|
|
397
|
-
factStore: SqliteFactStore,
|
|
398
|
-
facts: ReadonlyArray<Fact>,
|
|
399
|
-
): void {
|
|
400
|
-
const db = this.db;
|
|
401
|
-
db.prepare("DELETE FROM facts WHERE source_session_id = ?").run(sessionId);
|
|
402
|
-
factStore.insertManyInTxn(facts);
|
|
403
|
-
if (facts.length === 0) return;
|
|
404
|
-
|
|
405
|
-
const findCollisionStmt = db.prepare<
|
|
406
|
-
[string, string, string],
|
|
407
|
-
{ id: string }
|
|
408
|
-
>(`
|
|
409
|
-
SELECT id
|
|
410
|
-
FROM facts
|
|
411
|
-
WHERE subject = ?
|
|
412
|
-
AND predicate = ?
|
|
413
|
-
AND superseded_by IS NULL
|
|
414
|
-
AND id != ?
|
|
415
|
-
ORDER BY created_at DESC
|
|
416
|
-
LIMIT 1
|
|
417
|
-
`);
|
|
418
|
-
const markSupersededStmt = db.prepare(
|
|
419
|
-
"UPDATE facts SET superseded_by = ? WHERE id = ?",
|
|
420
|
-
);
|
|
421
|
-
for (const fact of facts) {
|
|
422
|
-
const prior = findCollisionStmt.get(fact.subject, fact.predicate, fact.id);
|
|
423
|
-
if (prior) markSupersededStmt.run(fact.id, prior.id);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Best-effort per-fact embedding. Writes `${subject} ${predicate} ${value}`
|
|
429
|
-
* embeddings to fact_embeddings via FactStore.upsertEmbedding. Per-fact
|
|
430
|
-
* failures don't abort the batch, and never affect committed fact rows.
|
|
431
|
-
*/
|
|
432
|
-
private async embedFacts(
|
|
433
|
-
factStore: SqliteFactStore,
|
|
434
|
-
facts: ReadonlyArray<Fact>,
|
|
435
|
-
embedder: import("@ports/llm-client.js").LLMClient,
|
|
436
|
-
): Promise<void> {
|
|
437
|
-
for (const fact of facts) {
|
|
438
|
-
const factText = `${fact.subject} ${fact.predicate} ${fact.value}`.trim();
|
|
439
|
-
if (!factText) continue;
|
|
440
|
-
try {
|
|
441
|
-
const { vector } = await embedder.embed(factText, "document");
|
|
442
|
-
factStore.upsertEmbedding(fact.id, vector);
|
|
443
|
-
} catch {
|
|
444
|
-
// Per-fact embedding failure must not abort embedding of subsequent
|
|
445
|
-
// facts. The fact row stays current; semantic recall just misses it
|
|
446
|
-
// until a future re-ingest.
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async list(filter?: SessionFilter): Promise<ReadonlyArray<Session>> {
|
|
452
|
-
const rows = this.db
|
|
453
|
-
.prepare<[], SessionRow>(`
|
|
454
|
-
SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
|
|
455
|
-
label, summary, status, transcript_kind, transcript_path, body
|
|
456
|
-
FROM sessions
|
|
457
|
-
ORDER BY started_at ASC
|
|
458
|
-
`)
|
|
459
|
-
.all();
|
|
460
|
-
|
|
461
|
-
if (rows.length === 0) return [];
|
|
462
|
-
|
|
463
|
-
const ids = rows.map((r) => r.id);
|
|
464
|
-
const entitiesByIdMap = this.loadEntities(ids);
|
|
465
|
-
const markersByIdMap = this.loadMarkers(ids);
|
|
466
|
-
const overlay = loadActionOverlay(this.db);
|
|
467
|
-
|
|
468
|
-
const sessions = rows.map((r) => this.rowToSession(r, entitiesByIdMap, markersByIdMap, overlay));
|
|
469
|
-
|
|
470
|
-
if (!filter) return sessions;
|
|
471
|
-
return sessions.filter((s) => {
|
|
472
|
-
if (filter.entity !== undefined && !s.entities.includes(filter.entity)) {
|
|
473
|
-
return false;
|
|
474
|
-
}
|
|
475
|
-
if (filter.hasDecisions === true && s.decisions.length === 0) return false;
|
|
476
|
-
if (filter.hasOpenQuestions === true && s.open.length === 0) return false;
|
|
477
|
-
return true;
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
async getById(sessionId: string): Promise<Session | null> {
|
|
482
|
-
const row = this.db
|
|
483
|
-
.prepare<[string], SessionRow>(`
|
|
484
|
-
SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
|
|
485
|
-
label, summary, status, transcript_kind, transcript_path, body
|
|
486
|
-
FROM sessions
|
|
487
|
-
WHERE id = ?
|
|
488
|
-
`)
|
|
489
|
-
.get(sessionId);
|
|
490
|
-
|
|
491
|
-
if (!row) return null;
|
|
492
|
-
const entities = this.loadEntities([sessionId]);
|
|
493
|
-
const markers = this.loadMarkers([sessionId]);
|
|
494
|
-
const overlay = loadActionOverlay(this.db);
|
|
495
|
-
return this.rowToSession(row, entities, markers, overlay);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Batched session fetch for the recall path. Deliberately omits the
|
|
500
|
-
* `body` column — body is ~48KB/row of session markdown that recall
|
|
501
|
-
* never reads, and SELECTing it for the corpus is what wedged the
|
|
502
|
-
* daemon. Resolved sessions carry `body: ""`.
|
|
503
|
-
*/
|
|
504
|
-
async getByIds(ids: ReadonlyArray<string>): Promise<ReadonlyArray<Session>> {
|
|
505
|
-
if (ids.length === 0) return [];
|
|
506
|
-
const placeholders = ids.map(() => "?").join(",");
|
|
507
|
-
const rows = this.db
|
|
508
|
-
.prepare<string[], Omit<SessionRow, "body">>(`
|
|
509
|
-
SELECT id, runtime, runtime_session_id, started_at, ended_at, duration_min,
|
|
510
|
-
label, summary, status, transcript_kind, transcript_path
|
|
511
|
-
FROM sessions
|
|
512
|
-
WHERE id IN (${placeholders})
|
|
513
|
-
`)
|
|
514
|
-
.all(...ids);
|
|
515
|
-
|
|
516
|
-
if (rows.length === 0) return [];
|
|
517
|
-
const foundIds = rows.map((r) => r.id);
|
|
518
|
-
const entitiesByIdMap = this.loadEntities(foundIds);
|
|
519
|
-
const markersByIdMap = this.loadMarkers(foundIds);
|
|
520
|
-
const overlay = loadActionOverlay(this.db);
|
|
521
|
-
return rows.map((r) =>
|
|
522
|
-
this.rowToSession({ ...r, body: null }, entitiesByIdMap, markersByIdMap, overlay),
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async semanticSearch(
|
|
527
|
-
queryVector: Float32Array,
|
|
528
|
-
limit: number,
|
|
529
|
-
): Promise<ReadonlyArray<SemanticNeighbor>> {
|
|
530
|
-
const k = Math.max(1, Math.trunc(limit));
|
|
531
|
-
const blob = Buffer.from(
|
|
532
|
-
queryVector.buffer,
|
|
533
|
-
queryVector.byteOffset,
|
|
534
|
-
queryVector.byteLength,
|
|
535
|
-
);
|
|
536
|
-
// Overfetch chunks so the max-pool grouping has enough unique sessions
|
|
537
|
-
// even when several top chunks come from the same session. Default 4
|
|
538
|
-
// ≈ average chunks per session on the LongMemEval-S benchmark. Env-
|
|
539
|
-
// tunable via NLM_CHUNK_OVERFETCH for per-type ablation against the
|
|
540
|
-
// preference/assistant regressions where displacement is hypothesized.
|
|
541
|
-
const envOverfetch = Number.parseInt(process.env["NLM_CHUNK_OVERFETCH"] ?? "", 10);
|
|
542
|
-
const CHUNK_OVERFETCH = Number.isFinite(envOverfetch) && envOverfetch > 0 ? envOverfetch : 4;
|
|
543
|
-
const chunkK = k * CHUNK_OVERFETCH;
|
|
544
|
-
const rows = this.db
|
|
545
|
-
.prepare<[Buffer, number], NeighborRow>(`
|
|
546
|
-
SELECT session_id, distance
|
|
547
|
-
FROM session_embedding_chunks
|
|
548
|
-
WHERE embedding MATCH ?
|
|
549
|
-
AND k = ?
|
|
550
|
-
ORDER BY distance
|
|
551
|
-
`)
|
|
552
|
-
.all(blob, chunkK);
|
|
553
|
-
|
|
554
|
-
// Max-pool: keep the smallest distance (highest cosine) per session.
|
|
555
|
-
const best = new Map<string, number>();
|
|
556
|
-
for (const r of rows) {
|
|
557
|
-
const cur = best.get(r.session_id);
|
|
558
|
-
if (cur === undefined || r.distance < cur) {
|
|
559
|
-
best.set(r.session_id, r.distance);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
return [...best.entries()]
|
|
563
|
-
.map(([sessionId, distance]) => ({ sessionId, distance }))
|
|
564
|
-
.sort((a, b) => a.distance - b.distance)
|
|
565
|
-
.slice(0, k);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Lexical recall via the sessions_fts FTS5 index. BM25 column weights
|
|
570
|
-
* favour label over summary over body. Returns sessions ranked best-first
|
|
571
|
-
* with a positive score (the negated bm25() value — bm25 is more negative
|
|
572
|
-
* for better matches). User input is tokenized and rebuilt into a quoted
|
|
573
|
-
* OR query so FTS5 metacharacters cannot reach the MATCH parser.
|
|
574
|
-
*/
|
|
575
|
-
async keywordSearch(
|
|
576
|
-
query: string,
|
|
577
|
-
limit: number,
|
|
578
|
-
): Promise<ReadonlyArray<KeywordNeighbor>> {
|
|
579
|
-
const matchExpr = toMatchExpression(query);
|
|
580
|
-
if (!matchExpr) return [];
|
|
581
|
-
const k = Math.max(1, Math.trunc(limit));
|
|
582
|
-
const rows = this.db
|
|
583
|
-
.prepare<[string, number], KeywordRow>(`
|
|
584
|
-
SELECT s.id AS session_id,
|
|
585
|
-
-bm25(sessions_fts, 10.0, 4.0, 1.0) AS score
|
|
586
|
-
FROM sessions_fts
|
|
587
|
-
JOIN sessions s ON s.rowid = sessions_fts.rowid
|
|
588
|
-
WHERE sessions_fts MATCH ?
|
|
589
|
-
ORDER BY score DESC
|
|
590
|
-
LIMIT ?
|
|
591
|
-
`)
|
|
592
|
-
.all(matchExpr, k);
|
|
593
|
-
return rows.map((r) => ({ sessionId: r.session_id, score: r.score }));
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
async updateStatus(sessionId: string, status: SessionStatus): Promise<void> {
|
|
597
|
-
if (status === "idle") {
|
|
598
|
-
throw new Error("Cannot persist derived status 'idle' — only active/closed/superseded");
|
|
599
|
-
}
|
|
600
|
-
this.db
|
|
601
|
-
.prepare(
|
|
602
|
-
"UPDATE sessions SET status = ?, updated_at = datetime('now') WHERE id = ?",
|
|
603
|
-
)
|
|
604
|
-
.run(status, sessionId);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// ── insert helpers used by tests / future ingest path ─────────────────
|
|
608
|
-
insertSessionForTest(session: Session): void {
|
|
609
|
-
const stmt = this.db.prepare(`
|
|
610
|
-
INSERT INTO sessions (
|
|
611
|
-
id, runtime, runtime_session_id, started_at, ended_at, duration_min,
|
|
612
|
-
label, summary, body, status, transcript_kind, transcript_path
|
|
613
|
-
) VALUES (
|
|
614
|
-
@id, @runtime, @runtimeSessionId, @startedAt, @endedAt, @durationMin,
|
|
615
|
-
@label, @summary, @body, @status, @transcriptKind, @transcriptPath
|
|
616
|
-
)
|
|
617
|
-
`);
|
|
618
|
-
const status: SessionStatus = session.status === "idle" ? "active" : session.status;
|
|
619
|
-
stmt.run({
|
|
620
|
-
id: session.id,
|
|
621
|
-
runtime: session.runtime,
|
|
622
|
-
runtimeSessionId: session.runtimeSessionId,
|
|
623
|
-
startedAt: session.startedAt,
|
|
624
|
-
endedAt: session.endedAt,
|
|
625
|
-
durationMin: session.durationMin,
|
|
626
|
-
label: session.label,
|
|
627
|
-
summary: session.summary,
|
|
628
|
-
body: session.body,
|
|
629
|
-
status,
|
|
630
|
-
transcriptKind: session.transcriptKind,
|
|
631
|
-
transcriptPath: session.transcriptPath,
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
const entStmt = this.db.prepare(`
|
|
635
|
-
INSERT OR IGNORE INTO entities (canonical, type, status)
|
|
636
|
-
VALUES (?, 'candidate', 'active')
|
|
637
|
-
`);
|
|
638
|
-
const linkStmt = this.db.prepare(
|
|
639
|
-
"INSERT OR IGNORE INTO session_entities (session_id, entity_canonical) VALUES (?, ?)",
|
|
640
|
-
);
|
|
641
|
-
for (const e of session.entities) {
|
|
642
|
-
entStmt.run(e);
|
|
643
|
-
linkStmt.run(session.id, e);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const markerStmt = this.db.prepare(
|
|
647
|
-
"INSERT INTO markers (session_id, kind, text, position) VALUES (?, ?, ?, ?)",
|
|
648
|
-
);
|
|
649
|
-
session.decisions.forEach((d, i) => markerStmt.run(session.id, "decision", d, i));
|
|
650
|
-
session.open.forEach((q, i) => markerStmt.run(session.id, "open", q, i));
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
insertEmbeddingForTest(sessionId: string, vector: Float32Array): void {
|
|
654
|
-
this.insertChunkEmbeddingForTest(sessionId, 0, vector);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
insertChunkEmbeddingForTest(
|
|
658
|
-
sessionId: string,
|
|
659
|
-
chunkIdx: number,
|
|
660
|
-
vector: Float32Array,
|
|
661
|
-
): void {
|
|
662
|
-
this.insertChunkEmbedding(sessionId, chunkIdx, vector);
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// ── internal ──────────────────────────────────────────────────────────
|
|
666
|
-
private loadEntities(ids: ReadonlyArray<string>): Map<string, string[]> {
|
|
667
|
-
if (ids.length === 0) return new Map();
|
|
668
|
-
const placeholders = ids.map(() => "?").join(",");
|
|
669
|
-
const rows = this.db
|
|
670
|
-
.prepare<string[], EntityRow>(`
|
|
671
|
-
SELECT session_id, entity_canonical
|
|
672
|
-
FROM session_entities
|
|
673
|
-
WHERE session_id IN (${placeholders})
|
|
674
|
-
ORDER BY session_id
|
|
675
|
-
`)
|
|
676
|
-
.all(...ids);
|
|
677
|
-
|
|
678
|
-
const out = new Map<string, string[]>();
|
|
679
|
-
for (const r of rows) {
|
|
680
|
-
const list = out.get(r.session_id);
|
|
681
|
-
if (list) list.push(r.entity_canonical);
|
|
682
|
-
else out.set(r.session_id, [r.entity_canonical]);
|
|
683
|
-
}
|
|
684
|
-
return out;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
private loadMarkers(
|
|
688
|
-
ids: ReadonlyArray<string>,
|
|
689
|
-
): Map<string, { decisions: string[]; open: string[] }> {
|
|
690
|
-
if (ids.length === 0) return new Map();
|
|
691
|
-
const placeholders = ids.map(() => "?").join(",");
|
|
692
|
-
const rows = this.db
|
|
693
|
-
.prepare<string[], MarkerRow>(`
|
|
694
|
-
SELECT session_id, kind, text
|
|
695
|
-
FROM markers
|
|
696
|
-
WHERE session_id IN (${placeholders})
|
|
697
|
-
ORDER BY session_id, position
|
|
698
|
-
`)
|
|
699
|
-
.all(...ids);
|
|
700
|
-
|
|
701
|
-
const out = new Map<string, { decisions: string[]; open: string[] }>();
|
|
702
|
-
for (const r of rows) {
|
|
703
|
-
let bucket = out.get(r.session_id);
|
|
704
|
-
if (!bucket) {
|
|
705
|
-
bucket = { decisions: [], open: [] };
|
|
706
|
-
out.set(r.session_id, bucket);
|
|
707
|
-
}
|
|
708
|
-
if (r.kind === "decision") bucket.decisions.push(r.text);
|
|
709
|
-
else bucket.open.push(r.text);
|
|
710
|
-
}
|
|
711
|
-
return out;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
private rowToSession(
|
|
715
|
-
row: SessionRow,
|
|
716
|
-
entitiesById: Map<string, string[]>,
|
|
717
|
-
markersById: Map<string, { decisions: string[]; open: string[] }>,
|
|
718
|
-
overlay: ActionOverlay,
|
|
719
|
-
): Session {
|
|
720
|
-
const m = markersById.get(row.id);
|
|
721
|
-
const rawDecisions = m?.decisions ?? [];
|
|
722
|
-
const rawOpen = m?.open ?? [];
|
|
723
|
-
const activeOpen: string[] = [];
|
|
724
|
-
const promotedDecisions: string[] = [];
|
|
725
|
-
for (const text of rawOpen) {
|
|
726
|
-
const id = openQuestionId(row.id, text);
|
|
727
|
-
if (overlay.resolvedOpens.has(id)) continue;
|
|
728
|
-
const resolution = overlay.promotedOpens.get(id);
|
|
729
|
-
if (resolution !== undefined) {
|
|
730
|
-
promotedDecisions.push(resolution);
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
activeOpen.push(text);
|
|
734
|
-
}
|
|
735
|
-
return {
|
|
736
|
-
id: row.id,
|
|
737
|
-
runtime: row.runtime,
|
|
738
|
-
runtimeSessionId: row.runtime_session_id ?? "",
|
|
739
|
-
startedAt: row.started_at,
|
|
740
|
-
endedAt: row.ended_at,
|
|
741
|
-
durationMin: row.duration_min,
|
|
742
|
-
label: row.label,
|
|
743
|
-
summary: row.summary,
|
|
744
|
-
status: liveSessionStatus(row.transcript_path, row.status),
|
|
745
|
-
transcriptKind: row.transcript_kind ?? "",
|
|
746
|
-
transcriptPath: row.transcript_path,
|
|
747
|
-
body: row.body ?? "",
|
|
748
|
-
entities: entitiesById.get(row.id) ?? [],
|
|
749
|
-
decisions: [...rawDecisions, ...promotedDecisions],
|
|
750
|
-
open: activeOpen,
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* Builds a safe FTS5 MATCH expression from raw user input. Each indexable
|
|
757
|
-
* token becomes a double-quoted string literal; literals are OR-joined.
|
|
758
|
-
* Quoting neutralizes FTS5 operators (AND, OR, NEAR, *, parentheses, colon).
|
|
759
|
-
* Returns null when the query has no indexable tokens.
|
|
760
|
-
*/
|
|
761
|
-
function toMatchExpression(query: string): string | null {
|
|
762
|
-
const terms = tokenize(query);
|
|
763
|
-
if (terms.length === 0) return null;
|
|
764
|
-
return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
|
|
765
|
-
}
|