@pcircle/footprint 1.3.0 → 1.6.0
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/LICENSE +1 -1
- package/README.md +215 -137
- package/SKILL.md +77 -33
- package/bin/footprint.js +16 -0
- package/dist/src/adapters/claude.d.ts +2 -0
- package/dist/src/adapters/claude.d.ts.map +1 -0
- package/dist/src/adapters/claude.js +7 -0
- package/dist/src/adapters/claude.js.map +1 -0
- package/dist/src/adapters/codex.d.ts +2 -0
- package/dist/src/adapters/codex.d.ts.map +1 -0
- package/dist/src/adapters/codex.js +7 -0
- package/dist/src/adapters/codex.js.map +1 -0
- package/dist/src/adapters/gemini.d.ts +2 -0
- package/dist/src/adapters/gemini.d.ts.map +1 -0
- package/dist/src/adapters/gemini.js +7 -0
- package/dist/src/adapters/gemini.js.map +1 -0
- package/dist/src/adapters/index.d.ts +5 -0
- package/dist/src/adapters/index.d.ts.map +1 -0
- package/dist/src/adapters/index.js +12 -0
- package/dist/src/adapters/index.js.map +1 -0
- package/dist/src/adapters/structured-prefix.d.ts +10 -0
- package/dist/src/adapters/structured-prefix.d.ts.map +1 -0
- package/dist/src/adapters/structured-prefix.js +59 -0
- package/dist/src/adapters/structured-prefix.js.map +1 -0
- package/dist/src/adapters/types.d.ts +32 -0
- package/dist/src/adapters/types.d.ts.map +1 -0
- package/dist/src/adapters/types.js +2 -0
- package/dist/src/adapters/types.js.map +1 -0
- package/dist/src/analyzers/content-analyzer.d.ts.map +1 -1
- package/dist/src/analyzers/content-analyzer.js +20 -4
- package/dist/src/analyzers/content-analyzer.js.map +1 -1
- package/dist/src/cli/context-flow.d.ts +92 -0
- package/dist/src/cli/context-flow.d.ts.map +1 -0
- package/dist/src/cli/context-flow.js +724 -0
- package/dist/src/cli/context-flow.js.map +1 -0
- package/dist/src/cli/history-display.d.ts +27 -0
- package/dist/src/cli/history-display.d.ts.map +1 -0
- package/dist/src/cli/history-display.js +167 -0
- package/dist/src/cli/history-display.js.map +1 -0
- package/dist/src/cli/index.js +924 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/launch-spec.d.ts +31 -0
- package/dist/src/cli/launch-spec.d.ts.map +1 -0
- package/dist/src/cli/launch-spec.js +182 -0
- package/dist/src/cli/launch-spec.js.map +1 -0
- package/dist/src/cli/live-demo.d.ts +34 -0
- package/dist/src/cli/live-demo.d.ts.map +1 -0
- package/dist/src/cli/live-demo.js +254 -0
- package/dist/src/cli/live-demo.js.map +1 -0
- package/dist/src/cli/pty-transcript.d.ts +34 -0
- package/dist/src/cli/pty-transcript.d.ts.map +1 -0
- package/dist/src/cli/pty-transcript.js +174 -0
- package/dist/src/cli/pty-transcript.js.map +1 -0
- package/dist/src/cli/session-display.d.ts +74 -0
- package/dist/src/cli/session-display.d.ts.map +1 -0
- package/dist/src/cli/session-display.js +922 -0
- package/dist/src/cli/session-display.js.map +1 -0
- package/dist/src/cli/session-execution.d.ts +55 -0
- package/dist/src/cli/session-execution.d.ts.map +1 -0
- package/dist/src/cli/session-execution.js +817 -0
- package/dist/src/cli/session-execution.js.map +1 -0
- package/dist/src/cli/session-runtime.d.ts +5 -0
- package/dist/src/cli/session-runtime.d.ts.map +1 -0
- package/dist/src/cli/session-runtime.js +11 -0
- package/dist/src/cli/session-runtime.js.map +1 -0
- package/dist/src/cli/setup.d.ts.map +1 -1
- package/dist/src/cli/setup.js +36 -12
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/utils/env.d.ts +7 -2
- package/dist/src/cli/utils/env.d.ts.map +1 -1
- package/dist/src/cli/utils/env.js +37 -6
- package/dist/src/cli/utils/env.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +187 -33
- package/dist/src/index.js.map +1 -1
- package/dist/src/ingestion/deterministic.d.ts +3 -0
- package/dist/src/ingestion/deterministic.d.ts.map +1 -0
- package/dist/src/ingestion/deterministic.js +862 -0
- package/dist/src/ingestion/deterministic.js.map +1 -0
- package/dist/src/ingestion/index.d.ts +5 -0
- package/dist/src/ingestion/index.d.ts.map +1 -0
- package/dist/src/ingestion/index.js +27 -0
- package/dist/src/ingestion/index.js.map +1 -0
- package/dist/src/ingestion/semantic.d.ts +6 -0
- package/dist/src/ingestion/semantic.d.ts.map +1 -0
- package/dist/src/ingestion/semantic.js +627 -0
- package/dist/src/ingestion/semantic.js.map +1 -0
- package/dist/src/ingestion/types.d.ts +10 -0
- package/dist/src/ingestion/types.d.ts.map +1 -0
- package/dist/src/ingestion/types.js +2 -0
- package/dist/src/ingestion/types.js.map +1 -0
- package/dist/src/lib/context-memory.d.ts +140 -0
- package/dist/src/lib/context-memory.d.ts.map +1 -0
- package/dist/src/lib/context-memory.js +974 -0
- package/dist/src/lib/context-memory.js.map +1 -0
- package/dist/src/lib/crypto/decrypt.d.ts.map +1 -1
- package/dist/src/lib/crypto/decrypt.js +12 -8
- package/dist/src/lib/crypto/decrypt.js.map +1 -1
- package/dist/src/lib/crypto/encrypt.d.ts.map +1 -1
- package/dist/src/lib/crypto/encrypt.js +6 -3
- package/dist/src/lib/crypto/encrypt.js.map +1 -1
- package/dist/src/lib/crypto/key-derivation.d.ts +1 -1
- package/dist/src/lib/crypto/key-derivation.d.ts.map +1 -1
- package/dist/src/lib/crypto/key-derivation.js +11 -11
- package/dist/src/lib/crypto/key-derivation.js.map +1 -1
- package/dist/src/lib/history-handoff.d.ts +43 -0
- package/dist/src/lib/history-handoff.d.ts.map +1 -0
- package/dist/src/lib/history-handoff.js +179 -0
- package/dist/src/lib/history-handoff.js.map +1 -0
- package/dist/src/lib/observability.d.ts +3 -0
- package/dist/src/lib/observability.d.ts.map +1 -0
- package/dist/src/lib/observability.js +63 -0
- package/dist/src/lib/observability.js.map +1 -0
- package/dist/src/lib/session-artifacts.d.ts +51 -0
- package/dist/src/lib/session-artifacts.d.ts.map +1 -0
- package/dist/src/lib/session-artifacts.js +132 -0
- package/dist/src/lib/session-artifacts.js.map +1 -0
- package/dist/src/lib/session-filters.d.ts +11 -0
- package/dist/src/lib/session-filters.d.ts.map +1 -0
- package/dist/src/lib/session-filters.js +16 -0
- package/dist/src/lib/session-filters.js.map +1 -0
- package/dist/src/lib/session-history.d.ts +50 -0
- package/dist/src/lib/session-history.d.ts.map +1 -0
- package/dist/src/lib/session-history.js +73 -0
- package/dist/src/lib/session-history.js.map +1 -0
- package/dist/src/lib/session-trends.d.ts +129 -0
- package/dist/src/lib/session-trends.d.ts.map +1 -0
- package/dist/src/lib/session-trends.js +361 -0
- package/dist/src/lib/session-trends.js.map +1 -0
- package/dist/src/lib/storage/database.d.ts +257 -3
- package/dist/src/lib/storage/database.d.ts.map +1 -1
- package/dist/src/lib/storage/database.js +1836 -161
- package/dist/src/lib/storage/database.js.map +1 -1
- package/dist/src/lib/storage/export-sessions.d.ts +33 -0
- package/dist/src/lib/storage/export-sessions.d.ts.map +1 -0
- package/dist/src/lib/storage/export-sessions.js +525 -0
- package/dist/src/lib/storage/export-sessions.js.map +1 -0
- package/dist/src/lib/storage/export.d.ts +1 -2
- package/dist/src/lib/storage/export.d.ts.map +1 -1
- package/dist/src/lib/storage/export.js +46 -33
- package/dist/src/lib/storage/export.js.map +1 -1
- package/dist/src/lib/storage/index.d.ts +7 -6
- package/dist/src/lib/storage/index.d.ts.map +1 -1
- package/dist/src/lib/storage/index.js +6 -5
- package/dist/src/lib/storage/index.js.map +1 -1
- package/dist/src/lib/storage/salt-storage.d.ts +1 -1
- package/dist/src/lib/storage/salt-storage.d.ts.map +1 -1
- package/dist/src/lib/storage/salt-storage.js +26 -18
- package/dist/src/lib/storage/salt-storage.js.map +1 -1
- package/dist/src/lib/storage/schema.d.ts +7 -2
- package/dist/src/lib/storage/schema.d.ts.map +1 -1
- package/dist/src/lib/storage/schema.js +357 -40
- package/dist/src/lib/storage/schema.js.map +1 -1
- package/dist/src/lib/storage/types.d.ts +122 -0
- package/dist/src/lib/storage/types.d.ts.map +1 -1
- package/dist/src/lib/tool-wrapper.d.ts.map +1 -1
- package/dist/src/lib/tool-wrapper.js +2 -2
- package/dist/src/lib/tool-wrapper.js.map +1 -1
- package/dist/src/prompts/skill-prompt.d.ts +6 -0
- package/dist/src/prompts/skill-prompt.d.ts.map +1 -0
- package/dist/src/prompts/skill-prompt.js +138 -0
- package/dist/src/prompts/skill-prompt.js.map +1 -0
- package/dist/src/tools/capture-footprint.d.ts +2 -2
- package/dist/src/tools/capture-footprint.d.ts.map +1 -1
- package/dist/src/tools/capture-footprint.js +52 -11
- package/dist/src/tools/capture-footprint.js.map +1 -1
- package/dist/src/tools/confirm-context-link.d.ts +62 -0
- package/dist/src/tools/confirm-context-link.d.ts.map +1 -0
- package/dist/src/tools/confirm-context-link.js +36 -0
- package/dist/src/tools/confirm-context-link.js.map +1 -0
- package/dist/src/tools/context-schemas.d.ts +694 -0
- package/dist/src/tools/context-schemas.d.ts.map +1 -0
- package/dist/src/tools/context-schemas.js +171 -0
- package/dist/src/tools/context-schemas.js.map +1 -0
- package/dist/src/tools/delete-footprints.d.ts +18 -1
- package/dist/src/tools/delete-footprints.d.ts.map +1 -1
- package/dist/src/tools/delete-footprints.js +53 -5
- package/dist/src/tools/delete-footprints.js.map +1 -1
- package/dist/src/tools/export-footprints.d.ts +11 -3
- package/dist/src/tools/export-footprints.d.ts.map +1 -1
- package/dist/src/tools/export-footprints.js +48 -9
- package/dist/src/tools/export-footprints.js.map +1 -1
- package/dist/src/tools/export-sessions.d.ts +111 -0
- package/dist/src/tools/export-sessions.d.ts.map +1 -0
- package/dist/src/tools/export-sessions.js +136 -0
- package/dist/src/tools/export-sessions.js.map +1 -0
- package/dist/src/tools/get-context.d.ts +208 -0
- package/dist/src/tools/get-context.d.ts.map +1 -0
- package/dist/src/tools/get-context.js +27 -0
- package/dist/src/tools/get-context.js.map +1 -0
- package/dist/src/tools/get-footprint.d.ts +1 -7
- package/dist/src/tools/get-footprint.d.ts.map +1 -1
- package/dist/src/tools/get-footprint.js +7 -3
- package/dist/src/tools/get-footprint.js.map +1 -1
- package/dist/src/tools/get-history-handoff.d.ts +109 -0
- package/dist/src/tools/get-history-handoff.d.ts.map +1 -0
- package/dist/src/tools/get-history-handoff.js +85 -0
- package/dist/src/tools/get-history-handoff.js.map +1 -0
- package/dist/src/tools/get-history-trends.d.ts +155 -0
- package/dist/src/tools/get-history-trends.d.ts.map +1 -0
- package/dist/src/tools/get-history-trends.js +123 -0
- package/dist/src/tools/get-history-trends.js.map +1 -0
- package/dist/src/tools/get-session-artifacts.d.ts +151 -0
- package/dist/src/tools/get-session-artifacts.d.ts.map +1 -0
- package/dist/src/tools/get-session-artifacts.js +184 -0
- package/dist/src/tools/get-session-artifacts.js.map +1 -0
- package/dist/src/tools/get-session-decisions.d.ts +69 -0
- package/dist/src/tools/get-session-decisions.d.ts.map +1 -0
- package/dist/src/tools/get-session-decisions.js +99 -0
- package/dist/src/tools/get-session-decisions.js.map +1 -0
- package/dist/src/tools/get-session-messages.d.ts +55 -0
- package/dist/src/tools/get-session-messages.d.ts.map +1 -0
- package/dist/src/tools/get-session-messages.js +89 -0
- package/dist/src/tools/get-session-messages.js.map +1 -0
- package/dist/src/tools/get-session-narrative.d.ts +72 -0
- package/dist/src/tools/get-session-narrative.d.ts.map +1 -0
- package/dist/src/tools/get-session-narrative.js +106 -0
- package/dist/src/tools/get-session-narrative.js.map +1 -0
- package/dist/src/tools/get-session-timeline.d.ts +55 -0
- package/dist/src/tools/get-session-timeline.d.ts.map +1 -0
- package/dist/src/tools/get-session-timeline.js +93 -0
- package/dist/src/tools/get-session-timeline.js.map +1 -0
- package/dist/src/tools/get-session-trends.d.ts +108 -0
- package/dist/src/tools/get-session-trends.d.ts.map +1 -0
- package/dist/src/tools/get-session-trends.js +130 -0
- package/dist/src/tools/get-session-trends.js.map +1 -0
- package/dist/src/tools/get-session.d.ts +251 -0
- package/dist/src/tools/get-session.d.ts.map +1 -0
- package/dist/src/tools/get-session.js +290 -0
- package/dist/src/tools/get-session.js.map +1 -0
- package/dist/src/tools/index.d.ts +23 -3
- package/dist/src/tools/index.d.ts.map +1 -1
- package/dist/src/tools/index.js +23 -3
- package/dist/src/tools/index.js.map +1 -1
- package/dist/src/tools/list-contexts.d.ts +50 -0
- package/dist/src/tools/list-contexts.d.ts.map +1 -0
- package/dist/src/tools/list-contexts.js +28 -0
- package/dist/src/tools/list-contexts.js.map +1 -0
- package/dist/src/tools/list-footprints.d.ts +1 -15
- package/dist/src/tools/list-footprints.d.ts.map +1 -1
- package/dist/src/tools/list-footprints.js +17 -6
- package/dist/src/tools/list-footprints.js.map +1 -1
- package/dist/src/tools/list-sessions.d.ts +86 -0
- package/dist/src/tools/list-sessions.d.ts.map +1 -0
- package/dist/src/tools/list-sessions.js +97 -0
- package/dist/src/tools/list-sessions.js.map +1 -0
- package/dist/src/tools/manage-tags.d.ts +47 -0
- package/dist/src/tools/manage-tags.d.ts.map +1 -0
- package/dist/src/tools/manage-tags.js +109 -0
- package/dist/src/tools/manage-tags.js.map +1 -0
- package/dist/src/tools/merge-contexts.d.ts +58 -0
- package/dist/src/tools/merge-contexts.d.ts.map +1 -0
- package/dist/src/tools/merge-contexts.js +27 -0
- package/dist/src/tools/merge-contexts.js.map +1 -0
- package/dist/src/tools/move-session-context.d.ts +62 -0
- package/dist/src/tools/move-session-context.d.ts.map +1 -0
- package/dist/src/tools/move-session-context.js +33 -0
- package/dist/src/tools/move-session-context.js.map +1 -0
- package/dist/src/tools/reingest-session.d.ts +31 -0
- package/dist/src/tools/reingest-session.d.ts.map +1 -0
- package/dist/src/tools/reingest-session.js +43 -0
- package/dist/src/tools/reingest-session.js.map +1 -0
- package/dist/src/tools/reject-context-link.d.ts +58 -0
- package/dist/src/tools/reject-context-link.d.ts.map +1 -0
- package/dist/src/tools/reject-context-link.js +26 -0
- package/dist/src/tools/reject-context-link.js.map +1 -0
- package/dist/src/tools/resolve-context.d.ts +287 -0
- package/dist/src/tools/resolve-context.d.ts.map +1 -0
- package/dist/src/tools/resolve-context.js +35 -0
- package/dist/src/tools/resolve-context.js.map +1 -0
- package/dist/src/tools/search-footprints.d.ts +2 -16
- package/dist/src/tools/search-footprints.d.ts.map +1 -1
- package/dist/src/tools/search-footprints.js +23 -7
- package/dist/src/tools/search-footprints.js.map +1 -1
- package/dist/src/tools/search-history.d.ts +86 -0
- package/dist/src/tools/search-history.d.ts.map +1 -0
- package/dist/src/tools/search-history.js +103 -0
- package/dist/src/tools/search-history.js.map +1 -0
- package/dist/src/tools/session-ui-metadata.d.ts +15 -0
- package/dist/src/tools/session-ui-metadata.d.ts.map +1 -0
- package/dist/src/tools/session-ui-metadata.js +15 -0
- package/dist/src/tools/session-ui-metadata.js.map +1 -0
- package/dist/src/tools/set-active-context.d.ts +58 -0
- package/dist/src/tools/set-active-context.d.ts.map +1 -0
- package/dist/src/tools/set-active-context.js +26 -0
- package/dist/src/tools/set-active-context.js.map +1 -0
- package/dist/src/tools/split-context.d.ts +62 -0
- package/dist/src/tools/split-context.d.ts.map +1 -0
- package/dist/src/tools/split-context.js +36 -0
- package/dist/src/tools/split-context.js.map +1 -0
- package/dist/src/tools/suggest-capture.d.ts +1 -1
- package/dist/src/tools/suggest-capture.d.ts.map +1 -1
- package/dist/src/tools/suggest-capture.js +6 -2
- package/dist/src/tools/suggest-capture.js.map +1 -1
- package/dist/src/tools/verify-footprint.d.ts +7 -54
- package/dist/src/tools/verify-footprint.d.ts.map +1 -1
- package/dist/src/tools/verify-footprint.js +11 -8
- package/dist/src/tools/verify-footprint.js.map +1 -1
- package/dist/src/types.d.ts +6 -4
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/ui/register.d.ts +6 -1
- package/dist/src/ui/register.d.ts.map +1 -1
- package/dist/src/ui/register.js +60 -16
- package/dist/src/ui/register.js.map +1 -1
- package/dist/ui/dashboard.html +259 -875
- package/dist/ui/detail.html +124 -252
- package/dist/ui/export.html +133 -303
- package/dist/ui/session-dashboard-live.html +264 -0
- package/dist/ui/session-dashboard.html +329 -0
- package/dist/ui/session-detail-live.html +336 -0
- package/dist/ui/session-detail.html +355 -0
- package/package.json +61 -16
- package/dist/src/lib/errors/base-error.d.ts +0 -15
- package/dist/src/lib/errors/base-error.d.ts.map +0 -1
- package/dist/src/lib/errors/base-error.js +0 -34
- package/dist/src/lib/errors/base-error.js.map +0 -1
- package/dist/src/lib/errors/crypto-error.d.ts +0 -29
- package/dist/src/lib/errors/crypto-error.d.ts.map +0 -1
- package/dist/src/lib/errors/crypto-error.js +0 -43
- package/dist/src/lib/errors/crypto-error.js.map +0 -1
- package/dist/src/lib/errors/index.d.ts +0 -26
- package/dist/src/lib/errors/index.d.ts.map +0 -1
- package/dist/src/lib/errors/index.js +0 -26
- package/dist/src/lib/errors/index.js.map +0 -1
- package/dist/src/lib/errors/storage-error.d.ts +0 -25
- package/dist/src/lib/errors/storage-error.d.ts.map +0 -1
- package/dist/src/lib/errors/storage-error.js +0 -38
- package/dist/src/lib/errors/storage-error.js.map +0 -1
- package/dist/src/lib/errors/validation-error.d.ts +0 -21
- package/dist/src/lib/errors/validation-error.d.ts.map +0 -1
- package/dist/src/lib/errors/validation-error.js +0 -29
- package/dist/src/lib/errors/validation-error.js.map +0 -1
- package/dist/src/test-helpers.d.ts +0 -33
- package/dist/src/test-helpers.d.ts.map +0 -1
- package/dist/src/test-helpers.js +0 -108
- package/dist/src/test-helpers.js.map +0 -1
- package/dist/src/tools/get-tag-stats.d.ts +0 -30
- package/dist/src/tools/get-tag-stats.d.ts.map +0 -1
- package/dist/src/tools/get-tag-stats.js +0 -33
- package/dist/src/tools/get-tag-stats.js.map +0 -1
- package/dist/src/tools/remove-tag.d.ts +0 -22
- package/dist/src/tools/remove-tag.d.ts.map +0 -1
- package/dist/src/tools/remove-tag.js +0 -30
- package/dist/src/tools/remove-tag.js.map +0 -1
- package/dist/src/tools/rename-tag.d.ts +0 -24
- package/dist/src/tools/rename-tag.d.ts.map +0 -1
- package/dist/src/tools/rename-tag.js +0 -34
- package/dist/src/tools/rename-tag.js.map +0 -1
|
@@ -1,11 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
1
|
+
/* global Buffer, crypto */
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { getArtifactSearchableText, parseArtifactMetadata, } from "../session-artifacts.js";
|
|
6
|
+
import { traceSyncOperation } from "../observability.js";
|
|
7
|
+
import { createSchema } from "./schema.js";
|
|
8
|
+
function escapeLikePattern(pattern) {
|
|
9
|
+
return pattern.replace(/[%_\\]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
function normalizeWorkspaceKeyForMatching(value) {
|
|
12
|
+
const resolved = path.resolve(value);
|
|
13
|
+
try {
|
|
14
|
+
return fs.realpathSync.native(resolved);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function matchesWorkspaceKey(session, workspaceKey) {
|
|
21
|
+
const normalizedWorkspaceKey = normalizeWorkspaceKeyForMatching(workspaceKey);
|
|
22
|
+
return (normalizeWorkspaceKeyForMatching(session.projectRoot) ===
|
|
23
|
+
normalizedWorkspaceKey ||
|
|
24
|
+
normalizeWorkspaceKeyForMatching(session.cwd) === normalizedWorkspaceKey);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Append LIMIT/OFFSET clause to SQL query string.
|
|
28
|
+
* Mutates the params array by pushing limit/offset values.
|
|
29
|
+
* @returns The query string with pagination appended.
|
|
30
|
+
*/
|
|
31
|
+
function appendPaginationClause(query, params, limit, offset) {
|
|
32
|
+
const off = offset ?? 0;
|
|
33
|
+
if (limit !== undefined) {
|
|
34
|
+
query += " LIMIT ?";
|
|
35
|
+
params.push(limit);
|
|
36
|
+
if (off > 0) {
|
|
37
|
+
query += " OFFSET ?";
|
|
38
|
+
params.push(off);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (off > 0) {
|
|
42
|
+
query += " LIMIT -1 OFFSET ?";
|
|
43
|
+
params.push(off);
|
|
44
|
+
}
|
|
45
|
+
return query;
|
|
46
|
+
}
|
|
47
|
+
function formatDbError(action, error) {
|
|
48
|
+
return new Error(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
49
|
+
}
|
|
50
|
+
const TREND_FAILED_OUTCOME_PATTERN = /\b(?:fail|failed|error|timeout|timed-out|interrupted|non-zero)\b/i;
|
|
51
|
+
const TREND_SUCCEEDED_OUTCOME_PATTERN = /\b(?:success|succeeded|passed|completed|captured|ok)\b/i;
|
|
52
|
+
const SESSION_HISTORY_CACHE_VERSION_KEY = "session_history_cache_version";
|
|
53
|
+
const SESSION_TREND_CACHE_VERSION_KEY = "session_trend_cache_version";
|
|
54
|
+
const CURRENT_SESSION_HISTORY_CACHE_VERSION = 1;
|
|
55
|
+
const CURRENT_SESSION_TREND_CACHE_VERSION = 1;
|
|
3
56
|
/**
|
|
4
57
|
* Evidence database with CRUD operations
|
|
5
58
|
* Manages encrypted evidence storage with SQLite backend
|
|
6
59
|
*/
|
|
7
60
|
export class EvidenceDatabase {
|
|
8
61
|
db;
|
|
62
|
+
sessionHistoryCacheBackfilled = false;
|
|
63
|
+
sessionTrendAttemptsBackfilled = false;
|
|
9
64
|
/**
|
|
10
65
|
* Creates or opens an evidence database
|
|
11
66
|
* @param dbPath - Path to SQLite database file
|
|
@@ -15,12 +70,101 @@ export class EvidenceDatabase {
|
|
|
15
70
|
this.db = new Database(dbPath);
|
|
16
71
|
try {
|
|
17
72
|
createSchema(this.db);
|
|
73
|
+
this.initializeMaterializedCaches();
|
|
18
74
|
}
|
|
19
75
|
catch (error) {
|
|
20
76
|
// Clean up database connection on any initialization failure
|
|
21
77
|
this.db.close();
|
|
22
|
-
throw
|
|
78
|
+
throw formatDbError("initialize database", error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
getMetadataValue(key) {
|
|
82
|
+
const row = this.db
|
|
83
|
+
.prepare(`
|
|
84
|
+
SELECT value
|
|
85
|
+
FROM metadata
|
|
86
|
+
WHERE key = ?
|
|
87
|
+
`)
|
|
88
|
+
.get(key);
|
|
89
|
+
return row?.value ?? null;
|
|
90
|
+
}
|
|
91
|
+
getMetadataVersion(key) {
|
|
92
|
+
const value = this.getMetadataValue(key);
|
|
93
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
94
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
95
|
+
}
|
|
96
|
+
setMetadataValue(key, value) {
|
|
97
|
+
this.db
|
|
98
|
+
.prepare(`
|
|
99
|
+
INSERT INTO metadata (key, value)
|
|
100
|
+
VALUES (?, ?)
|
|
101
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
102
|
+
`)
|
|
103
|
+
.run(key, value);
|
|
104
|
+
}
|
|
105
|
+
getAllSessionIds() {
|
|
106
|
+
return this.db
|
|
107
|
+
.prepare(`
|
|
108
|
+
SELECT id
|
|
109
|
+
FROM sessions
|
|
110
|
+
ORDER BY startedAt ASC, id ASC
|
|
111
|
+
`)
|
|
112
|
+
.all().map((row) => row.id);
|
|
113
|
+
}
|
|
114
|
+
initializeMaterializedCaches() {
|
|
115
|
+
if (this.getMetadataVersion(SESSION_HISTORY_CACHE_VERSION_KEY) <
|
|
116
|
+
CURRENT_SESSION_HISTORY_CACHE_VERSION) {
|
|
117
|
+
this.rebuildAllSessionHistoryCaches();
|
|
118
|
+
this.setMetadataValue(SESSION_HISTORY_CACHE_VERSION_KEY, String(CURRENT_SESSION_HISTORY_CACHE_VERSION));
|
|
119
|
+
this.sessionHistoryCacheBackfilled = true;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
this.sessionHistoryCacheBackfilled = false;
|
|
123
|
+
this.ensureSessionHistoryCacheBackfilled();
|
|
124
|
+
}
|
|
125
|
+
if (this.getMetadataVersion(SESSION_TREND_CACHE_VERSION_KEY) <
|
|
126
|
+
CURRENT_SESSION_TREND_CACHE_VERSION) {
|
|
127
|
+
this.rebuildAllSessionTrendAttempts();
|
|
128
|
+
this.setMetadataValue(SESSION_TREND_CACHE_VERSION_KEY, String(CURRENT_SESSION_TREND_CACHE_VERSION));
|
|
129
|
+
this.sessionTrendAttemptsBackfilled = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.sessionTrendAttemptsBackfilled = false;
|
|
133
|
+
this.ensureSessionTrendAttemptsBackfilled();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
dbOp(action, fn) {
|
|
137
|
+
try {
|
|
138
|
+
return fn();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
throw formatDbError(action, error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
resolveActiveContextIdOrThrow(contextId) {
|
|
145
|
+
let currentId = contextId.trim();
|
|
146
|
+
const visited = new Set();
|
|
147
|
+
while (currentId) {
|
|
148
|
+
if (visited.has(currentId)) {
|
|
149
|
+
throw new Error(`Context merge loop detected for ${contextId}`);
|
|
150
|
+
}
|
|
151
|
+
visited.add(currentId);
|
|
152
|
+
const row = this.db
|
|
153
|
+
.prepare(`
|
|
154
|
+
SELECT id, status, mergedIntoContextId
|
|
155
|
+
FROM contexts
|
|
156
|
+
WHERE id = ?
|
|
157
|
+
`)
|
|
158
|
+
.get(currentId);
|
|
159
|
+
if (!row) {
|
|
160
|
+
throw new Error(`Context not found: ${contextId}`);
|
|
161
|
+
}
|
|
162
|
+
if (row.status !== "merged" || !row.mergedIntoContextId) {
|
|
163
|
+
return row.id;
|
|
164
|
+
}
|
|
165
|
+
currentId = row.mergedIntoContextId;
|
|
23
166
|
}
|
|
167
|
+
throw new Error(`Context not found: ${contextId}`);
|
|
24
168
|
}
|
|
25
169
|
/**
|
|
26
170
|
* Creates a new evidence record
|
|
@@ -30,7 +174,7 @@ export class EvidenceDatabase {
|
|
|
30
174
|
create(evidence) {
|
|
31
175
|
const id = crypto.randomUUID();
|
|
32
176
|
const now = new Date().toISOString();
|
|
33
|
-
|
|
177
|
+
return this.dbOp("create evidence", () => {
|
|
34
178
|
const stmt = this.db.prepare(`
|
|
35
179
|
INSERT INTO evidences (
|
|
36
180
|
id, timestamp, conversationId, llmProvider,
|
|
@@ -40,10 +184,7 @@ export class EvidenceDatabase {
|
|
|
40
184
|
`);
|
|
41
185
|
stmt.run(id, evidence.timestamp, evidence.conversationId, evidence.llmProvider, Buffer.from(evidence.encryptedContent), Buffer.from(evidence.nonce), evidence.contentHash, evidence.messageCount, evidence.gitCommitHash, evidence.gitTimestamp, evidence.tags, now, now);
|
|
42
186
|
return id;
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
throw new Error(`Failed to create evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
-
}
|
|
187
|
+
});
|
|
47
188
|
}
|
|
48
189
|
/**
|
|
49
190
|
* Finds evidence by ID
|
|
@@ -51,7 +192,7 @@ export class EvidenceDatabase {
|
|
|
51
192
|
* @returns Evidence or null if not found
|
|
52
193
|
*/
|
|
53
194
|
findById(id) {
|
|
54
|
-
|
|
195
|
+
return this.dbOp("find evidence by ID", () => {
|
|
55
196
|
const stmt = this.db.prepare(`
|
|
56
197
|
SELECT * FROM evidences WHERE id = ?
|
|
57
198
|
`);
|
|
@@ -60,11 +201,7 @@ export class EvidenceDatabase {
|
|
|
60
201
|
return null;
|
|
61
202
|
}
|
|
62
203
|
return this.rowToEvidence(row);
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
// Don't swallow database errors - re-throw with context
|
|
66
|
-
throw new Error(`Failed to find evidence by ID: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
-
}
|
|
204
|
+
});
|
|
68
205
|
}
|
|
69
206
|
/**
|
|
70
207
|
* Finds all evidences for a conversation
|
|
@@ -72,7 +209,7 @@ export class EvidenceDatabase {
|
|
|
72
209
|
* @returns Array of evidences (empty if none found)
|
|
73
210
|
*/
|
|
74
211
|
findByConversationId(conversationId) {
|
|
75
|
-
|
|
212
|
+
return this.dbOp("find evidences by conversationId", () => {
|
|
76
213
|
const stmt = this.db.prepare(`
|
|
77
214
|
SELECT * FROM evidences
|
|
78
215
|
WHERE conversationId = ?
|
|
@@ -80,10 +217,7 @@ export class EvidenceDatabase {
|
|
|
80
217
|
`);
|
|
81
218
|
const rows = stmt.all(conversationId);
|
|
82
219
|
return rows.map((row) => this.rowToEvidence(row));
|
|
83
|
-
}
|
|
84
|
-
catch (error) {
|
|
85
|
-
throw new Error(`Failed to find evidences by conversationId: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
-
}
|
|
220
|
+
});
|
|
87
221
|
}
|
|
88
222
|
/**
|
|
89
223
|
* Lists evidences with pagination
|
|
@@ -91,31 +225,1407 @@ export class EvidenceDatabase {
|
|
|
91
225
|
* @returns Array of evidences
|
|
92
226
|
*/
|
|
93
227
|
list(options) {
|
|
228
|
+
return this.dbOp("list evidences", () => {
|
|
229
|
+
const params = [];
|
|
230
|
+
const query = appendPaginationClause("SELECT * FROM evidences ORDER BY timestamp DESC", params, options?.limit, options?.offset);
|
|
231
|
+
const stmt = this.db.prepare(query);
|
|
232
|
+
const rows = stmt.all(...params);
|
|
233
|
+
return rows.map((row) => this.rowToEvidence(row));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
static joinSearchParts(parts) {
|
|
237
|
+
return parts
|
|
238
|
+
.map((part) => part.trim())
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.join("\n");
|
|
241
|
+
}
|
|
242
|
+
static appendSearchPart(existing, addition) {
|
|
243
|
+
const normalizedAddition = addition.trim();
|
|
244
|
+
if (!normalizedAddition) {
|
|
245
|
+
return existing;
|
|
246
|
+
}
|
|
247
|
+
return existing.trim()
|
|
248
|
+
? `${existing}\n${normalizedAddition}`
|
|
249
|
+
: normalizedAddition;
|
|
250
|
+
}
|
|
251
|
+
static normalizeTrendOutcome(value) {
|
|
252
|
+
if (!value) {
|
|
253
|
+
return "other";
|
|
254
|
+
}
|
|
255
|
+
if (TREND_FAILED_OUTCOME_PATTERN.test(value)) {
|
|
256
|
+
return "failed";
|
|
257
|
+
}
|
|
258
|
+
if (TREND_SUCCEEDED_OUTCOME_PATTERN.test(value)) {
|
|
259
|
+
return "succeeded";
|
|
260
|
+
}
|
|
261
|
+
return "other";
|
|
262
|
+
}
|
|
263
|
+
buildArtifactHistoryCache(artifacts) {
|
|
264
|
+
const issueKeys = new Set();
|
|
265
|
+
const text = EvidenceDatabase.joinSearchParts(artifacts.flatMap((artifact) => {
|
|
266
|
+
const metadata = parseArtifactMetadata(artifact.metadata);
|
|
267
|
+
if (metadata.issueKey) {
|
|
268
|
+
issueKeys.add(metadata.issueKey);
|
|
269
|
+
}
|
|
270
|
+
return getArtifactSearchableText(artifact);
|
|
271
|
+
}));
|
|
272
|
+
return {
|
|
273
|
+
text,
|
|
274
|
+
issueKeys: Array.from(issueKeys).sort(),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
buildNarrativeHistoryCache(narratives) {
|
|
278
|
+
return EvidenceDatabase.joinSearchParts(narratives.map((narrative) => narrative.content));
|
|
279
|
+
}
|
|
280
|
+
buildDecisionHistoryCache(decisions) {
|
|
281
|
+
return EvidenceDatabase.joinSearchParts(decisions.map((decision) => decision.summary));
|
|
282
|
+
}
|
|
283
|
+
ensureSessionHistoryCacheRow(sessionId) {
|
|
284
|
+
this.db
|
|
285
|
+
.prepare(`
|
|
286
|
+
INSERT INTO session_history_cache (
|
|
287
|
+
sessionId, titleText, metadataText, messagesText,
|
|
288
|
+
artifactsText, narrativesText, decisionsText, updatedAt
|
|
289
|
+
)
|
|
290
|
+
SELECT
|
|
291
|
+
id,
|
|
292
|
+
COALESCE(title, ''),
|
|
293
|
+
COALESCE(metadata, ''),
|
|
294
|
+
'',
|
|
295
|
+
'',
|
|
296
|
+
'',
|
|
297
|
+
'',
|
|
298
|
+
?
|
|
299
|
+
FROM sessions
|
|
300
|
+
WHERE id = ?
|
|
301
|
+
ON CONFLICT(sessionId) DO NOTHING
|
|
302
|
+
`)
|
|
303
|
+
.run(new Date().toISOString(), sessionId);
|
|
304
|
+
}
|
|
305
|
+
updateSessionHistoryCache(sessionId, updates) {
|
|
306
|
+
this.ensureSessionHistoryCacheRow(sessionId);
|
|
307
|
+
const current = this.db
|
|
308
|
+
.prepare(`
|
|
309
|
+
SELECT * FROM session_history_cache
|
|
310
|
+
WHERE sessionId = ?
|
|
311
|
+
`)
|
|
312
|
+
.get(sessionId);
|
|
313
|
+
if (!current) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const next = {
|
|
317
|
+
...current,
|
|
318
|
+
...updates,
|
|
319
|
+
sessionId,
|
|
320
|
+
updatedAt: new Date().toISOString(),
|
|
321
|
+
};
|
|
322
|
+
this.db
|
|
323
|
+
.prepare(`
|
|
324
|
+
UPDATE session_history_cache
|
|
325
|
+
SET titleText = ?,
|
|
326
|
+
metadataText = ?,
|
|
327
|
+
messagesText = ?,
|
|
328
|
+
artifactsText = ?,
|
|
329
|
+
narrativesText = ?,
|
|
330
|
+
decisionsText = ?,
|
|
331
|
+
updatedAt = ?
|
|
332
|
+
WHERE sessionId = ?
|
|
333
|
+
`)
|
|
334
|
+
.run(next.titleText, next.metadataText, next.messagesText, next.artifactsText, next.narrativesText, next.decisionsText, next.updatedAt, sessionId);
|
|
335
|
+
}
|
|
336
|
+
replaceSessionIssueKeys(sessionId, issueKeys) {
|
|
337
|
+
this.db
|
|
338
|
+
.prepare(`DELETE FROM session_issue_keys WHERE sessionId = ?`)
|
|
339
|
+
.run(sessionId);
|
|
340
|
+
const insertIssueKey = this.db.prepare(`
|
|
341
|
+
INSERT INTO session_issue_keys (sessionId, issueKey)
|
|
342
|
+
VALUES (?, ?)
|
|
343
|
+
`);
|
|
344
|
+
for (const issueKey of issueKeys) {
|
|
345
|
+
insertIssueKey.run(sessionId, issueKey);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
rebuildSessionHistoryCache(sessionId) {
|
|
349
|
+
const session = this.findSessionById(sessionId);
|
|
350
|
+
if (!session) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const messagesText = EvidenceDatabase.joinSearchParts(this.getSessionMessages(sessionId).map((message) => message.content));
|
|
354
|
+
const artifacts = this.getSessionArtifacts(sessionId);
|
|
355
|
+
const artifactCache = this.buildArtifactHistoryCache(artifacts);
|
|
356
|
+
const narrativesText = this.buildNarrativeHistoryCache(this.getSessionNarratives(sessionId));
|
|
357
|
+
const decisionsText = this.buildDecisionHistoryCache(this.getSessionDecisions(sessionId));
|
|
358
|
+
this.updateSessionHistoryCache(sessionId, {
|
|
359
|
+
titleText: session.title ?? "",
|
|
360
|
+
metadataText: session.metadata ?? "",
|
|
361
|
+
messagesText,
|
|
362
|
+
artifactsText: artifactCache.text,
|
|
363
|
+
narrativesText,
|
|
364
|
+
decisionsText,
|
|
365
|
+
});
|
|
366
|
+
this.replaceSessionIssueKeys(sessionId, artifactCache.issueKeys);
|
|
367
|
+
}
|
|
368
|
+
ensureSessionHistoryCacheBackfilled() {
|
|
369
|
+
if (this.sessionHistoryCacheBackfilled) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const missingRows = this.db
|
|
373
|
+
.prepare(`
|
|
374
|
+
SELECT s.id
|
|
375
|
+
FROM sessions s
|
|
376
|
+
LEFT JOIN session_history_cache cache ON cache.sessionId = s.id
|
|
377
|
+
WHERE cache.sessionId IS NULL
|
|
378
|
+
ORDER BY s.startedAt ASC, s.id ASC
|
|
379
|
+
`)
|
|
380
|
+
.all();
|
|
381
|
+
for (const row of missingRows) {
|
|
382
|
+
this.rebuildSessionHistoryCache(row.id);
|
|
383
|
+
}
|
|
384
|
+
this.sessionHistoryCacheBackfilled = true;
|
|
385
|
+
}
|
|
386
|
+
rebuildAllSessionHistoryCaches() {
|
|
387
|
+
for (const sessionId of this.getAllSessionIds()) {
|
|
388
|
+
this.rebuildSessionHistoryCache(sessionId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
fetchTrendSeenAtByEventIds(sessionId, eventIds) {
|
|
392
|
+
if (eventIds.length === 0) {
|
|
393
|
+
return new Map();
|
|
394
|
+
}
|
|
395
|
+
const placeholders = eventIds.map(() => "?").join(", ");
|
|
396
|
+
const rows = this.db
|
|
397
|
+
.prepare(`
|
|
398
|
+
SELECT id, COALESCE(endedAt, startedAt) as seenAt
|
|
399
|
+
FROM timeline_events
|
|
400
|
+
WHERE sessionId = ? AND id IN (${placeholders})
|
|
401
|
+
`)
|
|
402
|
+
.all(sessionId, ...eventIds);
|
|
403
|
+
return new Map(rows.map((row) => [row.id, row.seenAt]));
|
|
404
|
+
}
|
|
405
|
+
buildSessionTrendAttempts(sessionId, artifacts) {
|
|
406
|
+
const eventIds = [
|
|
407
|
+
...new Set(artifacts
|
|
408
|
+
.map((artifact) => artifact.eventId)
|
|
409
|
+
.filter((eventId) => Boolean(eventId))),
|
|
410
|
+
];
|
|
411
|
+
const seenAtByEventId = this.fetchTrendSeenAtByEventIds(sessionId, eventIds);
|
|
412
|
+
return artifacts.flatMap((artifact) => {
|
|
413
|
+
const metadata = parseArtifactMetadata(artifact.metadata);
|
|
414
|
+
const eventType = typeof metadata.details.eventType === "string"
|
|
415
|
+
? metadata.details.eventType
|
|
416
|
+
: null;
|
|
417
|
+
const outcome = metadata.outcome ?? metadata.status ?? "captured";
|
|
418
|
+
const outcomeCategory = EvidenceDatabase.normalizeTrendOutcome(outcome);
|
|
419
|
+
if (!artifact.eventId || !metadata.issueKey) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
if (!eventType ||
|
|
423
|
+
(!eventType.startsWith("command.") && !eventType.startsWith("test."))) {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
if (outcomeCategory === "other") {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
return [
|
|
430
|
+
{
|
|
431
|
+
artifactId: artifact.id,
|
|
432
|
+
sessionId,
|
|
433
|
+
issueKey: metadata.issueKey,
|
|
434
|
+
issueLabel: metadata.issueLabel ?? metadata.issueKey,
|
|
435
|
+
kind: metadata.intent ?? metadata.category,
|
|
436
|
+
issueFamilyKey: metadata.issueFamilyKey,
|
|
437
|
+
issueFamilyLabel: metadata.issueFamilyLabel ??
|
|
438
|
+
metadata.issueFamilyKey ??
|
|
439
|
+
metadata.issueLabel,
|
|
440
|
+
outcome,
|
|
441
|
+
outcomeCategory,
|
|
442
|
+
seenAt: seenAtByEventId.get(artifact.eventId) ?? artifact.createdAt,
|
|
443
|
+
createdAt: artifact.createdAt,
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
markSessionTrendAttemptsFresh(sessionId) {
|
|
449
|
+
this.db
|
|
450
|
+
.prepare(`
|
|
451
|
+
INSERT INTO session_trend_cache_state (sessionId, updatedAt)
|
|
452
|
+
VALUES (?, ?)
|
|
453
|
+
ON CONFLICT(sessionId) DO UPDATE SET updatedAt = excluded.updatedAt
|
|
454
|
+
`)
|
|
455
|
+
.run(sessionId, new Date().toISOString());
|
|
456
|
+
}
|
|
457
|
+
replaceSessionTrendAttempts(sessionId, artifacts) {
|
|
458
|
+
this.db
|
|
459
|
+
.prepare(`DELETE FROM session_trend_attempts WHERE sessionId = ?`)
|
|
460
|
+
.run(sessionId);
|
|
461
|
+
const attempts = this.buildSessionTrendAttempts(sessionId, artifacts);
|
|
462
|
+
if (attempts.length === 0) {
|
|
463
|
+
this.markSessionTrendAttemptsFresh(sessionId);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const insertAttempt = this.db.prepare(`
|
|
467
|
+
INSERT INTO session_trend_attempts (
|
|
468
|
+
artifactId, sessionId, issueKey, issueLabel, kind,
|
|
469
|
+
issueFamilyKey, issueFamilyLabel, outcome, outcomeCategory,
|
|
470
|
+
seenAt, createdAt
|
|
471
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
472
|
+
`);
|
|
473
|
+
for (const attempt of attempts) {
|
|
474
|
+
insertAttempt.run(attempt.artifactId, attempt.sessionId, attempt.issueKey, attempt.issueLabel, attempt.kind, attempt.issueFamilyKey, attempt.issueFamilyLabel, attempt.outcome, attempt.outcomeCategory, attempt.seenAt, attempt.createdAt);
|
|
475
|
+
}
|
|
476
|
+
this.markSessionTrendAttemptsFresh(sessionId);
|
|
477
|
+
}
|
|
478
|
+
insertSessionTrendAttempts(sessionId, artifacts) {
|
|
479
|
+
const attempts = this.buildSessionTrendAttempts(sessionId, artifacts);
|
|
480
|
+
if (attempts.length === 0) {
|
|
481
|
+
this.markSessionTrendAttemptsFresh(sessionId);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const insertAttempt = this.db.prepare(`
|
|
485
|
+
INSERT INTO session_trend_attempts (
|
|
486
|
+
artifactId, sessionId, issueKey, issueLabel, kind,
|
|
487
|
+
issueFamilyKey, issueFamilyLabel, outcome, outcomeCategory,
|
|
488
|
+
seenAt, createdAt
|
|
489
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
490
|
+
ON CONFLICT(artifactId) DO UPDATE SET
|
|
491
|
+
sessionId = excluded.sessionId,
|
|
492
|
+
issueKey = excluded.issueKey,
|
|
493
|
+
issueLabel = excluded.issueLabel,
|
|
494
|
+
kind = excluded.kind,
|
|
495
|
+
issueFamilyKey = excluded.issueFamilyKey,
|
|
496
|
+
issueFamilyLabel = excluded.issueFamilyLabel,
|
|
497
|
+
outcome = excluded.outcome,
|
|
498
|
+
outcomeCategory = excluded.outcomeCategory,
|
|
499
|
+
seenAt = excluded.seenAt,
|
|
500
|
+
createdAt = excluded.createdAt
|
|
501
|
+
`);
|
|
502
|
+
for (const attempt of attempts) {
|
|
503
|
+
insertAttempt.run(attempt.artifactId, attempt.sessionId, attempt.issueKey, attempt.issueLabel, attempt.kind, attempt.issueFamilyKey, attempt.issueFamilyLabel, attempt.outcome, attempt.outcomeCategory, attempt.seenAt, attempt.createdAt);
|
|
504
|
+
}
|
|
505
|
+
this.markSessionTrendAttemptsFresh(sessionId);
|
|
506
|
+
}
|
|
507
|
+
rebuildSessionTrendAttempts(sessionId) {
|
|
508
|
+
const session = this.findSessionById(sessionId);
|
|
509
|
+
if (!session) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.replaceSessionTrendAttempts(sessionId, this.getSessionArtifacts(sessionId));
|
|
513
|
+
}
|
|
514
|
+
ensureSessionTrendAttemptsBackfilled() {
|
|
515
|
+
if (this.sessionTrendAttemptsBackfilled) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const missingRows = this.db
|
|
519
|
+
.prepare(`
|
|
520
|
+
SELECT s.id
|
|
521
|
+
FROM sessions s
|
|
522
|
+
LEFT JOIN session_trend_cache_state state ON state.sessionId = s.id
|
|
523
|
+
WHERE state.sessionId IS NULL
|
|
524
|
+
ORDER BY s.startedAt ASC, s.id ASC
|
|
525
|
+
`)
|
|
526
|
+
.all();
|
|
527
|
+
for (const row of missingRows) {
|
|
528
|
+
this.rebuildSessionTrendAttempts(row.id);
|
|
529
|
+
}
|
|
530
|
+
this.sessionTrendAttemptsBackfilled = true;
|
|
531
|
+
}
|
|
532
|
+
rebuildAllSessionTrendAttempts() {
|
|
533
|
+
for (const sessionId of this.getAllSessionIds()) {
|
|
534
|
+
this.rebuildSessionTrendAttempts(sessionId);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
querySessionsByHistory(options) {
|
|
538
|
+
return traceSyncOperation("db.query-sessions-by-history", {
|
|
539
|
+
host: options.host,
|
|
540
|
+
status: options.status,
|
|
541
|
+
hasQuery: Boolean(options.query?.trim()),
|
|
542
|
+
issueKey: options.issueKey?.trim() || undefined,
|
|
543
|
+
sessionIds: options.sessionIds?.length ?? 0,
|
|
544
|
+
limit: options.limit,
|
|
545
|
+
offset: options.offset,
|
|
546
|
+
}, () => this.dbOp("query sessions by history", () => {
|
|
547
|
+
this.ensureSessionHistoryCacheBackfilled();
|
|
548
|
+
const conditions = [];
|
|
549
|
+
const params = [];
|
|
550
|
+
if (options.host) {
|
|
551
|
+
conditions.push(`s.host = ?`);
|
|
552
|
+
params.push(options.host);
|
|
553
|
+
}
|
|
554
|
+
if (options.status) {
|
|
555
|
+
conditions.push(`s.status = ?`);
|
|
556
|
+
params.push(options.status);
|
|
557
|
+
}
|
|
558
|
+
if (options.query && options.query.trim()) {
|
|
559
|
+
const pattern = `%${escapeLikePattern(options.query.trim())}%`;
|
|
560
|
+
conditions.push(`
|
|
561
|
+
(
|
|
562
|
+
cache.titleText LIKE ? ESCAPE '\\'
|
|
563
|
+
OR cache.metadataText LIKE ? ESCAPE '\\'
|
|
564
|
+
OR cache.messagesText LIKE ? ESCAPE '\\'
|
|
565
|
+
OR cache.artifactsText LIKE ? ESCAPE '\\'
|
|
566
|
+
OR cache.narrativesText LIKE ? ESCAPE '\\'
|
|
567
|
+
OR cache.decisionsText LIKE ? ESCAPE '\\'
|
|
568
|
+
)
|
|
569
|
+
`);
|
|
570
|
+
params.push(pattern, pattern, pattern, pattern, pattern, pattern);
|
|
571
|
+
}
|
|
572
|
+
if (options.issueKey && options.issueKey.trim()) {
|
|
573
|
+
conditions.push(`
|
|
574
|
+
EXISTS (
|
|
575
|
+
SELECT 1
|
|
576
|
+
FROM session_issue_keys issue_keys
|
|
577
|
+
WHERE issue_keys.sessionId = s.id
|
|
578
|
+
AND issue_keys.issueKey = ?
|
|
579
|
+
)
|
|
580
|
+
`);
|
|
581
|
+
params.push(options.issueKey.trim());
|
|
582
|
+
}
|
|
583
|
+
if (options.sessionIds && options.sessionIds.length > 0) {
|
|
584
|
+
const normalizedIds = options.sessionIds
|
|
585
|
+
.map((sessionId) => sessionId.trim())
|
|
586
|
+
.filter(Boolean);
|
|
587
|
+
if (normalizedIds.length === 0) {
|
|
588
|
+
return {
|
|
589
|
+
sessions: [],
|
|
590
|
+
total: 0,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
conditions.push(`s.id IN (${normalizedIds.map(() => "?").join(", ")})`);
|
|
594
|
+
params.push(...normalizedIds);
|
|
595
|
+
}
|
|
596
|
+
const whereSql = conditions.length
|
|
597
|
+
? `WHERE ${conditions.join(" AND ")}`
|
|
598
|
+
: "";
|
|
599
|
+
const fromSql = `
|
|
600
|
+
FROM sessions s
|
|
601
|
+
LEFT JOIN session_history_cache cache ON cache.sessionId = s.id
|
|
602
|
+
${whereSql}
|
|
603
|
+
`;
|
|
604
|
+
const total = this.db
|
|
605
|
+
.prepare(`SELECT COUNT(*) as total ${fromSql}`)
|
|
606
|
+
.get(...params)?.total ?? 0;
|
|
607
|
+
let query = `
|
|
608
|
+
SELECT s.*
|
|
609
|
+
${fromSql}
|
|
610
|
+
ORDER BY s.startedAt DESC, s.id DESC
|
|
611
|
+
`;
|
|
612
|
+
const pageParams = [...params];
|
|
613
|
+
query = appendPaginationClause(query, pageParams, options.limit, options.offset);
|
|
614
|
+
const rows = this.db
|
|
615
|
+
.prepare(query)
|
|
616
|
+
.all(...pageParams);
|
|
617
|
+
return {
|
|
618
|
+
sessions: rows.map((row) => this.rowToSession(row)),
|
|
619
|
+
total,
|
|
620
|
+
};
|
|
621
|
+
}));
|
|
622
|
+
}
|
|
623
|
+
querySessionTrendAttempts(options) {
|
|
624
|
+
return traceSyncOperation("db.query-session-trend-attempts", {
|
|
625
|
+
host: options?.host,
|
|
626
|
+
status: options?.status,
|
|
627
|
+
sessionIds: options?.sessionIds?.length ?? 0,
|
|
628
|
+
}, () => this.dbOp("query session trend attempts", () => {
|
|
629
|
+
this.ensureSessionTrendAttemptsBackfilled();
|
|
630
|
+
const conditions = [];
|
|
631
|
+
const params = [];
|
|
632
|
+
if (options?.host) {
|
|
633
|
+
conditions.push(`s.host = ?`);
|
|
634
|
+
params.push(options.host);
|
|
635
|
+
}
|
|
636
|
+
if (options?.status) {
|
|
637
|
+
conditions.push(`s.status = ?`);
|
|
638
|
+
params.push(options.status);
|
|
639
|
+
}
|
|
640
|
+
if (options?.sessionIds && options.sessionIds.length > 0) {
|
|
641
|
+
const normalizedIds = options.sessionIds
|
|
642
|
+
.map((sessionId) => sessionId.trim())
|
|
643
|
+
.filter(Boolean);
|
|
644
|
+
if (normalizedIds.length === 0) {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
conditions.push(`ta.sessionId IN (${normalizedIds.map(() => "?").join(", ")})`);
|
|
648
|
+
params.push(...normalizedIds);
|
|
649
|
+
}
|
|
650
|
+
const whereSql = conditions.length
|
|
651
|
+
? `WHERE ${conditions.join(" AND ")}`
|
|
652
|
+
: "";
|
|
653
|
+
return this.db
|
|
654
|
+
.prepare(`
|
|
655
|
+
SELECT
|
|
656
|
+
ta.*,
|
|
657
|
+
s.host,
|
|
658
|
+
s.status,
|
|
659
|
+
s.cwd,
|
|
660
|
+
s.startedAt,
|
|
661
|
+
s.endedAt,
|
|
662
|
+
s.title
|
|
663
|
+
FROM session_trend_attempts ta
|
|
664
|
+
INNER JOIN sessions s ON s.id = ta.sessionId
|
|
665
|
+
${whereSql}
|
|
666
|
+
ORDER BY ta.seenAt DESC, ta.artifactId DESC
|
|
667
|
+
`)
|
|
668
|
+
.all(...params);
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
querySessionTrendContextAttempts(sessionId) {
|
|
672
|
+
return this.dbOp("query session trend context attempts", () => {
|
|
673
|
+
const normalizedSessionId = sessionId.trim();
|
|
674
|
+
if (!normalizedSessionId) {
|
|
675
|
+
return [];
|
|
676
|
+
}
|
|
677
|
+
this.ensureSessionTrendAttemptsBackfilled();
|
|
678
|
+
return this.db
|
|
679
|
+
.prepare(`
|
|
680
|
+
WITH target_issue_keys AS (
|
|
681
|
+
SELECT DISTINCT issueKey
|
|
682
|
+
FROM session_trend_attempts
|
|
683
|
+
WHERE sessionId = ?
|
|
684
|
+
)
|
|
685
|
+
SELECT
|
|
686
|
+
ta.*,
|
|
687
|
+
s.host,
|
|
688
|
+
s.status,
|
|
689
|
+
s.cwd,
|
|
690
|
+
s.startedAt,
|
|
691
|
+
s.endedAt,
|
|
692
|
+
s.title
|
|
693
|
+
FROM session_trend_attempts ta
|
|
694
|
+
INNER JOIN target_issue_keys tik ON tik.issueKey = ta.issueKey
|
|
695
|
+
INNER JOIN sessions s ON s.id = ta.sessionId
|
|
696
|
+
ORDER BY ta.issueKey ASC, ta.seenAt DESC, ta.artifactId DESC
|
|
697
|
+
`)
|
|
698
|
+
.all(normalizedSessionId);
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
getSessionFollowUpMessages(sessionIds, options) {
|
|
702
|
+
return traceSyncOperation("db.get-session-follow-up-messages", {
|
|
703
|
+
sessionIds: sessionIds.length,
|
|
704
|
+
limit: options?.limit,
|
|
705
|
+
offset: options?.offset,
|
|
706
|
+
}, () => this.dbOp("get session follow-up messages", () => {
|
|
707
|
+
const normalizedIds = sessionIds
|
|
708
|
+
.map((sessionId) => sessionId.trim())
|
|
709
|
+
.filter(Boolean);
|
|
710
|
+
if (normalizedIds.length === 0) {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
const params = [...normalizedIds];
|
|
714
|
+
let query = `
|
|
715
|
+
SELECT id, sessionId, content, capturedAt, seq
|
|
716
|
+
FROM messages
|
|
717
|
+
WHERE sessionId IN (${normalizedIds.map(() => "?").join(", ")})
|
|
718
|
+
AND (
|
|
719
|
+
content LIKE '%?%'
|
|
720
|
+
OR lower(content) LIKE 'next:%'
|
|
721
|
+
)
|
|
722
|
+
ORDER BY capturedAt DESC, sessionId DESC, seq DESC, id DESC
|
|
723
|
+
`;
|
|
724
|
+
query = appendPaginationClause(query, params, options?.limit, options?.offset);
|
|
725
|
+
const rows = this.db
|
|
726
|
+
.prepare(query)
|
|
727
|
+
.all(...params);
|
|
728
|
+
return rows.map((row) => ({
|
|
729
|
+
sessionId: row.sessionId,
|
|
730
|
+
content: row.content,
|
|
731
|
+
capturedAt: row.capturedAt,
|
|
732
|
+
}));
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
createSession(session) {
|
|
736
|
+
const id = crypto.randomUUID();
|
|
737
|
+
const now = new Date().toISOString();
|
|
738
|
+
return this.dbOp("create session", () => {
|
|
739
|
+
const stmt = this.db.prepare(`
|
|
740
|
+
INSERT INTO sessions (
|
|
741
|
+
id, host, projectRoot, cwd, title, status,
|
|
742
|
+
startedAt, endedAt, metadata, createdAt, updatedAt
|
|
743
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
744
|
+
`);
|
|
745
|
+
stmt.run(id, session.host, session.projectRoot, session.cwd, session.title, session.status, session.startedAt, session.endedAt, session.metadata, now, now);
|
|
746
|
+
this.db
|
|
747
|
+
.prepare(`
|
|
748
|
+
INSERT INTO session_history_cache (
|
|
749
|
+
sessionId, titleText, metadataText, messagesText,
|
|
750
|
+
artifactsText, narrativesText, decisionsText, updatedAt
|
|
751
|
+
) VALUES (?, ?, ?, '', '', '', '', ?)
|
|
752
|
+
`)
|
|
753
|
+
.run(id, session.title ?? "", session.metadata ?? "", now);
|
|
754
|
+
this.markSessionTrendAttemptsFresh(id);
|
|
755
|
+
return id;
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
appendMessage(message) {
|
|
759
|
+
const id = crypto.randomUUID();
|
|
760
|
+
return this.dbOp("append message", () => {
|
|
761
|
+
const stmt = this.db.prepare(`
|
|
762
|
+
INSERT INTO messages (
|
|
763
|
+
id, sessionId, seq, role, source, content, capturedAt, metadata
|
|
764
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
765
|
+
`);
|
|
766
|
+
stmt.run(id, message.sessionId, message.seq, message.role, message.source, message.content, message.capturedAt, message.metadata);
|
|
767
|
+
const currentCache = this.db
|
|
768
|
+
.prepare(`
|
|
769
|
+
SELECT messagesText FROM session_history_cache
|
|
770
|
+
WHERE sessionId = ?
|
|
771
|
+
`)
|
|
772
|
+
.get(message.sessionId);
|
|
773
|
+
this.updateSessionHistoryCache(message.sessionId, {
|
|
774
|
+
messagesText: EvidenceDatabase.appendSearchPart(currentCache?.messagesText ?? "", message.content),
|
|
775
|
+
});
|
|
776
|
+
return id;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
appendTimelineEvent(event) {
|
|
780
|
+
const id = crypto.randomUUID();
|
|
781
|
+
return this.dbOp("append timeline event", () => {
|
|
782
|
+
const stmt = this.db.prepare(`
|
|
783
|
+
INSERT INTO timeline_events (
|
|
784
|
+
id, sessionId, seq, eventType, eventSubType, source,
|
|
785
|
+
summary, payload, startedAt, endedAt, status, relatedMessageId
|
|
786
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
787
|
+
`);
|
|
788
|
+
stmt.run(id, event.sessionId, event.seq, event.eventType, event.eventSubType, event.source, event.summary, event.payload, event.startedAt, event.endedAt, event.status, event.relatedMessageId);
|
|
789
|
+
return id;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
updateSessionTitle(id, title) {
|
|
793
|
+
this.dbOp("update session title", () => {
|
|
794
|
+
const stmt = this.db.prepare(`
|
|
795
|
+
UPDATE sessions
|
|
796
|
+
SET title = ?,
|
|
797
|
+
updatedAt = ?
|
|
798
|
+
WHERE id = ?
|
|
799
|
+
`);
|
|
800
|
+
const result = stmt.run(title, new Date().toISOString(), id);
|
|
801
|
+
if (result.changes === 0) {
|
|
802
|
+
throw new Error(`Session with id ${id} not found`);
|
|
803
|
+
}
|
|
804
|
+
this.updateSessionHistoryCache(id, {
|
|
805
|
+
titleText: title,
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
finalizeSession(id, updates) {
|
|
810
|
+
this.dbOp("finalize session", () => {
|
|
811
|
+
const stmt = this.db.prepare(`
|
|
812
|
+
UPDATE sessions
|
|
813
|
+
SET status = ?,
|
|
814
|
+
endedAt = ?,
|
|
815
|
+
title = COALESCE(?, title),
|
|
816
|
+
updatedAt = ?
|
|
817
|
+
WHERE id = ?
|
|
818
|
+
`);
|
|
819
|
+
const result = stmt.run(updates.status, updates.endedAt, updates.title, new Date().toISOString(), id);
|
|
820
|
+
if (result.changes === 0) {
|
|
821
|
+
throw new Error(`Session with id ${id} not found`);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
findSessionById(id) {
|
|
826
|
+
return this.dbOp("find session by ID", () => {
|
|
827
|
+
const row = this.db
|
|
828
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
829
|
+
.get(id);
|
|
830
|
+
return row ? this.rowToSession(row) : null;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
listSessions(options) {
|
|
834
|
+
return this.dbOp("list sessions", () => {
|
|
835
|
+
const params = [];
|
|
836
|
+
const query = appendPaginationClause("SELECT * FROM sessions ORDER BY startedAt DESC", params, options?.limit, options?.offset);
|
|
837
|
+
const rows = this.db.prepare(query).all(...params);
|
|
838
|
+
return rows.map((row) => this.rowToSession(row));
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
createContext(context) {
|
|
842
|
+
const id = crypto.randomUUID();
|
|
843
|
+
const now = new Date().toISOString();
|
|
844
|
+
return this.dbOp("create context", () => {
|
|
845
|
+
this.db
|
|
846
|
+
.prepare(`
|
|
847
|
+
INSERT INTO contexts (
|
|
848
|
+
id, label, workspaceKey, status, mergedIntoContextId,
|
|
849
|
+
metadata, createdAt, updatedAt
|
|
850
|
+
) VALUES (?, ?, ?, 'active', NULL, ?, ?, ?)
|
|
851
|
+
`)
|
|
852
|
+
.run(id, context.label, context.workspaceKey, context.metadata ?? null, now, now);
|
|
853
|
+
return {
|
|
854
|
+
id,
|
|
855
|
+
label: context.label,
|
|
856
|
+
workspaceKey: context.workspaceKey,
|
|
857
|
+
status: "active",
|
|
858
|
+
mergedIntoContextId: null,
|
|
859
|
+
metadata: context.metadata ?? null,
|
|
860
|
+
createdAt: now,
|
|
861
|
+
updatedAt: now,
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
findContextById(id) {
|
|
866
|
+
return this.dbOp("find context by ID", () => {
|
|
867
|
+
const row = this.db
|
|
868
|
+
.prepare(`SELECT * FROM contexts WHERE id = ?`)
|
|
869
|
+
.get(id);
|
|
870
|
+
return row ? this.rowToContext(row) : null;
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
resolveContextById(id) {
|
|
94
874
|
try {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
875
|
+
const activeId = this.resolveActiveContextIdOrThrow(id);
|
|
876
|
+
return this.findContextById(activeId);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
listContexts(options) {
|
|
883
|
+
return this.dbOp("list contexts", () => {
|
|
884
|
+
const conditions = [];
|
|
98
885
|
const params = [];
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
params.push(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
886
|
+
if (options?.workspaceKey) {
|
|
887
|
+
conditions.push("workspaceKey = ?");
|
|
888
|
+
params.push(options.workspaceKey);
|
|
889
|
+
}
|
|
890
|
+
if (options?.status) {
|
|
891
|
+
conditions.push("status = ?");
|
|
892
|
+
params.push(options.status);
|
|
893
|
+
}
|
|
894
|
+
else if (!options?.includeMerged) {
|
|
895
|
+
conditions.push("status = 'active'");
|
|
896
|
+
}
|
|
897
|
+
const whereSql = conditions.length
|
|
898
|
+
? `WHERE ${conditions.join(" AND ")}`
|
|
899
|
+
: "";
|
|
900
|
+
const query = appendPaginationClause(`
|
|
901
|
+
SELECT *
|
|
902
|
+
FROM contexts
|
|
903
|
+
${whereSql}
|
|
904
|
+
ORDER BY updatedAt DESC, createdAt DESC, id DESC
|
|
905
|
+
`, params, options?.limit, options?.offset);
|
|
906
|
+
const rows = this.db.prepare(query).all(...params);
|
|
907
|
+
return rows.map((row) => this.rowToContext(row));
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
getContextCount(options) {
|
|
911
|
+
return this.dbOp("count contexts", () => {
|
|
912
|
+
const conditions = [];
|
|
913
|
+
const params = [];
|
|
914
|
+
if (options?.workspaceKey) {
|
|
915
|
+
conditions.push("workspaceKey = ?");
|
|
916
|
+
params.push(options.workspaceKey);
|
|
917
|
+
}
|
|
918
|
+
if (options?.status) {
|
|
919
|
+
conditions.push("status = ?");
|
|
920
|
+
params.push(options.status);
|
|
921
|
+
}
|
|
922
|
+
else if (!options?.includeMerged) {
|
|
923
|
+
conditions.push("status = 'active'");
|
|
924
|
+
}
|
|
925
|
+
const whereSql = conditions.length
|
|
926
|
+
? `WHERE ${conditions.join(" AND ")}`
|
|
927
|
+
: "";
|
|
928
|
+
const row = this.db
|
|
929
|
+
.prepare(`SELECT COUNT(*) as count FROM contexts ${whereSql}`)
|
|
930
|
+
.get(...params);
|
|
931
|
+
return row?.count ?? 0;
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
listSessionsForContext(contextId) {
|
|
935
|
+
return this.dbOp("list sessions for context", () => {
|
|
936
|
+
const activeId = this.resolveActiveContextIdOrThrow(contextId);
|
|
937
|
+
const rows = this.db
|
|
938
|
+
.prepare(`
|
|
939
|
+
SELECT s.*
|
|
940
|
+
FROM context_session_links links
|
|
941
|
+
INNER JOIN sessions s ON s.id = links.sessionId
|
|
942
|
+
WHERE links.contextId = ?
|
|
943
|
+
ORDER BY s.startedAt ASC, s.id ASC
|
|
944
|
+
`)
|
|
945
|
+
.all(activeId);
|
|
946
|
+
return rows.map((row) => this.rowToSession(row));
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
listUnlinkedSessions(options) {
|
|
950
|
+
return this.dbOp("list unlinked sessions", () => {
|
|
951
|
+
const conditions = [
|
|
952
|
+
"NOT EXISTS (SELECT 1 FROM context_session_links links WHERE links.sessionId = s.id)",
|
|
953
|
+
];
|
|
954
|
+
const params = [];
|
|
955
|
+
let query = `
|
|
956
|
+
SELECT s.*
|
|
957
|
+
FROM sessions s
|
|
958
|
+
WHERE ${conditions.join(" AND ")}
|
|
959
|
+
ORDER BY s.startedAt ASC, s.id ASC
|
|
960
|
+
`;
|
|
961
|
+
if (!options?.workspaceKey) {
|
|
962
|
+
query = appendPaginationClause(query, params, options?.limit, options?.offset);
|
|
963
|
+
}
|
|
964
|
+
const rows = this.db.prepare(query).all(...params);
|
|
965
|
+
let sessions = rows.map((row) => this.rowToSession(row));
|
|
966
|
+
if (options?.workspaceKey) {
|
|
967
|
+
sessions = sessions.filter((session) => matchesWorkspaceKey(session, options.workspaceKey));
|
|
968
|
+
const off = options.offset ?? 0;
|
|
969
|
+
if (off > 0) {
|
|
970
|
+
sessions = sessions.slice(off);
|
|
971
|
+
}
|
|
972
|
+
if (options.limit !== undefined) {
|
|
973
|
+
sessions = sessions.slice(0, options.limit);
|
|
105
974
|
}
|
|
106
975
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
976
|
+
return sessions;
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
findContextLinkForSession(sessionId) {
|
|
980
|
+
return this.dbOp("find context link for session", () => {
|
|
981
|
+
const row = this.db
|
|
982
|
+
.prepare(`
|
|
983
|
+
SELECT *
|
|
984
|
+
FROM context_session_links
|
|
985
|
+
WHERE sessionId = ?
|
|
986
|
+
`)
|
|
987
|
+
.get(sessionId);
|
|
988
|
+
return row ? this.rowToContextSessionLink(row) : null;
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
listContextSessionLinks(contextId) {
|
|
992
|
+
return this.dbOp("list context session links", () => {
|
|
993
|
+
const activeId = this.resolveActiveContextIdOrThrow(contextId);
|
|
994
|
+
const rows = this.db
|
|
995
|
+
.prepare(`
|
|
996
|
+
SELECT *
|
|
997
|
+
FROM context_session_links
|
|
998
|
+
WHERE contextId = ?
|
|
999
|
+
ORDER BY updatedAt DESC, sessionId DESC
|
|
1000
|
+
`)
|
|
1001
|
+
.all(activeId);
|
|
1002
|
+
return rows.map((row) => this.rowToContextSessionLink(row));
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
assignSessionToContext(input) {
|
|
1006
|
+
const now = new Date().toISOString();
|
|
1007
|
+
return this.dbOp("assign session to context", () => {
|
|
1008
|
+
const session = this.findSessionById(input.sessionId);
|
|
1009
|
+
if (!session) {
|
|
1010
|
+
throw new Error(`Session not found: ${input.sessionId}`);
|
|
1011
|
+
}
|
|
1012
|
+
const activeContextId = this.resolveActiveContextIdOrThrow(input.contextId);
|
|
1013
|
+
this.db
|
|
1014
|
+
.prepare(`
|
|
1015
|
+
INSERT INTO context_session_links (
|
|
1016
|
+
sessionId, contextId, linkSource, createdAt, updatedAt
|
|
1017
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1018
|
+
ON CONFLICT(sessionId) DO UPDATE SET
|
|
1019
|
+
contextId = excluded.contextId,
|
|
1020
|
+
linkSource = excluded.linkSource,
|
|
1021
|
+
updatedAt = excluded.updatedAt
|
|
1022
|
+
`)
|
|
1023
|
+
.run(input.sessionId, activeContextId, input.linkSource, now, now);
|
|
1024
|
+
this.db
|
|
1025
|
+
.prepare(`
|
|
1026
|
+
DELETE FROM context_link_rejections
|
|
1027
|
+
WHERE sessionId = ? AND contextId = ?
|
|
1028
|
+
`)
|
|
1029
|
+
.run(input.sessionId, activeContextId);
|
|
1030
|
+
this.db
|
|
1031
|
+
.prepare(`
|
|
1032
|
+
UPDATE contexts
|
|
1033
|
+
SET updatedAt = ?
|
|
1034
|
+
WHERE id = ?
|
|
1035
|
+
`)
|
|
1036
|
+
.run(now, activeContextId);
|
|
1037
|
+
return {
|
|
1038
|
+
sessionId: input.sessionId,
|
|
1039
|
+
contextId: activeContextId,
|
|
1040
|
+
linkSource: input.linkSource,
|
|
1041
|
+
createdAt: now,
|
|
1042
|
+
updatedAt: now,
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
rejectContextForSession(sessionId, contextId) {
|
|
1047
|
+
const now = new Date().toISOString();
|
|
1048
|
+
return this.dbOp("reject context for session", () => {
|
|
1049
|
+
if (!this.findSessionById(sessionId)) {
|
|
1050
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
1051
|
+
}
|
|
1052
|
+
const activeContextId = this.resolveActiveContextIdOrThrow(contextId);
|
|
1053
|
+
this.db
|
|
1054
|
+
.prepare(`
|
|
1055
|
+
INSERT INTO context_link_rejections (sessionId, contextId, createdAt)
|
|
1056
|
+
VALUES (?, ?, ?)
|
|
1057
|
+
ON CONFLICT(sessionId, contextId) DO NOTHING
|
|
1058
|
+
`)
|
|
1059
|
+
.run(sessionId, activeContextId, now);
|
|
1060
|
+
return {
|
|
1061
|
+
sessionId,
|
|
1062
|
+
contextId: activeContextId,
|
|
1063
|
+
createdAt: now,
|
|
1064
|
+
};
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
listContextRejectionsForSession(sessionId) {
|
|
1068
|
+
return this.dbOp("list context rejections", () => {
|
|
1069
|
+
const rows = this.db
|
|
1070
|
+
.prepare(`
|
|
1071
|
+
SELECT *
|
|
1072
|
+
FROM context_link_rejections
|
|
1073
|
+
WHERE sessionId = ?
|
|
1074
|
+
ORDER BY createdAt DESC, contextId DESC
|
|
1075
|
+
`)
|
|
1076
|
+
.all(sessionId);
|
|
1077
|
+
return rows.map((row) => this.rowToContextLinkRejection(row));
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
setWorkspacePreferredContext(workspaceKey, contextId) {
|
|
1081
|
+
const now = new Date().toISOString();
|
|
1082
|
+
return this.dbOp("set preferred context", () => {
|
|
1083
|
+
const activeContextId = this.resolveActiveContextIdOrThrow(contextId);
|
|
1084
|
+
this.db
|
|
1085
|
+
.prepare(`
|
|
1086
|
+
INSERT INTO context_workspace_preferences (
|
|
1087
|
+
workspaceKey, contextId, createdAt, updatedAt
|
|
1088
|
+
) VALUES (?, ?, ?, ?)
|
|
1089
|
+
ON CONFLICT(workspaceKey) DO UPDATE SET
|
|
1090
|
+
contextId = excluded.contextId,
|
|
1091
|
+
updatedAt = excluded.updatedAt
|
|
1092
|
+
`)
|
|
1093
|
+
.run(workspaceKey, activeContextId, now, now);
|
|
1094
|
+
return {
|
|
1095
|
+
workspaceKey,
|
|
1096
|
+
contextId: activeContextId,
|
|
1097
|
+
createdAt: now,
|
|
1098
|
+
updatedAt: now,
|
|
1099
|
+
};
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
getWorkspacePreferredContext(workspaceKey) {
|
|
1103
|
+
return this.dbOp("get preferred context", () => {
|
|
1104
|
+
const row = this.db
|
|
1105
|
+
.prepare(`
|
|
1106
|
+
SELECT *
|
|
1107
|
+
FROM context_workspace_preferences
|
|
1108
|
+
WHERE workspaceKey = ?
|
|
1109
|
+
`)
|
|
1110
|
+
.get(workspaceKey);
|
|
1111
|
+
if (!row) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
const activeContextId = this.resolveActiveContextIdOrThrow(row.contextId);
|
|
1116
|
+
if (activeContextId !== row.contextId) {
|
|
1117
|
+
const now = new Date().toISOString();
|
|
1118
|
+
this.db
|
|
1119
|
+
.prepare(`
|
|
1120
|
+
UPDATE context_workspace_preferences
|
|
1121
|
+
SET contextId = ?, updatedAt = ?
|
|
1122
|
+
WHERE workspaceKey = ?
|
|
1123
|
+
`)
|
|
1124
|
+
.run(activeContextId, now, workspaceKey);
|
|
1125
|
+
return this.rowToContextWorkspacePreference({
|
|
1126
|
+
...row,
|
|
1127
|
+
contextId: activeContextId,
|
|
1128
|
+
updatedAt: now,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
catch {
|
|
1133
|
+
return this.rowToContextWorkspacePreference(row);
|
|
1134
|
+
}
|
|
1135
|
+
return this.rowToContextWorkspacePreference(row);
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
mergeContexts(sourceContextId, targetContextId) {
|
|
1139
|
+
this.dbOp("merge contexts", () => {
|
|
1140
|
+
const sourceId = this.resolveActiveContextIdOrThrow(sourceContextId);
|
|
1141
|
+
const targetId = this.resolveActiveContextIdOrThrow(targetContextId);
|
|
1142
|
+
if (sourceId === targetId) {
|
|
1143
|
+
throw new Error("Cannot merge a context into itself");
|
|
1144
|
+
}
|
|
1145
|
+
const now = new Date().toISOString();
|
|
1146
|
+
const transaction = this.db.transaction(() => {
|
|
1147
|
+
this.db
|
|
1148
|
+
.prepare(`
|
|
1149
|
+
UPDATE context_session_links
|
|
1150
|
+
SET contextId = ?, linkSource = 'merge', updatedAt = ?
|
|
1151
|
+
WHERE contextId = ?
|
|
1152
|
+
`)
|
|
1153
|
+
.run(targetId, now, sourceId);
|
|
1154
|
+
this.db
|
|
1155
|
+
.prepare(`
|
|
1156
|
+
UPDATE context_workspace_preferences
|
|
1157
|
+
SET contextId = ?, updatedAt = ?
|
|
1158
|
+
WHERE contextId = ?
|
|
1159
|
+
`)
|
|
1160
|
+
.run(targetId, now, sourceId);
|
|
1161
|
+
this.db
|
|
1162
|
+
.prepare(`
|
|
1163
|
+
UPDATE contexts
|
|
1164
|
+
SET status = 'merged',
|
|
1165
|
+
mergedIntoContextId = ?,
|
|
1166
|
+
updatedAt = ?
|
|
1167
|
+
WHERE id = ?
|
|
1168
|
+
`)
|
|
1169
|
+
.run(targetId, now, sourceId);
|
|
1170
|
+
this.db
|
|
1171
|
+
.prepare(`
|
|
1172
|
+
UPDATE contexts
|
|
1173
|
+
SET updatedAt = ?
|
|
1174
|
+
WHERE id = ?
|
|
1175
|
+
`)
|
|
1176
|
+
.run(now, targetId);
|
|
1177
|
+
});
|
|
1178
|
+
transaction();
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
countSessionMessages(sessionId) {
|
|
1182
|
+
return this.dbOp("count session messages", () => {
|
|
1183
|
+
const row = this.db
|
|
1184
|
+
.prepare(`SELECT COUNT(*) as total FROM messages WHERE sessionId = ?`)
|
|
1185
|
+
.get(sessionId);
|
|
1186
|
+
return row?.total ?? 0;
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
getSessionMessageStats(sessionId) {
|
|
1190
|
+
return this.dbOp("summarize session messages", () => {
|
|
1191
|
+
const summaryRow = this.db
|
|
1192
|
+
.prepare(`
|
|
1193
|
+
SELECT
|
|
1194
|
+
COUNT(*) as total,
|
|
1195
|
+
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) as userCount,
|
|
1196
|
+
COALESCE(SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END), 0) as assistantCount,
|
|
1197
|
+
COALESCE(SUM(CASE WHEN role = 'system' THEN 1 ELSE 0 END), 0) as systemCount,
|
|
1198
|
+
MIN(capturedAt) as firstCapturedAt,
|
|
1199
|
+
MAX(capturedAt) as lastCapturedAt
|
|
1200
|
+
FROM messages
|
|
1201
|
+
WHERE sessionId = ?
|
|
1202
|
+
`)
|
|
1203
|
+
.get(sessionId);
|
|
1204
|
+
const previewRow = this.db
|
|
1205
|
+
.prepare(`
|
|
1206
|
+
SELECT content FROM messages
|
|
1207
|
+
WHERE sessionId = ?
|
|
1208
|
+
ORDER BY seq ASC, capturedAt ASC
|
|
1209
|
+
LIMIT 1
|
|
1210
|
+
`)
|
|
1211
|
+
.get(sessionId);
|
|
1212
|
+
return {
|
|
1213
|
+
total: summaryRow?.total ?? 0,
|
|
1214
|
+
byRole: {
|
|
1215
|
+
user: summaryRow?.userCount ?? 0,
|
|
1216
|
+
assistant: summaryRow?.assistantCount ?? 0,
|
|
1217
|
+
system: summaryRow?.systemCount ?? 0,
|
|
1218
|
+
},
|
|
1219
|
+
firstCapturedAt: summaryRow?.firstCapturedAt ?? null,
|
|
1220
|
+
lastCapturedAt: summaryRow?.lastCapturedAt ?? null,
|
|
1221
|
+
previewContent: previewRow?.content ?? null,
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
getSessionMessages(sessionId, options) {
|
|
1226
|
+
return this.dbOp("get session messages", () => {
|
|
1227
|
+
const params = [sessionId];
|
|
1228
|
+
const query = appendPaginationClause("SELECT * FROM messages WHERE sessionId = ? ORDER BY seq ASC, capturedAt ASC", params, options?.limit, options?.offset);
|
|
1229
|
+
const rows = this.db.prepare(query).all(...params);
|
|
1230
|
+
return rows.map((row) => this.rowToMessage(row));
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
countSessionTimeline(sessionId) {
|
|
1234
|
+
return this.dbOp("count session timeline", () => {
|
|
1235
|
+
const row = this.db
|
|
1236
|
+
.prepare(`SELECT COUNT(*) as total FROM timeline_events WHERE sessionId = ?`)
|
|
1237
|
+
.get(sessionId);
|
|
1238
|
+
return row?.total ?? 0;
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
getSessionTimelineStats(sessionId) {
|
|
1242
|
+
return this.dbOp("summarize session timeline", () => {
|
|
1243
|
+
const summaryRow = this.db
|
|
1244
|
+
.prepare(`
|
|
1245
|
+
SELECT
|
|
1246
|
+
COUNT(*) as total,
|
|
1247
|
+
MIN(startedAt) as firstStartedAt,
|
|
1248
|
+
MAX(COALESCE(endedAt, startedAt)) as lastEndedAt
|
|
1249
|
+
FROM timeline_events
|
|
1250
|
+
WHERE sessionId = ?
|
|
1251
|
+
`)
|
|
1252
|
+
.get(sessionId);
|
|
1253
|
+
const eventTypeRows = this.db
|
|
1254
|
+
.prepare(`
|
|
1255
|
+
SELECT eventType FROM timeline_events
|
|
1256
|
+
WHERE sessionId = ?
|
|
1257
|
+
GROUP BY eventType
|
|
1258
|
+
ORDER BY MIN(seq) ASC, MIN(startedAt) ASC
|
|
1259
|
+
`)
|
|
1260
|
+
.all(sessionId);
|
|
1261
|
+
const statusRows = this.db
|
|
1262
|
+
.prepare(`
|
|
1263
|
+
SELECT status FROM timeline_events
|
|
1264
|
+
WHERE sessionId = ? AND status IS NOT NULL
|
|
1265
|
+
GROUP BY status
|
|
1266
|
+
ORDER BY MIN(seq) ASC, MIN(startedAt) ASC
|
|
1267
|
+
`)
|
|
1268
|
+
.all(sessionId);
|
|
1269
|
+
return {
|
|
1270
|
+
total: summaryRow?.total ?? 0,
|
|
1271
|
+
eventTypes: eventTypeRows.map((row) => row.eventType),
|
|
1272
|
+
statuses: statusRows.map((row) => row.status),
|
|
1273
|
+
firstStartedAt: summaryRow?.firstStartedAt ?? null,
|
|
1274
|
+
lastEndedAt: summaryRow?.lastEndedAt ?? null,
|
|
1275
|
+
};
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
getSessionTimeline(sessionId, options) {
|
|
1279
|
+
return this.dbOp("get session timeline", () => {
|
|
1280
|
+
const params = [sessionId];
|
|
1281
|
+
const query = appendPaginationClause(`
|
|
1282
|
+
SELECT * FROM timeline_events
|
|
1283
|
+
WHERE sessionId = ?
|
|
1284
|
+
ORDER BY seq ASC, startedAt ASC
|
|
1285
|
+
`, params, options?.limit, options?.offset);
|
|
1286
|
+
const rows = this.db.prepare(query).all(...params);
|
|
1287
|
+
return rows.map((row) => this.rowToTimelineEvent(row));
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
createArtifact(artifact) {
|
|
1291
|
+
const id = crypto.randomUUID();
|
|
1292
|
+
const createdAt = new Date().toISOString();
|
|
1293
|
+
return this.dbOp("create artifact", () => {
|
|
1294
|
+
this.db
|
|
1295
|
+
.prepare(`
|
|
1296
|
+
INSERT INTO artifacts (
|
|
1297
|
+
id, sessionId, eventId, artifactType, path, metadata, createdAt
|
|
1298
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1299
|
+
`)
|
|
1300
|
+
.run(id, artifact.sessionId, artifact.eventId, artifact.artifactType, artifact.path, artifact.metadata, createdAt);
|
|
1301
|
+
const artifactRecord = {
|
|
1302
|
+
id,
|
|
1303
|
+
sessionId: artifact.sessionId,
|
|
1304
|
+
eventId: artifact.eventId,
|
|
1305
|
+
artifactType: artifact.artifactType,
|
|
1306
|
+
path: artifact.path,
|
|
1307
|
+
metadata: artifact.metadata,
|
|
1308
|
+
createdAt,
|
|
1309
|
+
};
|
|
1310
|
+
const artifactCache = this.buildArtifactHistoryCache([artifactRecord]);
|
|
1311
|
+
const currentCache = this.db
|
|
1312
|
+
.prepare(`
|
|
1313
|
+
SELECT artifactsText FROM session_history_cache
|
|
1314
|
+
WHERE sessionId = ?
|
|
1315
|
+
`)
|
|
1316
|
+
.get(artifact.sessionId);
|
|
1317
|
+
this.updateSessionHistoryCache(artifact.sessionId, {
|
|
1318
|
+
artifactsText: EvidenceDatabase.appendSearchPart(currentCache?.artifactsText ?? "", artifactCache.text),
|
|
1319
|
+
});
|
|
1320
|
+
for (const issueKey of artifactCache.issueKeys) {
|
|
1321
|
+
this.db
|
|
1322
|
+
.prepare(`
|
|
1323
|
+
INSERT INTO session_issue_keys (sessionId, issueKey)
|
|
1324
|
+
VALUES (?, ?)
|
|
1325
|
+
ON CONFLICT(sessionId, issueKey) DO NOTHING
|
|
1326
|
+
`)
|
|
1327
|
+
.run(artifact.sessionId, issueKey);
|
|
1328
|
+
}
|
|
1329
|
+
this.insertSessionTrendAttempts(artifact.sessionId, [artifactRecord]);
|
|
1330
|
+
return artifactRecord;
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
replaceArtifactsForSession(sessionId, artifacts) {
|
|
1334
|
+
return this.dbOp("replace artifacts", () => {
|
|
1335
|
+
const transaction = this.db.transaction(() => {
|
|
1336
|
+
this.db
|
|
1337
|
+
.prepare(`DELETE FROM artifacts WHERE sessionId = ?`)
|
|
1338
|
+
.run(sessionId);
|
|
1339
|
+
const insertArtifact = this.db.prepare(`
|
|
1340
|
+
INSERT INTO artifacts (
|
|
1341
|
+
id, sessionId, eventId, artifactType, path, metadata, createdAt
|
|
1342
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1343
|
+
`);
|
|
1344
|
+
const createdArtifacts = artifacts.map((artifact) => {
|
|
1345
|
+
const id = crypto.randomUUID();
|
|
1346
|
+
const createdAt = new Date().toISOString();
|
|
1347
|
+
insertArtifact.run(id, sessionId, artifact.eventId, artifact.artifactType, artifact.path, artifact.metadata, createdAt);
|
|
1348
|
+
return {
|
|
1349
|
+
id,
|
|
1350
|
+
sessionId,
|
|
1351
|
+
eventId: artifact.eventId,
|
|
1352
|
+
artifactType: artifact.artifactType,
|
|
1353
|
+
path: artifact.path,
|
|
1354
|
+
metadata: artifact.metadata,
|
|
1355
|
+
createdAt,
|
|
1356
|
+
};
|
|
1357
|
+
});
|
|
1358
|
+
const artifactCache = this.buildArtifactHistoryCache(createdArtifacts);
|
|
1359
|
+
this.updateSessionHistoryCache(sessionId, {
|
|
1360
|
+
artifactsText: artifactCache.text,
|
|
1361
|
+
});
|
|
1362
|
+
this.replaceSessionIssueKeys(sessionId, artifactCache.issueKeys);
|
|
1363
|
+
this.replaceSessionTrendAttempts(sessionId, createdArtifacts);
|
|
1364
|
+
return createdArtifacts;
|
|
1365
|
+
});
|
|
1366
|
+
return transaction();
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
countSessionArtifacts(sessionId, options) {
|
|
1370
|
+
return this.dbOp("count session artifacts", () => {
|
|
1371
|
+
const row = (options?.artifactType
|
|
1372
|
+
? this.db
|
|
1373
|
+
.prepare(`SELECT COUNT(*) as count FROM artifacts WHERE sessionId = ? AND artifactType = ?`)
|
|
1374
|
+
.get(sessionId, options.artifactType)
|
|
1375
|
+
: this.db
|
|
1376
|
+
.prepare(`SELECT COUNT(*) as count FROM artifacts WHERE sessionId = ?`)
|
|
1377
|
+
.get(sessionId));
|
|
1378
|
+
return row?.count ?? 0;
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
getSessionArtifactSummary(sessionId) {
|
|
1382
|
+
return this.dbOp("summarize session artifacts", () => {
|
|
1383
|
+
const row = this.db
|
|
1384
|
+
.prepare(`
|
|
1385
|
+
SELECT
|
|
1386
|
+
COUNT(*) as total,
|
|
1387
|
+
COALESCE(SUM(CASE WHEN artifactType = 'file-change' THEN 1 ELSE 0 END), 0) as fileChange,
|
|
1388
|
+
COALESCE(SUM(CASE WHEN artifactType = 'command-output' THEN 1 ELSE 0 END), 0) as commandOutput,
|
|
1389
|
+
COALESCE(SUM(CASE WHEN artifactType = 'test-result' THEN 1 ELSE 0 END), 0) as testResult,
|
|
1390
|
+
COALESCE(SUM(CASE WHEN artifactType = 'git-commit' THEN 1 ELSE 0 END), 0) as gitCommit
|
|
1391
|
+
FROM artifacts
|
|
1392
|
+
WHERE sessionId = ?
|
|
1393
|
+
`)
|
|
1394
|
+
.get(sessionId);
|
|
1395
|
+
return {
|
|
1396
|
+
total: row?.total ?? 0,
|
|
1397
|
+
byType: {
|
|
1398
|
+
fileChange: row?.fileChange ?? 0,
|
|
1399
|
+
commandOutput: row?.commandOutput ?? 0,
|
|
1400
|
+
testResult: row?.testResult ?? 0,
|
|
1401
|
+
gitCommit: row?.gitCommit ?? 0,
|
|
1402
|
+
},
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
getSessionArtifacts(sessionId, options) {
|
|
1407
|
+
return this.dbOp("get session artifacts", () => {
|
|
1408
|
+
const conditions = ["sessionId = ?"];
|
|
1409
|
+
const parameters = [sessionId];
|
|
1410
|
+
if (options?.artifactType) {
|
|
1411
|
+
conditions.push("artifactType = ?");
|
|
1412
|
+
parameters.push(options.artifactType);
|
|
1413
|
+
}
|
|
1414
|
+
const query = appendPaginationClause(`SELECT * FROM artifacts WHERE ${conditions.join(" AND ")} ORDER BY createdAt ASC, id ASC`, parameters, options?.limit, options?.offset);
|
|
1415
|
+
const rows = this.db.prepare(query).all(...parameters);
|
|
1416
|
+
return rows.map((row) => this.rowToArtifact(row));
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
replaceDecisionsForSession(sessionId, decisions) {
|
|
1420
|
+
return this.dbOp("replace decisions", () => {
|
|
1421
|
+
const transaction = this.db.transaction(() => {
|
|
1422
|
+
this.db
|
|
1423
|
+
.prepare(`DELETE FROM decisions WHERE sessionId = ?`)
|
|
1424
|
+
.run(sessionId);
|
|
1425
|
+
const createdDecisions = decisions.map((decision) => {
|
|
1426
|
+
const id = crypto.randomUUID();
|
|
1427
|
+
const createdAt = new Date().toISOString();
|
|
1428
|
+
this.db
|
|
1429
|
+
.prepare(`
|
|
1430
|
+
INSERT INTO decisions (
|
|
1431
|
+
id, sessionId, title, summary, rationale, status, sourceRefs, createdAt
|
|
1432
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1433
|
+
`)
|
|
1434
|
+
.run(id, sessionId, decision.title, decision.summary, decision.rationale, decision.status, decision.sourceRefs, createdAt);
|
|
1435
|
+
return {
|
|
1436
|
+
id,
|
|
1437
|
+
sessionId,
|
|
1438
|
+
title: decision.title,
|
|
1439
|
+
summary: decision.summary,
|
|
1440
|
+
rationale: decision.rationale,
|
|
1441
|
+
status: decision.status,
|
|
1442
|
+
sourceRefs: decision.sourceRefs,
|
|
1443
|
+
createdAt,
|
|
1444
|
+
};
|
|
1445
|
+
});
|
|
1446
|
+
this.updateSessionHistoryCache(sessionId, {
|
|
1447
|
+
decisionsText: this.buildDecisionHistoryCache(createdDecisions),
|
|
1448
|
+
});
|
|
1449
|
+
return createdDecisions;
|
|
1450
|
+
});
|
|
1451
|
+
return transaction();
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
countSessionDecisions(sessionId) {
|
|
1455
|
+
return this.dbOp("count session decisions", () => {
|
|
1456
|
+
const row = this.db
|
|
1457
|
+
.prepare(`SELECT COUNT(*) as count FROM decisions WHERE sessionId = ?`)
|
|
1458
|
+
.get(sessionId);
|
|
1459
|
+
return row?.count ?? 0;
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
getSessionDecisions(sessionId, options) {
|
|
1463
|
+
return this.dbOp("get session decisions", () => {
|
|
1464
|
+
const params = [sessionId];
|
|
1465
|
+
const query = appendPaginationClause(`
|
|
1466
|
+
SELECT * FROM decisions
|
|
1467
|
+
WHERE sessionId = ?
|
|
1468
|
+
ORDER BY createdAt ASC, id ASC
|
|
1469
|
+
`, params, options?.limit, options?.offset);
|
|
1470
|
+
const rows = this.db.prepare(query).all(...params);
|
|
1471
|
+
return rows.map((row) => this.rowToDecision(row));
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
replaceNarrativesForSession(sessionId, narratives) {
|
|
1475
|
+
return this.dbOp("replace narratives", () => {
|
|
1476
|
+
const transaction = this.db.transaction(() => {
|
|
1477
|
+
this.db
|
|
1478
|
+
.prepare(`DELETE FROM narratives WHERE sessionId = ?`)
|
|
1479
|
+
.run(sessionId);
|
|
1480
|
+
const createdNarratives = narratives.map((narrative) => {
|
|
1481
|
+
const id = crypto.randomUUID();
|
|
1482
|
+
const now = new Date().toISOString();
|
|
1483
|
+
this.db
|
|
1484
|
+
.prepare(`
|
|
1485
|
+
INSERT INTO narratives (
|
|
1486
|
+
id, sessionId, kind, content, sourceRefs, createdAt, updatedAt
|
|
1487
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1488
|
+
`)
|
|
1489
|
+
.run(id, sessionId, narrative.kind, narrative.content, narrative.sourceRefs, now, now);
|
|
1490
|
+
return {
|
|
1491
|
+
id,
|
|
1492
|
+
sessionId,
|
|
1493
|
+
kind: narrative.kind,
|
|
1494
|
+
content: narrative.content,
|
|
1495
|
+
sourceRefs: narrative.sourceRefs,
|
|
1496
|
+
createdAt: now,
|
|
1497
|
+
updatedAt: now,
|
|
1498
|
+
};
|
|
1499
|
+
});
|
|
1500
|
+
this.updateSessionHistoryCache(sessionId, {
|
|
1501
|
+
narrativesText: this.buildNarrativeHistoryCache(createdNarratives),
|
|
1502
|
+
});
|
|
1503
|
+
return createdNarratives;
|
|
1504
|
+
});
|
|
1505
|
+
return transaction();
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
countSessionNarratives(sessionId, options) {
|
|
1509
|
+
return this.dbOp("count session narratives", () => {
|
|
1510
|
+
const row = (options?.kind
|
|
1511
|
+
? this.db
|
|
1512
|
+
.prepare(`SELECT COUNT(*) as count FROM narratives WHERE sessionId = ? AND kind = ?`)
|
|
1513
|
+
.get(sessionId, options.kind)
|
|
1514
|
+
: this.db
|
|
1515
|
+
.prepare(`SELECT COUNT(*) as count FROM narratives WHERE sessionId = ?`)
|
|
1516
|
+
.get(sessionId));
|
|
1517
|
+
return row?.count ?? 0;
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
getSessionNarratives(sessionId, options) {
|
|
1521
|
+
return this.dbOp("get session narratives", () => {
|
|
1522
|
+
const conditions = ["sessionId = ?"];
|
|
1523
|
+
const params = [sessionId];
|
|
1524
|
+
if (options?.kind) {
|
|
1525
|
+
conditions.push("kind = ?");
|
|
1526
|
+
params.push(options.kind);
|
|
1527
|
+
}
|
|
1528
|
+
let query = `
|
|
1529
|
+
SELECT * FROM narratives
|
|
1530
|
+
WHERE ${conditions.join(" AND ")}
|
|
1531
|
+
ORDER BY CASE kind
|
|
1532
|
+
WHEN 'journal' THEN 1
|
|
1533
|
+
WHEN 'project-summary' THEN 2
|
|
1534
|
+
WHEN 'handoff' THEN 3
|
|
1535
|
+
ELSE 99
|
|
1536
|
+
END, createdAt ASC, id ASC
|
|
1537
|
+
`;
|
|
1538
|
+
query = appendPaginationClause(query, params, options?.limit, options?.offset);
|
|
1539
|
+
const rows = this.db.prepare(query).all(...params);
|
|
1540
|
+
return rows.map((row) => this.rowToNarrative(row));
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
hasNarrativesForSession(sessionId) {
|
|
1544
|
+
return this.dbOp("check session narratives", () => {
|
|
1545
|
+
const row = this.db
|
|
1546
|
+
.prepare(`SELECT EXISTS(SELECT 1 FROM narratives WHERE sessionId = ?) as present`)
|
|
1547
|
+
.get(sessionId);
|
|
1548
|
+
return Boolean(row?.present);
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
createIngestionRun(run) {
|
|
1552
|
+
const id = crypto.randomUUID();
|
|
1553
|
+
const startedAt = new Date().toISOString();
|
|
1554
|
+
return this.dbOp("create ingestion run", () => {
|
|
1555
|
+
this.db
|
|
1556
|
+
.prepare(`
|
|
1557
|
+
INSERT INTO ingestion_runs (
|
|
1558
|
+
id, sessionId, stage, status, error, startedAt, endedAt
|
|
1559
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1560
|
+
`)
|
|
1561
|
+
.run(id, run.sessionId, run.stage, run.status, run.error ?? null, startedAt, null);
|
|
1562
|
+
return {
|
|
1563
|
+
id,
|
|
1564
|
+
sessionId: run.sessionId,
|
|
1565
|
+
stage: run.stage,
|
|
1566
|
+
status: run.status,
|
|
1567
|
+
error: run.error ?? null,
|
|
1568
|
+
startedAt,
|
|
1569
|
+
endedAt: null,
|
|
1570
|
+
};
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
completeIngestionRun(id, status, error = null) {
|
|
1574
|
+
try {
|
|
1575
|
+
const endedAt = new Date().toISOString();
|
|
1576
|
+
const result = this.db
|
|
1577
|
+
.prepare(`
|
|
1578
|
+
UPDATE ingestion_runs
|
|
1579
|
+
SET status = ?, error = ?, endedAt = ?
|
|
1580
|
+
WHERE id = ?
|
|
1581
|
+
`)
|
|
1582
|
+
.run(status, error, endedAt, id);
|
|
1583
|
+
if (result.changes === 0) {
|
|
1584
|
+
throw new Error(`Ingestion run with id ${id} not found`);
|
|
110
1585
|
}
|
|
111
|
-
const stmt = this.db.prepare(query);
|
|
112
|
-
const rows = stmt.all(...params);
|
|
113
|
-
return rows.map((row) => this.rowToEvidence(row));
|
|
114
1586
|
}
|
|
115
|
-
catch (
|
|
116
|
-
throw new Error(`Failed to
|
|
1587
|
+
catch (cause) {
|
|
1588
|
+
throw new Error(`Failed to complete ingestion run: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
117
1589
|
}
|
|
118
1590
|
}
|
|
1591
|
+
getSessionIngestionRuns(sessionId) {
|
|
1592
|
+
return this.dbOp("get ingestion runs", () => {
|
|
1593
|
+
const rows = this.db
|
|
1594
|
+
.prepare(`
|
|
1595
|
+
SELECT * FROM ingestion_runs
|
|
1596
|
+
WHERE sessionId = ?
|
|
1597
|
+
ORDER BY startedAt ASC
|
|
1598
|
+
`)
|
|
1599
|
+
.all(sessionId);
|
|
1600
|
+
return rows.map((row) => this.rowToIngestionRun(row));
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
getSessionDetail(sessionId) {
|
|
1604
|
+
// Wrap all reads in a single transaction for atomic snapshot
|
|
1605
|
+
const readAll = this.db.transaction(() => {
|
|
1606
|
+
const session = this.findSessionById(sessionId);
|
|
1607
|
+
if (!session) {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
const messages = this.getSessionMessages(sessionId);
|
|
1611
|
+
const timeline = this.getSessionTimeline(sessionId);
|
|
1612
|
+
const artifacts = this.getSessionArtifacts(sessionId);
|
|
1613
|
+
const narratives = this.getSessionNarratives(sessionId);
|
|
1614
|
+
const decisions = this.getSessionDecisions(sessionId);
|
|
1615
|
+
const ingestionRuns = this.getSessionIngestionRuns(sessionId);
|
|
1616
|
+
return {
|
|
1617
|
+
session,
|
|
1618
|
+
messages,
|
|
1619
|
+
timeline,
|
|
1620
|
+
artifacts,
|
|
1621
|
+
narratives,
|
|
1622
|
+
decisions,
|
|
1623
|
+
ingestionRuns,
|
|
1624
|
+
hasNarratives: narratives.length > 0,
|
|
1625
|
+
};
|
|
1626
|
+
});
|
|
1627
|
+
return readAll();
|
|
1628
|
+
}
|
|
119
1629
|
/**
|
|
120
1630
|
* Updates git commit information for an evidence
|
|
121
1631
|
* @param id - Evidence UUID
|
|
@@ -123,7 +1633,7 @@ export class EvidenceDatabase {
|
|
|
123
1633
|
* @param gitTimestamp - Git commit timestamp (ISO 8601)
|
|
124
1634
|
*/
|
|
125
1635
|
updateGitInfo(id, gitCommitHash, gitTimestamp) {
|
|
126
|
-
|
|
1636
|
+
this.dbOp("update git info", () => {
|
|
127
1637
|
const stmt = this.db.prepare(`
|
|
128
1638
|
UPDATE evidences
|
|
129
1639
|
SET gitCommitHash = ?,
|
|
@@ -135,10 +1645,7 @@ export class EvidenceDatabase {
|
|
|
135
1645
|
if (result.changes === 0) {
|
|
136
1646
|
throw new Error(`Evidence with id ${id} not found`);
|
|
137
1647
|
}
|
|
138
|
-
}
|
|
139
|
-
catch (error) {
|
|
140
|
-
throw new Error(`Failed to update git info: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
-
}
|
|
1648
|
+
});
|
|
142
1649
|
}
|
|
143
1650
|
/**
|
|
144
1651
|
* Adds tags to an evidence (appends to existing tags)
|
|
@@ -147,38 +1654,112 @@ export class EvidenceDatabase {
|
|
|
147
1654
|
* @throws Error if tags array is empty or all tags are whitespace
|
|
148
1655
|
*/
|
|
149
1656
|
addTags(id, tags) {
|
|
150
|
-
|
|
1657
|
+
this.dbOp("add tags", () => {
|
|
151
1658
|
if (tags.length === 0) {
|
|
152
|
-
throw new Error(
|
|
1659
|
+
throw new Error("Tags array cannot be empty");
|
|
153
1660
|
}
|
|
154
1661
|
// Filter out empty/whitespace-only tags
|
|
155
|
-
const validTags = tags.map(t => t.trim()).filter(t => t.length > 0);
|
|
1662
|
+
const validTags = tags.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
156
1663
|
if (validTags.length === 0) {
|
|
157
|
-
throw new Error(
|
|
1664
|
+
throw new Error("All provided tags are empty or whitespace");
|
|
158
1665
|
}
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
1666
|
+
// Wrap in transaction to prevent read-modify-write race condition
|
|
1667
|
+
const transaction = this.db.transaction(() => {
|
|
1668
|
+
const evidence = this.findById(id);
|
|
1669
|
+
if (!evidence) {
|
|
1670
|
+
throw new Error(`Evidence with id ${id} not found`);
|
|
1671
|
+
}
|
|
1672
|
+
// Parse existing tags (comma-separated) or create empty array
|
|
1673
|
+
const existingTags = evidence.tags
|
|
1674
|
+
? evidence.tags
|
|
1675
|
+
.split(",")
|
|
1676
|
+
.map((t) => t.trim())
|
|
1677
|
+
.filter((t) => t)
|
|
1678
|
+
: [];
|
|
1679
|
+
// Merge tags (deduplicate) using validTags instead of raw tags
|
|
1680
|
+
const mergedTags = [...new Set([...existingTags, ...validTags])];
|
|
1681
|
+
// Update database with comma-separated format
|
|
1682
|
+
const stmt = this.db.prepare(`
|
|
1683
|
+
UPDATE evidences
|
|
1684
|
+
SET tags = ?,
|
|
1685
|
+
updatedAt = ?
|
|
1686
|
+
WHERE id = ?
|
|
1687
|
+
`);
|
|
1688
|
+
stmt.run(mergedTags.join(","), new Date().toISOString(), id);
|
|
1689
|
+
});
|
|
1690
|
+
transaction();
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Builds a WHERE clause from search/filter options
|
|
1695
|
+
* @param options - Filter criteria
|
|
1696
|
+
* @returns SQL WHERE clause string and parameter values
|
|
1697
|
+
*/
|
|
1698
|
+
buildWhereClause(options) {
|
|
1699
|
+
const conditions = [];
|
|
1700
|
+
const params = [];
|
|
1701
|
+
if (options.query && options.query.trim()) {
|
|
1702
|
+
conditions.push(`(conversationId LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')`);
|
|
1703
|
+
const searchPattern = `%${escapeLikePattern(options.query.trim())}%`;
|
|
1704
|
+
params.push(searchPattern, searchPattern);
|
|
1705
|
+
}
|
|
1706
|
+
if (options.tags && options.tags.length > 0) {
|
|
1707
|
+
for (const tag of options.tags) {
|
|
1708
|
+
conditions.push(`(tags LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR tags = ?)`);
|
|
1709
|
+
const trimmedTag = escapeLikePattern(tag.trim());
|
|
1710
|
+
params.push(`${trimmedTag},%`, `%,${trimmedTag},%`, `%,${trimmedTag}`, tag.trim());
|
|
163
1711
|
}
|
|
164
|
-
// Parse existing tags (comma-separated) or create empty array
|
|
165
|
-
const existingTags = evidence.tags
|
|
166
|
-
? evidence.tags.split(',').map(t => t.trim()).filter(t => t)
|
|
167
|
-
: [];
|
|
168
|
-
// Merge tags (deduplicate) using validTags instead of raw tags
|
|
169
|
-
const mergedTags = [...new Set([...existingTags, ...validTags])];
|
|
170
|
-
// Update database with comma-separated format
|
|
171
|
-
const stmt = this.db.prepare(`
|
|
172
|
-
UPDATE evidences
|
|
173
|
-
SET tags = ?,
|
|
174
|
-
updatedAt = ?
|
|
175
|
-
WHERE id = ?
|
|
176
|
-
`);
|
|
177
|
-
stmt.run(mergedTags.join(','), new Date().toISOString(), id);
|
|
178
1712
|
}
|
|
179
|
-
|
|
180
|
-
|
|
1713
|
+
if (options.dateFrom) {
|
|
1714
|
+
conditions.push(`timestamp >= ?`);
|
|
1715
|
+
params.push(options.dateFrom);
|
|
1716
|
+
}
|
|
1717
|
+
if (options.dateTo) {
|
|
1718
|
+
conditions.push(`timestamp <= ?`);
|
|
1719
|
+
params.push(options.dateTo);
|
|
181
1720
|
}
|
|
1721
|
+
const sql = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
|
|
1722
|
+
return { sql, params };
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Gets count of evidences matching filter criteria
|
|
1726
|
+
* @param options - Filter criteria (query, tags, date range)
|
|
1727
|
+
* @returns Number of matching evidences
|
|
1728
|
+
*/
|
|
1729
|
+
getFilteredCount(options) {
|
|
1730
|
+
return this.dbOp("get filtered count", () => {
|
|
1731
|
+
const { sql: whereClause, params } = this.buildWhereClause(options);
|
|
1732
|
+
const row = this.db
|
|
1733
|
+
.prepare(`SELECT COUNT(*) as count FROM evidences${whereClause}`)
|
|
1734
|
+
.get(...params);
|
|
1735
|
+
return row.count;
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Search evidences and return both paginated results and total matching count
|
|
1740
|
+
* in a single pass (builds WHERE clause once instead of twice)
|
|
1741
|
+
*/
|
|
1742
|
+
searchWithCount(options) {
|
|
1743
|
+
return this.dbOp("search evidences", () => {
|
|
1744
|
+
const { limit, offset = 0 } = options;
|
|
1745
|
+
const { sql: whereClause, params: baseParams } = this.buildWhereClause(options);
|
|
1746
|
+
// Wrap both queries in a transaction for consistent snapshot
|
|
1747
|
+
const query = this.db.transaction(() => {
|
|
1748
|
+
// Get total count with same WHERE clause
|
|
1749
|
+
const countRow = this.db
|
|
1750
|
+
.prepare(`SELECT COUNT(*) as count FROM evidences${whereClause}`)
|
|
1751
|
+
.get(...baseParams);
|
|
1752
|
+
// Build paginated query (clone params since we append to it)
|
|
1753
|
+
const searchParams = [...baseParams];
|
|
1754
|
+
const sql = appendPaginationClause(`SELECT * FROM evidences${whereClause} ORDER BY timestamp DESC`, searchParams, limit, offset);
|
|
1755
|
+
const rows = this.db.prepare(sql).all(...searchParams);
|
|
1756
|
+
return {
|
|
1757
|
+
evidences: rows.map((row) => this.rowToEvidence(row)),
|
|
1758
|
+
total: countRow.count,
|
|
1759
|
+
};
|
|
1760
|
+
});
|
|
1761
|
+
return query();
|
|
1762
|
+
});
|
|
182
1763
|
}
|
|
183
1764
|
/**
|
|
184
1765
|
* Search and filter evidences by various criteria
|
|
@@ -186,66 +1767,17 @@ export class EvidenceDatabase {
|
|
|
186
1767
|
* @returns Array of matching evidences
|
|
187
1768
|
*/
|
|
188
1769
|
search(options) {
|
|
189
|
-
|
|
190
|
-
const {
|
|
191
|
-
|
|
192
|
-
const conditions = [];
|
|
193
|
-
const params = [];
|
|
194
|
-
// Text search in conversationId and tags
|
|
195
|
-
if (query && query.trim()) {
|
|
196
|
-
conditions.push(`(conversationId LIKE ? OR tags LIKE ?)`);
|
|
197
|
-
const searchPattern = `%${query.trim()}%`;
|
|
198
|
-
params.push(searchPattern, searchPattern);
|
|
199
|
-
}
|
|
200
|
-
// Tag filtering (AND logic - all specified tags must be present)
|
|
201
|
-
// Tags are stored as comma-separated strings: "tag1,tag2,tag3"
|
|
202
|
-
if (tags && tags.length > 0) {
|
|
203
|
-
for (const tag of tags) {
|
|
204
|
-
// Match tag at start, middle, or end of comma-separated list
|
|
205
|
-
conditions.push(`(tags LIKE ? OR tags LIKE ? OR tags LIKE ? OR tags = ?)`);
|
|
206
|
-
const trimmedTag = tag.trim();
|
|
207
|
-
params.push(`${trimmedTag},%`, // tag at start: "tag1,..."
|
|
208
|
-
`%,${trimmedTag},%`, // tag in middle: "...,tag1,..."
|
|
209
|
-
`%,${trimmedTag}`, // tag at end: "...,tag1"
|
|
210
|
-
trimmedTag // exact match (single tag)
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
// Date range filtering
|
|
215
|
-
if (dateFrom) {
|
|
216
|
-
conditions.push(`timestamp >= ?`);
|
|
217
|
-
params.push(dateFrom);
|
|
218
|
-
}
|
|
219
|
-
if (dateTo) {
|
|
220
|
-
conditions.push(`timestamp <= ?`);
|
|
221
|
-
params.push(dateTo);
|
|
222
|
-
}
|
|
1770
|
+
return this.dbOp("search evidences", () => {
|
|
1771
|
+
const { limit, offset = 0 } = options;
|
|
1772
|
+
const { sql: whereClause, params } = this.buildWhereClause(options);
|
|
223
1773
|
// Build final query
|
|
224
|
-
let sql =
|
|
225
|
-
if (conditions.length > 0) {
|
|
226
|
-
sql += ' WHERE ' + conditions.join(' AND ');
|
|
227
|
-
}
|
|
228
|
-
sql += ' ORDER BY timestamp DESC';
|
|
1774
|
+
let sql = `SELECT * FROM evidences${whereClause} ORDER BY timestamp DESC`;
|
|
229
1775
|
// Add pagination
|
|
230
|
-
|
|
231
|
-
sql += ' LIMIT ?';
|
|
232
|
-
params.push(limit);
|
|
233
|
-
if (offset > 0) {
|
|
234
|
-
sql += ' OFFSET ?';
|
|
235
|
-
params.push(offset);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
else if (offset > 0) {
|
|
239
|
-
sql += ' LIMIT -1 OFFSET ?';
|
|
240
|
-
params.push(offset);
|
|
241
|
-
}
|
|
1776
|
+
sql = appendPaginationClause(sql, params, limit, offset);
|
|
242
1777
|
const stmt = this.db.prepare(sql);
|
|
243
1778
|
const rows = stmt.all(...params);
|
|
244
1779
|
return rows.map((row) => this.rowToEvidence(row));
|
|
245
|
-
}
|
|
246
|
-
catch (error) {
|
|
247
|
-
throw new Error(`Failed to search evidences: ${error instanceof Error ? error.message : String(error)}`);
|
|
248
|
-
}
|
|
1780
|
+
});
|
|
249
1781
|
}
|
|
250
1782
|
/**
|
|
251
1783
|
* Deletes evidence by ID
|
|
@@ -253,14 +1785,11 @@ export class EvidenceDatabase {
|
|
|
253
1785
|
* @returns true if deleted, false if not found
|
|
254
1786
|
*/
|
|
255
1787
|
delete(id) {
|
|
256
|
-
|
|
1788
|
+
return this.dbOp("delete evidence", () => {
|
|
257
1789
|
const stmt = this.db.prepare(`DELETE FROM evidences WHERE id = ?`);
|
|
258
1790
|
const result = stmt.run(id);
|
|
259
1791
|
return result.changes > 0;
|
|
260
|
-
}
|
|
261
|
-
catch (error) {
|
|
262
|
-
throw new Error(`Failed to delete evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
-
}
|
|
1792
|
+
});
|
|
264
1793
|
}
|
|
265
1794
|
/**
|
|
266
1795
|
* Deletes multiple evidences by IDs
|
|
@@ -270,15 +1799,19 @@ export class EvidenceDatabase {
|
|
|
270
1799
|
deleteMany(ids) {
|
|
271
1800
|
if (ids.length === 0)
|
|
272
1801
|
return 0;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
1802
|
+
return this.dbOp("delete evidences", () => {
|
|
1803
|
+
// Batch deletions to stay under SQLite's 999 parameter limit
|
|
1804
|
+
const BATCH_SIZE = 999;
|
|
1805
|
+
let totalDeleted = 0;
|
|
1806
|
+
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
|
1807
|
+
const batch = ids.slice(i, i + BATCH_SIZE);
|
|
1808
|
+
const placeholders = batch.map(() => "?").join(",");
|
|
1809
|
+
const stmt = this.db.prepare(`DELETE FROM evidences WHERE id IN (${placeholders})`);
|
|
1810
|
+
const result = stmt.run(...batch);
|
|
1811
|
+
totalDeleted += result.changes;
|
|
1812
|
+
}
|
|
1813
|
+
return totalDeleted;
|
|
1814
|
+
});
|
|
282
1815
|
}
|
|
283
1816
|
/**
|
|
284
1817
|
* Updates tags for an evidence (replaces existing tags)
|
|
@@ -287,7 +1820,7 @@ export class EvidenceDatabase {
|
|
|
287
1820
|
* @returns true if updated, false if not found
|
|
288
1821
|
*/
|
|
289
1822
|
updateTags(id, tags) {
|
|
290
|
-
|
|
1823
|
+
return this.dbOp("update tags", () => {
|
|
291
1824
|
const stmt = this.db.prepare(`
|
|
292
1825
|
UPDATE evidences
|
|
293
1826
|
SET tags = ?,
|
|
@@ -296,10 +1829,7 @@ export class EvidenceDatabase {
|
|
|
296
1829
|
`);
|
|
297
1830
|
const result = stmt.run(tags, new Date().toISOString(), id);
|
|
298
1831
|
return result.changes > 0;
|
|
299
|
-
}
|
|
300
|
-
catch (error) {
|
|
301
|
-
throw new Error(`Failed to update tags: ${error instanceof Error ? error.message : String(error)}`);
|
|
302
|
-
}
|
|
1832
|
+
});
|
|
303
1833
|
}
|
|
304
1834
|
/**
|
|
305
1835
|
* Renames a tag across all evidences atomically using transaction
|
|
@@ -309,7 +1839,7 @@ export class EvidenceDatabase {
|
|
|
309
1839
|
* @throws Error if transaction fails (no partial updates)
|
|
310
1840
|
*/
|
|
311
1841
|
renameTag(oldTag, newTag) {
|
|
312
|
-
|
|
1842
|
+
return this.dbOp("rename tag", () => {
|
|
313
1843
|
// Wrap in transaction for atomic updates
|
|
314
1844
|
const transaction = this.db.transaction(() => {
|
|
315
1845
|
// Get all evidences with this tag
|
|
@@ -318,19 +1848,16 @@ export class EvidenceDatabase {
|
|
|
318
1848
|
for (const evidence of evidences) {
|
|
319
1849
|
if (!evidence.tags)
|
|
320
1850
|
continue;
|
|
321
|
-
const tags = evidence.tags.split(
|
|
322
|
-
const newTags = tags.map(t => t === oldTag ? newTag : t);
|
|
323
|
-
if (this.updateTags(evidence.id, newTags.join(
|
|
1851
|
+
const tags = evidence.tags.split(",").map((t) => t.trim());
|
|
1852
|
+
const newTags = tags.map((t) => (t === oldTag ? newTag : t));
|
|
1853
|
+
if (this.updateTags(evidence.id, newTags.join(","))) {
|
|
324
1854
|
updatedCount++;
|
|
325
1855
|
}
|
|
326
1856
|
}
|
|
327
1857
|
return updatedCount;
|
|
328
1858
|
});
|
|
329
1859
|
return transaction();
|
|
330
|
-
}
|
|
331
|
-
catch (error) {
|
|
332
|
-
throw new Error(`Failed to rename tag: ${error instanceof Error ? error.message : String(error)}`);
|
|
333
|
-
}
|
|
1860
|
+
});
|
|
334
1861
|
}
|
|
335
1862
|
/**
|
|
336
1863
|
* Removes a tag from all evidences atomically using transaction
|
|
@@ -339,7 +1866,7 @@ export class EvidenceDatabase {
|
|
|
339
1866
|
* @throws Error if transaction fails (no partial updates)
|
|
340
1867
|
*/
|
|
341
1868
|
removeTag(tag) {
|
|
342
|
-
|
|
1869
|
+
return this.dbOp("remove tag", () => {
|
|
343
1870
|
// Wrap in transaction for atomic updates
|
|
344
1871
|
const transaction = this.db.transaction(() => {
|
|
345
1872
|
// Get all evidences with this tag
|
|
@@ -348,8 +1875,11 @@ export class EvidenceDatabase {
|
|
|
348
1875
|
for (const evidence of evidences) {
|
|
349
1876
|
if (!evidence.tags)
|
|
350
1877
|
continue;
|
|
351
|
-
const tags = evidence.tags
|
|
352
|
-
|
|
1878
|
+
const tags = evidence.tags
|
|
1879
|
+
.split(",")
|
|
1880
|
+
.map((t) => t.trim())
|
|
1881
|
+
.filter((t) => t !== tag);
|
|
1882
|
+
const newTags = tags.length > 0 ? tags.join(",") : null;
|
|
353
1883
|
if (this.updateTags(evidence.id, newTags)) {
|
|
354
1884
|
updatedCount++;
|
|
355
1885
|
}
|
|
@@ -357,31 +1887,52 @@ export class EvidenceDatabase {
|
|
|
357
1887
|
return updatedCount;
|
|
358
1888
|
});
|
|
359
1889
|
return transaction();
|
|
360
|
-
}
|
|
361
|
-
catch (error) {
|
|
362
|
-
throw new Error(`Failed to remove tag: ${error instanceof Error ? error.message : String(error)}`);
|
|
363
|
-
}
|
|
1890
|
+
});
|
|
364
1891
|
}
|
|
365
1892
|
/**
|
|
366
1893
|
* Gets all unique tags with their counts
|
|
367
1894
|
* @returns Map of tag to count
|
|
368
1895
|
*/
|
|
369
1896
|
getTagCounts() {
|
|
370
|
-
|
|
1897
|
+
return this.dbOp("get tag counts", () => {
|
|
371
1898
|
const stmt = this.db.prepare(`SELECT tags FROM evidences WHERE tags IS NOT NULL AND tags != ''`);
|
|
372
1899
|
const rows = stmt.all();
|
|
373
1900
|
const tagCounts = new Map();
|
|
374
1901
|
for (const row of rows) {
|
|
375
|
-
const tags = row.tags
|
|
1902
|
+
const tags = row.tags
|
|
1903
|
+
.split(",")
|
|
1904
|
+
.map((t) => t.trim())
|
|
1905
|
+
.filter((t) => t);
|
|
376
1906
|
for (const tag of tags) {
|
|
377
1907
|
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
378
1908
|
}
|
|
379
1909
|
}
|
|
380
1910
|
return tagCounts;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Gets total count of all evidence records
|
|
1915
|
+
* @returns Total number of evidences in the database
|
|
1916
|
+
*/
|
|
1917
|
+
getTotalCount() {
|
|
1918
|
+
const row = this.db
|
|
1919
|
+
.prepare("SELECT COUNT(*) as count FROM evidences")
|
|
1920
|
+
.get();
|
|
1921
|
+
return row.count;
|
|
1922
|
+
}
|
|
1923
|
+
getSessionCount() {
|
|
1924
|
+
const row = this.db
|
|
1925
|
+
.prepare("SELECT COUNT(*) as count FROM sessions")
|
|
1926
|
+
.get();
|
|
1927
|
+
return row.count;
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Gets count of evidence records matching a search query
|
|
1931
|
+
* @param query - Search text to match against conversationId and tags
|
|
1932
|
+
* @returns Number of matching evidences
|
|
1933
|
+
*/
|
|
1934
|
+
getSearchCount(query) {
|
|
1935
|
+
return this.getFilteredCount({ query });
|
|
385
1936
|
}
|
|
386
1937
|
/**
|
|
387
1938
|
* Get the underlying database instance
|
|
@@ -419,7 +1970,131 @@ export class EvidenceDatabase {
|
|
|
419
1970
|
gitTimestamp: row.gitTimestamp,
|
|
420
1971
|
tags: row.tags,
|
|
421
1972
|
createdAt: row.createdAt,
|
|
422
|
-
updatedAt: row.updatedAt
|
|
1973
|
+
updatedAt: row.updatedAt,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
rowToSession(row) {
|
|
1977
|
+
return {
|
|
1978
|
+
id: row.id,
|
|
1979
|
+
host: row.host,
|
|
1980
|
+
projectRoot: row.projectRoot,
|
|
1981
|
+
cwd: row.cwd,
|
|
1982
|
+
title: row.title,
|
|
1983
|
+
status: row.status,
|
|
1984
|
+
startedAt: row.startedAt,
|
|
1985
|
+
endedAt: row.endedAt,
|
|
1986
|
+
metadata: row.metadata,
|
|
1987
|
+
createdAt: row.createdAt,
|
|
1988
|
+
updatedAt: row.updatedAt,
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
rowToContext(row) {
|
|
1992
|
+
return {
|
|
1993
|
+
id: row.id,
|
|
1994
|
+
label: row.label,
|
|
1995
|
+
workspaceKey: row.workspaceKey,
|
|
1996
|
+
status: row.status,
|
|
1997
|
+
mergedIntoContextId: row.mergedIntoContextId,
|
|
1998
|
+
metadata: row.metadata,
|
|
1999
|
+
createdAt: row.createdAt,
|
|
2000
|
+
updatedAt: row.updatedAt,
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
rowToContextSessionLink(row) {
|
|
2004
|
+
return {
|
|
2005
|
+
sessionId: row.sessionId,
|
|
2006
|
+
contextId: row.contextId,
|
|
2007
|
+
linkSource: row.linkSource,
|
|
2008
|
+
createdAt: row.createdAt,
|
|
2009
|
+
updatedAt: row.updatedAt,
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
rowToContextLinkRejection(row) {
|
|
2013
|
+
return {
|
|
2014
|
+
sessionId: row.sessionId,
|
|
2015
|
+
contextId: row.contextId,
|
|
2016
|
+
createdAt: row.createdAt,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
rowToContextWorkspacePreference(row) {
|
|
2020
|
+
return {
|
|
2021
|
+
workspaceKey: row.workspaceKey,
|
|
2022
|
+
contextId: row.contextId,
|
|
2023
|
+
createdAt: row.createdAt,
|
|
2024
|
+
updatedAt: row.updatedAt,
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
rowToMessage(row) {
|
|
2028
|
+
return {
|
|
2029
|
+
id: row.id,
|
|
2030
|
+
sessionId: row.sessionId,
|
|
2031
|
+
seq: row.seq,
|
|
2032
|
+
role: row.role,
|
|
2033
|
+
source: row.source,
|
|
2034
|
+
content: row.content,
|
|
2035
|
+
capturedAt: row.capturedAt,
|
|
2036
|
+
metadata: row.metadata,
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
rowToTimelineEvent(row) {
|
|
2040
|
+
return {
|
|
2041
|
+
id: row.id,
|
|
2042
|
+
sessionId: row.sessionId,
|
|
2043
|
+
seq: row.seq,
|
|
2044
|
+
eventType: row.eventType,
|
|
2045
|
+
eventSubType: row.eventSubType,
|
|
2046
|
+
source: row.source,
|
|
2047
|
+
summary: row.summary,
|
|
2048
|
+
payload: row.payload,
|
|
2049
|
+
startedAt: row.startedAt,
|
|
2050
|
+
endedAt: row.endedAt,
|
|
2051
|
+
status: row.status,
|
|
2052
|
+
relatedMessageId: row.relatedMessageId,
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
rowToArtifact(row) {
|
|
2056
|
+
return {
|
|
2057
|
+
id: row.id,
|
|
2058
|
+
sessionId: row.sessionId,
|
|
2059
|
+
eventId: row.eventId,
|
|
2060
|
+
artifactType: row.artifactType,
|
|
2061
|
+
path: row.path,
|
|
2062
|
+
metadata: row.metadata,
|
|
2063
|
+
createdAt: row.createdAt,
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
rowToDecision(row) {
|
|
2067
|
+
return {
|
|
2068
|
+
id: row.id,
|
|
2069
|
+
sessionId: row.sessionId,
|
|
2070
|
+
title: row.title,
|
|
2071
|
+
summary: row.summary,
|
|
2072
|
+
rationale: row.rationale,
|
|
2073
|
+
status: row.status,
|
|
2074
|
+
sourceRefs: row.sourceRefs,
|
|
2075
|
+
createdAt: row.createdAt,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
rowToNarrative(row) {
|
|
2079
|
+
return {
|
|
2080
|
+
id: row.id,
|
|
2081
|
+
sessionId: row.sessionId,
|
|
2082
|
+
kind: row.kind,
|
|
2083
|
+
content: row.content,
|
|
2084
|
+
sourceRefs: row.sourceRefs,
|
|
2085
|
+
createdAt: row.createdAt,
|
|
2086
|
+
updatedAt: row.updatedAt,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
rowToIngestionRun(row) {
|
|
2090
|
+
return {
|
|
2091
|
+
id: row.id,
|
|
2092
|
+
sessionId: row.sessionId,
|
|
2093
|
+
stage: row.stage,
|
|
2094
|
+
status: row.status,
|
|
2095
|
+
error: row.error,
|
|
2096
|
+
startedAt: row.startedAt,
|
|
2097
|
+
endedAt: row.endedAt,
|
|
423
2098
|
};
|
|
424
2099
|
}
|
|
425
2100
|
}
|