agent-mockingbird 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/btca-cli/SKILL.md +64 -0
- package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
- package/.env.example +36 -0
- package/.githooks/pre-commit +33 -0
- package/.github/workflows/ci.yml +309 -0
- package/.opencode/bun.lock +18 -0
- package/.opencode/package.json +5 -0
- package/.opencode/tools/agent_type_manager.ts +100 -0
- package/.opencode/tools/config_manager.ts +87 -0
- package/.opencode/tools/cron_manager.ts +145 -0
- package/.opencode/tools/memory_get.ts +43 -0
- package/.opencode/tools/memory_remember.ts +53 -0
- package/.opencode/tools/memory_search.ts +48 -0
- package/AGENTS.md +126 -0
- package/MEMORY.md +2 -0
- package/README.md +451 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/agent-mockingbird.config.example.json +135 -0
- package/apps/server/package.json +32 -0
- package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
- package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
- package/apps/server/src/backend/agents/openclawImport.ts +797 -0
- package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
- package/apps/server/src/backend/agents/service.ts +10 -0
- package/apps/server/src/backend/config/example-config.test.ts +20 -0
- package/apps/server/src/backend/config/orchestration.ts +243 -0
- package/apps/server/src/backend/config/policy.ts +158 -0
- package/apps/server/src/backend/config/schema.test.ts +15 -0
- package/apps/server/src/backend/config/schema.ts +391 -0
- package/apps/server/src/backend/config/semantic.test.ts +34 -0
- package/apps/server/src/backend/config/semantic.ts +149 -0
- package/apps/server/src/backend/config/service.test.ts +75 -0
- package/apps/server/src/backend/config/service.ts +207 -0
- package/apps/server/src/backend/config/smoke.ts +77 -0
- package/apps/server/src/backend/config/store.test.ts +123 -0
- package/apps/server/src/backend/config/store.ts +581 -0
- package/apps/server/src/backend/config/testFixtures.ts +5 -0
- package/apps/server/src/backend/config/types.ts +56 -0
- package/apps/server/src/backend/contracts/events.ts +320 -0
- package/apps/server/src/backend/contracts/runtime.ts +111 -0
- package/apps/server/src/backend/cron/executor.ts +435 -0
- package/apps/server/src/backend/cron/repository.ts +170 -0
- package/apps/server/src/backend/cron/service.ts +660 -0
- package/apps/server/src/backend/cron/storage.ts +92 -0
- package/apps/server/src/backend/cron/types.ts +138 -0
- package/apps/server/src/backend/cron/utils.ts +351 -0
- package/apps/server/src/backend/db/client.ts +20 -0
- package/apps/server/src/backend/db/migrate.ts +40 -0
- package/apps/server/src/backend/db/repository.ts +1762 -0
- package/apps/server/src/backend/db/schema.ts +113 -0
- package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
- package/apps/server/src/backend/db/wipe.ts +13 -0
- package/apps/server/src/backend/defaults.ts +32 -0
- package/apps/server/src/backend/env.ts +48 -0
- package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
- package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
- package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
- package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
- package/apps/server/src/backend/heartbeat/service.ts +176 -0
- package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
- package/apps/server/src/backend/heartbeat/state.ts +167 -0
- package/apps/server/src/backend/heartbeat/types.ts +54 -0
- package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
- package/apps/server/src/backend/http/boundedQueue.ts +92 -0
- package/apps/server/src/backend/http/parsers.ts +40 -0
- package/apps/server/src/backend/http/router.ts +61 -0
- package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
- package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
- package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
- package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
- package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
- package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
- package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
- package/apps/server/src/backend/http/routes/index.ts +101 -0
- package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
- package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
- package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
- package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
- package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
- package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
- package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
- package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
- package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
- package/apps/server/src/backend/http/schemas.ts +64 -0
- package/apps/server/src/backend/http/sse.ts +144 -0
- package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
- package/apps/server/src/backend/logging/logger.ts +64 -0
- package/apps/server/src/backend/mcp/service.ts +326 -0
- package/apps/server/src/backend/memory/cli.ts +170 -0
- package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
- package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
- package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
- package/apps/server/src/backend/memory/qmdPort.ts +61 -0
- package/apps/server/src/backend/memory/records.test.ts +66 -0
- package/apps/server/src/backend/memory/records.ts +229 -0
- package/apps/server/src/backend/memory/service.ts +2012 -0
- package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
- package/apps/server/src/backend/memory/types.ts +104 -0
- package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
- package/apps/server/src/backend/opencode/client.ts +98 -0
- package/apps/server/src/backend/opencode/models.ts +41 -0
- package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
- package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
- package/apps/server/src/backend/paths.ts +57 -0
- package/apps/server/src/backend/prompts/service.ts +100 -0
- package/apps/server/src/backend/queue/queue.test.ts +189 -0
- package/apps/server/src/backend/queue/service.ts +177 -0
- package/apps/server/src/backend/queue/types.ts +39 -0
- package/apps/server/src/backend/run/service.ts +576 -0
- package/apps/server/src/backend/run/storage.ts +47 -0
- package/apps/server/src/backend/run/types.ts +44 -0
- package/apps/server/src/backend/runtime/errors.ts +61 -0
- package/apps/server/src/backend/runtime/index.ts +72 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
- package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
- package/apps/server/src/backend/skills/service.ts +442 -0
- package/apps/server/src/backend/workspace/resolve.ts +27 -0
- package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
- package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
- package/apps/server/src/cli/runtime-assets.mjs +269 -0
- package/apps/server/src/cli/runtime-assets.test.ts +52 -0
- package/apps/server/src/cli/runtime-layout.mjs +75 -0
- package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
- package/apps/server/src/cli/standaloneBuild.ts +19 -0
- package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
- package/apps/server/src/index.ts +178 -0
- package/apps/server/tsconfig.json +12 -0
- package/backlog.md +5 -0
- package/bin/agent-mockingbird +2522 -0
- package/bin/runtime-layout.mjs +75 -0
- package/build-bin.ts +34 -0
- package/build-cli.mjs +37 -0
- package/build.ts +40 -0
- package/bun-env.d.ts +11 -0
- package/bun.lock +888 -0
- package/bunfig.toml +2 -0
- package/components.json +21 -0
- package/config.json +130 -0
- package/deploy/RELEASE_INSTALL.md +112 -0
- package/deploy/docker-compose.yml +42 -0
- package/deploy/systemd/README.md +46 -0
- package/deploy/systemd/agent-mockingbird.service +28 -0
- package/deploy/systemd/opencode.service +25 -0
- package/docs/legacy-config-ui-reference.md +51 -0
- package/docs/memory-e2e-trace-2026-03-04.md +63 -0
- package/docs/memory-ops.md +96 -0
- package/docs/memory-runtime-contract.md +42 -0
- package/docs/memory-tuning-remote-2026-03-04.md +59 -0
- package/docs/opencode-rebase-workflow-plan.md +614 -0
- package/docs/opencode-startup-sync-plan.md +94 -0
- package/docs/vendor-opencode.md +41 -0
- package/drizzle/0000_famous_turbo.sql +49 -0
- package/drizzle/0001_cron_memory_aux.sql +160 -0
- package/drizzle/0002_runtime_session_bindings.sql +28 -0
- package/drizzle/0003_background_runs.sql +27 -0
- package/drizzle/0004_memory_open_write.sql +63 -0
- package/drizzle/0005_signal_channel.sql +47 -0
- package/drizzle/0006_usage_event_dimensions.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +341 -0
- package/drizzle/meta/_journal.json +55 -0
- package/drizzle.config.ts +14 -0
- package/eslint.config.mjs +77 -0
- package/knip.json +18 -0
- package/memory/2026-03-04.md +4 -0
- package/opencode.lock.json +16 -0
- package/package.json +67 -0
- package/packages/agent-mockingbird-installer/README.md +31 -0
- package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
- package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
- package/packages/agent-mockingbird-installer/package.json +23 -0
- package/packages/contracts/package.json +19 -0
- package/packages/contracts/src/agentTypes.ts +122 -0
- package/packages/contracts/src/cron.ts +146 -0
- package/packages/contracts/src/dashboard.ts +378 -0
- package/packages/contracts/src/index.ts +3 -0
- package/packages/contracts/tsconfig.json +4 -0
- package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
- package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
- package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
- package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
- package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
- package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
- package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
- package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
- package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
- package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
- package/runtime-assets/opencode-config/opencode.jsonc +25 -0
- package/runtime-assets/opencode-config/package.json +5 -0
- package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
- package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
- package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
- package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
- package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
- package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
- package/runtime-assets/workspace/AGENTS.md +56 -0
- package/runtime-assets/workspace/MEMORY.md +4 -0
- package/scripts/build-release-bundle.sh +66 -0
- package/scripts/check-ship.ts +383 -0
- package/scripts/dev-opencode.sh +17 -0
- package/scripts/dev-stack-opencode.sh +15 -0
- package/scripts/dev-stack.sh +61 -0
- package/scripts/install-systemd.sh +87 -0
- package/scripts/memory-e2e.sh +76 -0
- package/scripts/memory-trace-e2e.sh +141 -0
- package/scripts/migrate-opencode-env.ts +108 -0
- package/scripts/onboard/bootstrap.sh +32 -0
- package/scripts/opencode-swap.ts +78 -0
- package/scripts/opencode-sync.ts +715 -0
- package/scripts/runtime-assets-sync.mjs +83 -0
- package/scripts/setup-git-hooks.ts +39 -0
- package/tsconfig.json +45 -0
- package/tui.json +98 -0
- package/turbo.json +36 -0
- package/vendor/OPENCODE_VENDOR.md +13 -0
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { buildConceptExpandedQueries } from "./conceptExpansion";
|
|
6
|
+
import { blendRrfAndRerank, hasStrongBm25Signal, reciprocalRankFusion } from "./qmdPort";
|
|
7
|
+
import {
|
|
8
|
+
createMemoryRecord,
|
|
9
|
+
extractRecordIdFromChunk,
|
|
10
|
+
formatMemoryRecord,
|
|
11
|
+
parseMemoryRecordBlocks,
|
|
12
|
+
parseMemoryRecords,
|
|
13
|
+
} from "./records";
|
|
14
|
+
import { ensureSqliteVecLoaded, getSqliteVecState } from "./sqliteVec";
|
|
15
|
+
import type {
|
|
16
|
+
MemoryChunk,
|
|
17
|
+
MemoryLintReport,
|
|
18
|
+
MemoryRecord,
|
|
19
|
+
MemoryRecordInput,
|
|
20
|
+
MemoryRememberInput,
|
|
21
|
+
MemoryRememberResult,
|
|
22
|
+
MemorySearchResult,
|
|
23
|
+
MemoryStatus,
|
|
24
|
+
MemoryWriteEvent,
|
|
25
|
+
MemoryWriteValidation,
|
|
26
|
+
} from "./types";
|
|
27
|
+
import { getConfigSnapshot } from "../config/service";
|
|
28
|
+
import { sqlite } from "../db/client";
|
|
29
|
+
import { getBinaryDir } from "../paths";
|
|
30
|
+
|
|
31
|
+
interface MemoryFileRow {
|
|
32
|
+
path: string;
|
|
33
|
+
hash: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MemoryChunkRow {
|
|
37
|
+
id: string;
|
|
38
|
+
path: string;
|
|
39
|
+
start_line: number;
|
|
40
|
+
end_line: number;
|
|
41
|
+
text: string;
|
|
42
|
+
embedding_json: string | null;
|
|
43
|
+
updated_at: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MemoryFtsRow {
|
|
47
|
+
chunk_id: string;
|
|
48
|
+
path: string;
|
|
49
|
+
start_line: number;
|
|
50
|
+
end_line: number;
|
|
51
|
+
text: string;
|
|
52
|
+
rank: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface MemoryRecordRow {
|
|
56
|
+
id: string;
|
|
57
|
+
confidence: number;
|
|
58
|
+
superseded_by: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MemoryWriteEventRow {
|
|
62
|
+
id: string;
|
|
63
|
+
status: "accepted" | "rejected";
|
|
64
|
+
reason: string;
|
|
65
|
+
source: string;
|
|
66
|
+
content: string;
|
|
67
|
+
confidence: number;
|
|
68
|
+
session_id: string | null;
|
|
69
|
+
topic: string | null;
|
|
70
|
+
record_id: string | null;
|
|
71
|
+
path: string | null;
|
|
72
|
+
created_at: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SearchCandidate {
|
|
76
|
+
id: string;
|
|
77
|
+
path: string;
|
|
78
|
+
startLine: number;
|
|
79
|
+
endLine: number;
|
|
80
|
+
text: string;
|
|
81
|
+
embedding: number[] | null;
|
|
82
|
+
vectorScore: number;
|
|
83
|
+
textScore: number;
|
|
84
|
+
score: number;
|
|
85
|
+
updatedAt: number;
|
|
86
|
+
recordId: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const MEMORY_META_KEY = "memory_index_meta_v1";
|
|
90
|
+
const MEMORY_VEC_META_KEY = "memory_vec_meta_v1";
|
|
91
|
+
const SOURCE = "memory";
|
|
92
|
+
const SNIPPET_MAX_CHARS = 700;
|
|
93
|
+
const DEFAULT_CANDIDATE_LIMIT = 48;
|
|
94
|
+
const MMR_LAMBDA = 0.75;
|
|
95
|
+
const VECTOR_PREFILTER_MIN = 200;
|
|
96
|
+
const VECTOR_PREFILTER_MAX = 800;
|
|
97
|
+
const SQLITE_IN_BIND_LIMIT = 900;
|
|
98
|
+
const MEMORY_VEC_TABLE = "memory_chunks_vec";
|
|
99
|
+
const SEARCH_TOKEN_RE = /[a-z0-9]{3,}/g;
|
|
100
|
+
const RECALL_INTENT_RE = /\b(?:what\s+do\s+you\s+remember|remind\s+me|recall|from\s+memory|what\s+do\s+you\s+know\s+about\s+me)\b/i;
|
|
101
|
+
const MEMORY_MANAGEMENT_RE = /\b(?:memory|remember|recall|note|save)\b/i;
|
|
102
|
+
const STOPWORDS = new Set([
|
|
103
|
+
"about",
|
|
104
|
+
"after",
|
|
105
|
+
"again",
|
|
106
|
+
"against",
|
|
107
|
+
"also",
|
|
108
|
+
"and",
|
|
109
|
+
"any",
|
|
110
|
+
"are",
|
|
111
|
+
"because",
|
|
112
|
+
"been",
|
|
113
|
+
"being",
|
|
114
|
+
"between",
|
|
115
|
+
"both",
|
|
116
|
+
"but",
|
|
117
|
+
"can",
|
|
118
|
+
"could",
|
|
119
|
+
"did",
|
|
120
|
+
"does",
|
|
121
|
+
"doing",
|
|
122
|
+
"dont",
|
|
123
|
+
"each",
|
|
124
|
+
"few",
|
|
125
|
+
"for",
|
|
126
|
+
"from",
|
|
127
|
+
"had",
|
|
128
|
+
"has",
|
|
129
|
+
"have",
|
|
130
|
+
"here",
|
|
131
|
+
"how",
|
|
132
|
+
"into",
|
|
133
|
+
"its",
|
|
134
|
+
"just",
|
|
135
|
+
"more",
|
|
136
|
+
"most",
|
|
137
|
+
"not",
|
|
138
|
+
"now",
|
|
139
|
+
"off",
|
|
140
|
+
"our",
|
|
141
|
+
"out",
|
|
142
|
+
"over",
|
|
143
|
+
"same",
|
|
144
|
+
"should",
|
|
145
|
+
"some",
|
|
146
|
+
"such",
|
|
147
|
+
"than",
|
|
148
|
+
"that",
|
|
149
|
+
"the",
|
|
150
|
+
"their",
|
|
151
|
+
"them",
|
|
152
|
+
"then",
|
|
153
|
+
"there",
|
|
154
|
+
"these",
|
|
155
|
+
"they",
|
|
156
|
+
"this",
|
|
157
|
+
"those",
|
|
158
|
+
"through",
|
|
159
|
+
"under",
|
|
160
|
+
"until",
|
|
161
|
+
"very",
|
|
162
|
+
"want",
|
|
163
|
+
"what",
|
|
164
|
+
"when",
|
|
165
|
+
"where",
|
|
166
|
+
"which",
|
|
167
|
+
"while",
|
|
168
|
+
"with",
|
|
169
|
+
"would",
|
|
170
|
+
"your",
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
let schemaReady = false;
|
|
174
|
+
let lastSyncMs = 0;
|
|
175
|
+
let syncPromise: Promise<void> | null = null;
|
|
176
|
+
|
|
177
|
+
const nowMs = () => Date.now();
|
|
178
|
+
const hashText = (value: string) => crypto.createHash("sha256").update(value).digest("hex");
|
|
179
|
+
|
|
180
|
+
function currentMemoryConfig() {
|
|
181
|
+
return getConfigSnapshot().config.runtime.memory;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveWorkspaceDir() {
|
|
185
|
+
const workspaceDir = currentMemoryConfig().workspaceDir;
|
|
186
|
+
if (path.isAbsolute(workspaceDir)) {
|
|
187
|
+
return workspaceDir;
|
|
188
|
+
}
|
|
189
|
+
return path.resolve(getBinaryDir(), workspaceDir);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeRelPath(absPath: string) {
|
|
193
|
+
return path.relative(resolveWorkspaceDir(), absPath).replaceAll(path.sep, "/");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sqliteTableExists(name: string) {
|
|
197
|
+
const row = sqlite
|
|
198
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1")
|
|
199
|
+
.get(name) as { name: string } | null;
|
|
200
|
+
return Boolean(row?.name);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isMemoryPath(relPath: string) {
|
|
204
|
+
const normalized = relPath.trim().replaceAll("\\", "/");
|
|
205
|
+
if (!normalized || normalized.startsWith("../")) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (normalized === "MEMORY.md" || normalized === "memory.md") {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return normalized.startsWith("memory/") && normalized.endsWith(".md");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildChunkId(relPath: string, chunk: MemoryChunk) {
|
|
215
|
+
return hashText(`${SOURCE}:${relPath}:${chunk.startLine}:${chunk.endLine}:${chunk.hash}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseEmbedding(raw: string | null): number[] {
|
|
219
|
+
if (!raw) return [];
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(raw) as number[];
|
|
222
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function cosineSimilarity(a: number[], b: number[]) {
|
|
229
|
+
if (!a.length || !b.length) return 0;
|
|
230
|
+
const length = Math.min(a.length, b.length);
|
|
231
|
+
let dot = 0;
|
|
232
|
+
let magA = 0;
|
|
233
|
+
let magB = 0;
|
|
234
|
+
for (let i = 0; i < length; i += 1) {
|
|
235
|
+
const av = a[i] ?? 0;
|
|
236
|
+
const bv = b[i] ?? 0;
|
|
237
|
+
dot += av * bv;
|
|
238
|
+
magA += av * av;
|
|
239
|
+
magB += bv * bv;
|
|
240
|
+
}
|
|
241
|
+
if (magA <= 0 || magB <= 0) return 0;
|
|
242
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function clipSnippet(text: string, maxChars = SNIPPET_MAX_CHARS) {
|
|
246
|
+
if (text.length <= maxChars) return text;
|
|
247
|
+
return `${text.slice(0, maxChars).trimEnd()}…`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function collectQueryTokens(text: string) {
|
|
251
|
+
const matched = text.toLowerCase().match(SEARCH_TOKEN_RE) ?? [];
|
|
252
|
+
return new Set(matched.filter(token => !STOPWORDS.has(token)));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeSnippetFromChunk(text: string) {
|
|
256
|
+
const structured = text.match(/^(###\s+\[memory:[^\n]+\])\n(?:meta:[^\n]*\n)?\n?([\s\S]*)$/i);
|
|
257
|
+
if (!structured) {
|
|
258
|
+
return clipSnippet(text);
|
|
259
|
+
}
|
|
260
|
+
const heading = structured[1]?.trim();
|
|
261
|
+
const body = structured[2]?.trim();
|
|
262
|
+
if (!heading || !body) {
|
|
263
|
+
return clipSnippet(text);
|
|
264
|
+
}
|
|
265
|
+
return clipSnippet(`${heading}\n${body}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isMemoryRecallIntentQuery(query: string) {
|
|
269
|
+
return RECALL_INTENT_RE.test(query);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isMemoryManagementQuery(query: string) {
|
|
273
|
+
return MEMORY_MANAGEMENT_RE.test(query);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hasLexicalSignal(candidate: SearchCandidate, queryTokens: Set<string>) {
|
|
277
|
+
if (!queryTokens.size) return true;
|
|
278
|
+
const candidateTokens = collectQueryTokens(`${candidate.path}\n${candidate.text}`);
|
|
279
|
+
let overlap = 0;
|
|
280
|
+
for (const token of queryTokens) {
|
|
281
|
+
if (!candidateTokens.has(token)) continue;
|
|
282
|
+
overlap += 1;
|
|
283
|
+
if (overlap >= 2) return true;
|
|
284
|
+
}
|
|
285
|
+
return overlap >= 1 && candidate.score >= 0.55;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isLikelyBoilerplateIndexCandidate(candidate: SearchCandidate) {
|
|
289
|
+
if (candidate.path.toLowerCase() !== "memory.md") return false;
|
|
290
|
+
const text = candidate.text.toLowerCase();
|
|
291
|
+
return (
|
|
292
|
+
text.includes("memory index") ||
|
|
293
|
+
text.includes("store durable notes") ||
|
|
294
|
+
text.includes("memory/*.md")
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeRecordInput(input: MemoryRecordInput): MemoryRecordInput {
|
|
299
|
+
return {
|
|
300
|
+
source: input.source,
|
|
301
|
+
content: input.content.trim(),
|
|
302
|
+
entities: [...new Set((input.entities ?? []).map(value => value.trim()).filter(Boolean))],
|
|
303
|
+
confidence:
|
|
304
|
+
typeof input.confidence === "number" ? Math.max(0, Math.min(1, input.confidence)) : undefined,
|
|
305
|
+
supersedes: [...new Set((input.supersedes ?? []).map(value => value.trim()).filter(Boolean))],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function chunkMarkdown(content: string, tokens: number, overlap: number): MemoryChunk[] {
|
|
310
|
+
const lines = content.split("\n");
|
|
311
|
+
if (!lines.length) return [];
|
|
312
|
+
|
|
313
|
+
const maxChars = Math.max(64, tokens * 4);
|
|
314
|
+
const overlapChars = Math.max(0, overlap * 4);
|
|
315
|
+
|
|
316
|
+
const chunks: MemoryChunk[] = [];
|
|
317
|
+
let current: Array<{ line: string; lineNo: number }> = [];
|
|
318
|
+
let charCount = 0;
|
|
319
|
+
|
|
320
|
+
const flush = () => {
|
|
321
|
+
if (!current.length) return;
|
|
322
|
+
const first = current[0];
|
|
323
|
+
const last = current[current.length - 1];
|
|
324
|
+
if (!first || !last) return;
|
|
325
|
+
const text = current.map(entry => entry.line).join("\n");
|
|
326
|
+
chunks.push({
|
|
327
|
+
id: "",
|
|
328
|
+
path: "",
|
|
329
|
+
startLine: first.lineNo,
|
|
330
|
+
endLine: last.lineNo,
|
|
331
|
+
text,
|
|
332
|
+
hash: hashText(text),
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const carryOverlap = () => {
|
|
337
|
+
if (overlapChars <= 0 || !current.length) {
|
|
338
|
+
current = [];
|
|
339
|
+
charCount = 0;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let tracked = 0;
|
|
344
|
+
const kept: Array<{ line: string; lineNo: number }> = [];
|
|
345
|
+
for (let i = current.length - 1; i >= 0; i -= 1) {
|
|
346
|
+
const entry = current[i];
|
|
347
|
+
if (!entry) continue;
|
|
348
|
+
tracked += entry.line.length + 1;
|
|
349
|
+
kept.unshift(entry);
|
|
350
|
+
if (tracked >= overlapChars) break;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
current = kept;
|
|
354
|
+
charCount = kept.reduce((sum, entry) => sum + entry.line.length + 1, 0);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
358
|
+
const line = lines[index] ?? "";
|
|
359
|
+
const lineNo = index + 1;
|
|
360
|
+
const segments: string[] = line.length
|
|
361
|
+
? Array.from({ length: Math.ceil(line.length / maxChars) }, (_, segmentIndex) =>
|
|
362
|
+
line.slice(segmentIndex * maxChars, segmentIndex * maxChars + maxChars),
|
|
363
|
+
)
|
|
364
|
+
: [""];
|
|
365
|
+
|
|
366
|
+
for (const segment of segments) {
|
|
367
|
+
const segmentSize = segment.length + 1;
|
|
368
|
+
if (charCount + segmentSize > maxChars && current.length) {
|
|
369
|
+
flush();
|
|
370
|
+
carryOverlap();
|
|
371
|
+
}
|
|
372
|
+
current.push({ line: segment, lineNo });
|
|
373
|
+
charCount += segmentSize;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
flush();
|
|
378
|
+
return chunks.filter(chunk => chunk.text.trim().length > 0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildFtsQuery(query: string): string | null {
|
|
382
|
+
const tokens = query
|
|
383
|
+
.toLowerCase()
|
|
384
|
+
.split(/[^a-z0-9_]+/i)
|
|
385
|
+
.map(token => token.trim())
|
|
386
|
+
.filter(token => token.length >= 2);
|
|
387
|
+
if (!tokens.length) return null;
|
|
388
|
+
return tokens.map(token => `${token}*`).join(" AND ");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function walkDirectory(dir: string, output: string[]) {
|
|
392
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
393
|
+
for (const entry of entries) {
|
|
394
|
+
const full = path.join(dir, entry.name);
|
|
395
|
+
if (entry.isSymbolicLink()) continue;
|
|
396
|
+
if (entry.isDirectory()) {
|
|
397
|
+
await walkDirectory(full, output);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
401
|
+
output.push(full);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function listMemoryFiles(): Promise<string[]> {
|
|
406
|
+
const workspaceDir = resolveWorkspaceDir();
|
|
407
|
+
const files: string[] = [];
|
|
408
|
+
const baseCandidates = ["MEMORY.md", "memory.md"].map(name => path.join(workspaceDir, name));
|
|
409
|
+
for (const candidate of baseCandidates) {
|
|
410
|
+
try {
|
|
411
|
+
const candidateStat = await lstat(candidate);
|
|
412
|
+
if (candidateStat.isFile() && !candidateStat.isSymbolicLink()) {
|
|
413
|
+
files.push(candidate);
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// noop
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const memoryDir = path.join(workspaceDir, "memory");
|
|
421
|
+
try {
|
|
422
|
+
const memoryStat = await lstat(memoryDir);
|
|
423
|
+
if (memoryStat.isDirectory() && !memoryStat.isSymbolicLink()) {
|
|
424
|
+
await walkDirectory(memoryDir, files);
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
// noop
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return [...new Set(files)];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function ensureWorkspaceScaffold() {
|
|
434
|
+
const workspaceDir = resolveWorkspaceDir();
|
|
435
|
+
await mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
|
436
|
+
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
|
437
|
+
try {
|
|
438
|
+
await stat(memoryFile);
|
|
439
|
+
} catch {
|
|
440
|
+
await writeFile(memoryFile, "# Memory\n\n", "utf8");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function memoryIndexMeta() {
|
|
445
|
+
if (!sqliteTableExists("memory_meta")) return null;
|
|
446
|
+
const row = sqlite
|
|
447
|
+
.query("SELECT value_json FROM memory_meta WHERE key = ?1")
|
|
448
|
+
.get(MEMORY_META_KEY) as { value_json: string } | null;
|
|
449
|
+
if (!row?.value_json) return null;
|
|
450
|
+
try {
|
|
451
|
+
return JSON.parse(row.value_json) as {
|
|
452
|
+
provider?: string;
|
|
453
|
+
model?: string;
|
|
454
|
+
chunkTokens?: number;
|
|
455
|
+
chunkOverlap?: number;
|
|
456
|
+
indexedAt?: number;
|
|
457
|
+
};
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function memoryVecMeta() {
|
|
464
|
+
if (!sqliteTableExists("memory_meta")) return null;
|
|
465
|
+
const row = sqlite
|
|
466
|
+
.query("SELECT value_json FROM memory_meta WHERE key = ?1")
|
|
467
|
+
.get(MEMORY_VEC_META_KEY) as { value_json: string } | null;
|
|
468
|
+
if (!row?.value_json) return null;
|
|
469
|
+
try {
|
|
470
|
+
return JSON.parse(row.value_json) as {
|
|
471
|
+
enabled?: boolean;
|
|
472
|
+
dims?: number;
|
|
473
|
+
model?: string;
|
|
474
|
+
provider?: string;
|
|
475
|
+
updatedAt?: number;
|
|
476
|
+
error?: string | null;
|
|
477
|
+
};
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function vecTableInfo() {
|
|
484
|
+
return sqlite
|
|
485
|
+
.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1")
|
|
486
|
+
.get(MEMORY_VEC_TABLE) as { sql: string } | null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function vecTableExists() {
|
|
490
|
+
return Boolean(vecTableInfo());
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function vecTableDimensions() {
|
|
494
|
+
const info = vecTableInfo();
|
|
495
|
+
if (!info?.sql) return null;
|
|
496
|
+
const match = info.sql.match(/float\[(\d+)\]/);
|
|
497
|
+
if (!match?.[1]) return null;
|
|
498
|
+
const parsed = Number(match[1]);
|
|
499
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function recreateVecTableForDimensions(dims: number) {
|
|
503
|
+
sqlite.exec(`DROP TABLE IF EXISTS ${MEMORY_VEC_TABLE}`);
|
|
504
|
+
sqlite.exec(
|
|
505
|
+
`CREATE VIRTUAL TABLE ${MEMORY_VEC_TABLE} USING vec0(chunk_id TEXT PRIMARY KEY, embedding float[${dims}] distance_metric=cosine)`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function ensureVecTableForDimensions(dims: number) {
|
|
510
|
+
if (!Number.isFinite(dims) || dims <= 0) return false;
|
|
511
|
+
const existingDims = vecTableDimensions();
|
|
512
|
+
if (existingDims === dims && vecTableExists()) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
recreateVecTableForDimensions(dims);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function deleteVecRowsByChunkIds(chunkIds: string[]) {
|
|
520
|
+
if (!chunkIds.length || !vecTableExists()) return;
|
|
521
|
+
const batches: string[][] = [];
|
|
522
|
+
for (let index = 0; index < chunkIds.length; index += SQLITE_IN_BIND_LIMIT) {
|
|
523
|
+
batches.push(chunkIds.slice(index, index + SQLITE_IN_BIND_LIMIT));
|
|
524
|
+
}
|
|
525
|
+
for (const batch of batches) {
|
|
526
|
+
if (!batch.length) continue;
|
|
527
|
+
sqlite
|
|
528
|
+
.query(`DELETE FROM ${MEMORY_VEC_TABLE} WHERE chunk_id IN (${batch.map(() => "?").join(", ")})`)
|
|
529
|
+
.run(...batch);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function upsertVecRows(rows: Array<{ chunkId: string; embedding: number[] }>) {
|
|
534
|
+
if (!rows.length || !vecTableExists()) return;
|
|
535
|
+
const insert = sqlite.query(`INSERT OR REPLACE INTO ${MEMORY_VEC_TABLE} (chunk_id, embedding) VALUES (?1, ?2)`);
|
|
536
|
+
for (const row of rows) {
|
|
537
|
+
if (!row.embedding.length) continue;
|
|
538
|
+
insert.run(row.chunkId, new Float32Array(row.embedding));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function ensureSchema() {
|
|
543
|
+
if (schemaReady) return;
|
|
544
|
+
await ensureSqliteVecLoaded(sqlite);
|
|
545
|
+
sqlite.exec(`
|
|
546
|
+
CREATE TABLE IF NOT EXISTS memory_meta (
|
|
547
|
+
key TEXT PRIMARY KEY,
|
|
548
|
+
value_json TEXT NOT NULL
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
CREATE TABLE IF NOT EXISTS memory_files (
|
|
552
|
+
path TEXT PRIMARY KEY,
|
|
553
|
+
source TEXT NOT NULL DEFAULT 'memory',
|
|
554
|
+
hash TEXT NOT NULL,
|
|
555
|
+
mtime INTEGER NOT NULL,
|
|
556
|
+
size INTEGER NOT NULL,
|
|
557
|
+
indexed_at INTEGER NOT NULL
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
561
|
+
id TEXT PRIMARY KEY,
|
|
562
|
+
path TEXT NOT NULL,
|
|
563
|
+
source TEXT NOT NULL DEFAULT 'memory',
|
|
564
|
+
start_line INTEGER NOT NULL,
|
|
565
|
+
end_line INTEGER NOT NULL,
|
|
566
|
+
hash TEXT NOT NULL,
|
|
567
|
+
text TEXT NOT NULL,
|
|
568
|
+
embedding_json TEXT,
|
|
569
|
+
updated_at INTEGER NOT NULL
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
CREATE INDEX IF NOT EXISTS memory_chunks_path_idx ON memory_chunks(path);
|
|
573
|
+
CREATE INDEX IF NOT EXISTS memory_chunks_updated_idx ON memory_chunks(updated_at);
|
|
574
|
+
|
|
575
|
+
CREATE TABLE IF NOT EXISTS memory_embedding_cache (
|
|
576
|
+
provider TEXT NOT NULL,
|
|
577
|
+
model TEXT NOT NULL,
|
|
578
|
+
hash TEXT NOT NULL,
|
|
579
|
+
embedding_json TEXT NOT NULL,
|
|
580
|
+
dims INTEGER NOT NULL,
|
|
581
|
+
updated_at INTEGER NOT NULL,
|
|
582
|
+
PRIMARY KEY(provider, model, hash)
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
CREATE INDEX IF NOT EXISTS memory_embedding_cache_updated_idx ON memory_embedding_cache(updated_at);
|
|
586
|
+
|
|
587
|
+
CREATE TABLE IF NOT EXISTS memory_records (
|
|
588
|
+
id TEXT PRIMARY KEY,
|
|
589
|
+
path TEXT NOT NULL,
|
|
590
|
+
source TEXT NOT NULL,
|
|
591
|
+
content TEXT NOT NULL,
|
|
592
|
+
entities_json TEXT NOT NULL,
|
|
593
|
+
confidence REAL NOT NULL,
|
|
594
|
+
supersedes_json TEXT NOT NULL,
|
|
595
|
+
superseded_by TEXT,
|
|
596
|
+
recorded_at INTEGER NOT NULL,
|
|
597
|
+
updated_at INTEGER NOT NULL
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
CREATE INDEX IF NOT EXISTS memory_records_path_idx ON memory_records(path);
|
|
601
|
+
CREATE INDEX IF NOT EXISTS memory_records_superseded_idx ON memory_records(superseded_by);
|
|
602
|
+
|
|
603
|
+
CREATE TABLE IF NOT EXISTS memory_write_events (
|
|
604
|
+
id TEXT PRIMARY KEY,
|
|
605
|
+
status TEXT NOT NULL,
|
|
606
|
+
reason TEXT NOT NULL,
|
|
607
|
+
source TEXT NOT NULL,
|
|
608
|
+
content TEXT NOT NULL,
|
|
609
|
+
confidence REAL NOT NULL,
|
|
610
|
+
session_id TEXT,
|
|
611
|
+
topic TEXT,
|
|
612
|
+
record_id TEXT,
|
|
613
|
+
path TEXT,
|
|
614
|
+
created_at INTEGER NOT NULL
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
CREATE INDEX IF NOT EXISTS memory_write_events_created_idx ON memory_write_events(created_at DESC);
|
|
618
|
+
`);
|
|
619
|
+
|
|
620
|
+
sqlite.exec(`
|
|
621
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
|
|
622
|
+
text,
|
|
623
|
+
chunk_id UNINDEXED,
|
|
624
|
+
path UNINDEXED,
|
|
625
|
+
start_line UNINDEXED,
|
|
626
|
+
end_line UNINDEXED,
|
|
627
|
+
updated_at UNINDEXED
|
|
628
|
+
);
|
|
629
|
+
`);
|
|
630
|
+
|
|
631
|
+
schemaReady = true;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function getEmbeddingProviderConfig() {
|
|
635
|
+
const memoryConfig = currentMemoryConfig();
|
|
636
|
+
return {
|
|
637
|
+
provider: memoryConfig.embedProvider,
|
|
638
|
+
model: memoryConfig.embedModel,
|
|
639
|
+
ollamaBaseUrl: memoryConfig.ollamaBaseUrl,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function embedWithOllama(texts: string[]): Promise<number[][]> {
|
|
644
|
+
if (!texts.length) return [];
|
|
645
|
+
|
|
646
|
+
const { ollamaBaseUrl, model } = getEmbeddingProviderConfig();
|
|
647
|
+
const baseUrl = ollamaBaseUrl.replace(/\/+$/, "");
|
|
648
|
+
const embedResponse = await fetch(`${baseUrl}/api/embed`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({ model, input: texts }),
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
if (embedResponse.ok) {
|
|
655
|
+
const payload = (await embedResponse.json()) as { embeddings?: number[][] };
|
|
656
|
+
const embeddings = payload.embeddings ?? [];
|
|
657
|
+
if (embeddings.length === texts.length) {
|
|
658
|
+
return embeddings.map(vector => vector.map(value => (Number.isFinite(value) ? value : 0)));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const vectors: number[][] = [];
|
|
663
|
+
for (const text of texts) {
|
|
664
|
+
const fallbackResponse = await fetch(`${baseUrl}/api/embeddings`, {
|
|
665
|
+
method: "POST",
|
|
666
|
+
headers: { "Content-Type": "application/json" },
|
|
667
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
668
|
+
});
|
|
669
|
+
if (!fallbackResponse.ok) {
|
|
670
|
+
const payload = await fallbackResponse.text();
|
|
671
|
+
throw new Error(`Ollama embedding failed: ${fallbackResponse.status} ${payload}`);
|
|
672
|
+
}
|
|
673
|
+
const payload = (await fallbackResponse.json()) as { embedding?: number[] };
|
|
674
|
+
vectors.push((payload.embedding ?? []).map(value => (Number.isFinite(value) ? value : 0)));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return vectors;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function embedTexts(texts: string[]): Promise<number[][]> {
|
|
681
|
+
const provider = currentMemoryConfig().embedProvider;
|
|
682
|
+
if (provider === "none") {
|
|
683
|
+
return texts.map(() => []);
|
|
684
|
+
}
|
|
685
|
+
return embedWithOllama(texts);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function loadEmbeddingCache(hashes: string[]) {
|
|
689
|
+
if (!hashes.length) return new Map<string, number[]>();
|
|
690
|
+
const { provider, model } = getEmbeddingProviderConfig();
|
|
691
|
+
const unique = [...new Set(hashes)];
|
|
692
|
+
const placeholders = unique.map(() => "?").join(", ");
|
|
693
|
+
const rows = sqlite
|
|
694
|
+
.query(
|
|
695
|
+
`
|
|
696
|
+
SELECT hash, embedding_json
|
|
697
|
+
FROM memory_embedding_cache
|
|
698
|
+
WHERE provider = ?1 AND model = ?2 AND hash IN (${placeholders})
|
|
699
|
+
`,
|
|
700
|
+
)
|
|
701
|
+
.all(provider, model, ...unique) as Array<{ hash: string; embedding_json: string }>;
|
|
702
|
+
|
|
703
|
+
return new Map(rows.map(row => [row.hash, parseEmbedding(row.embedding_json)]));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function upsertEmbeddingCache(entries: Array<{ hash: string; embedding: number[] }>) {
|
|
707
|
+
if (!entries.length) return;
|
|
708
|
+
const { provider, model } = getEmbeddingProviderConfig();
|
|
709
|
+
const upsert = sqlite.query(`
|
|
710
|
+
INSERT INTO memory_embedding_cache (provider, model, hash, embedding_json, dims, updated_at)
|
|
711
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
712
|
+
ON CONFLICT(provider, model, hash) DO UPDATE SET
|
|
713
|
+
embedding_json = excluded.embedding_json,
|
|
714
|
+
dims = excluded.dims,
|
|
715
|
+
updated_at = excluded.updated_at
|
|
716
|
+
`);
|
|
717
|
+
const updatedAt = nowMs();
|
|
718
|
+
for (const entry of entries) {
|
|
719
|
+
upsert.run(provider, model, entry.hash, JSON.stringify(entry.embedding), entry.embedding.length, updatedAt);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function embedChunks(chunks: MemoryChunk[]) {
|
|
724
|
+
if (!chunks.length) return [];
|
|
725
|
+
const cache = loadEmbeddingCache(chunks.map(chunk => chunk.hash));
|
|
726
|
+
const result: number[][] = Array.from({ length: chunks.length }, () => []);
|
|
727
|
+
const missing: Array<{ index: number; chunk: MemoryChunk }> = [];
|
|
728
|
+
|
|
729
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
730
|
+
const chunk = chunks[i];
|
|
731
|
+
if (!chunk) continue;
|
|
732
|
+
const cached = cache.get(chunk.hash);
|
|
733
|
+
if (cached?.length) {
|
|
734
|
+
result[i] = cached;
|
|
735
|
+
} else {
|
|
736
|
+
missing.push({ index: i, chunk });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!missing.length) return result;
|
|
741
|
+
|
|
742
|
+
const embeddings = await embedTexts(missing.map(item => item.chunk.text));
|
|
743
|
+
const cacheEntries: Array<{ hash: string; embedding: number[] }> = [];
|
|
744
|
+
for (let i = 0; i < missing.length; i += 1) {
|
|
745
|
+
const missingItem = missing[i];
|
|
746
|
+
const embedding = embeddings[i] ?? [];
|
|
747
|
+
if (!missingItem) continue;
|
|
748
|
+
result[missingItem.index] = embedding;
|
|
749
|
+
cacheEntries.push({ hash: missingItem.chunk.hash, embedding });
|
|
750
|
+
}
|
|
751
|
+
upsertEmbeddingCache(cacheEntries);
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function indexMemoryFile(filePath: string, options?: { force?: boolean }) {
|
|
756
|
+
const fileStat = await stat(filePath);
|
|
757
|
+
const content = await readFile(filePath, "utf8");
|
|
758
|
+
const fileHash = hashText(content);
|
|
759
|
+
const relPath = normalizeRelPath(filePath);
|
|
760
|
+
|
|
761
|
+
const row = sqlite
|
|
762
|
+
.query("SELECT path, hash FROM memory_files WHERE path = ?1")
|
|
763
|
+
.get(relPath) as MemoryFileRow | null;
|
|
764
|
+
if (!options?.force && row?.hash === fileHash) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const memoryConfig = currentMemoryConfig();
|
|
769
|
+
const recordBlocks = parseMemoryRecordBlocks(content);
|
|
770
|
+
const chunks =
|
|
771
|
+
recordBlocks.length > 0
|
|
772
|
+
? recordBlocks.map(block => {
|
|
773
|
+
const text = block.text;
|
|
774
|
+
const hash = hashText(text);
|
|
775
|
+
const chunk: MemoryChunk = {
|
|
776
|
+
id: "",
|
|
777
|
+
path: relPath,
|
|
778
|
+
startLine: block.startLine,
|
|
779
|
+
endLine: block.endLine,
|
|
780
|
+
text,
|
|
781
|
+
hash,
|
|
782
|
+
};
|
|
783
|
+
return {
|
|
784
|
+
...chunk,
|
|
785
|
+
id: buildChunkId(relPath, chunk),
|
|
786
|
+
};
|
|
787
|
+
})
|
|
788
|
+
: chunkMarkdown(content, memoryConfig.chunkTokens, memoryConfig.chunkOverlap).map(
|
|
789
|
+
chunk => ({
|
|
790
|
+
...chunk,
|
|
791
|
+
id: buildChunkId(relPath, chunk),
|
|
792
|
+
path: relPath,
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
const embeddings = await embedChunks(chunks);
|
|
796
|
+
const vecState = getSqliteVecState();
|
|
797
|
+
const retrievalConfig = memoryConfig.retrieval;
|
|
798
|
+
const sqliteVecWriteEnabled = retrievalConfig.vectorBackend === "sqlite_vec" && vecState.available;
|
|
799
|
+
const vecRows = chunks
|
|
800
|
+
.map((chunk, index) => ({ chunkId: chunk.id, embedding: embeddings[index] ?? [] }))
|
|
801
|
+
.filter(row => row.embedding.length > 0);
|
|
802
|
+
const vecDims = vecRows[0]?.embedding.length ?? 0;
|
|
803
|
+
if (sqliteVecWriteEnabled && vecDims > 0) {
|
|
804
|
+
ensureVecTableForDimensions(vecDims);
|
|
805
|
+
}
|
|
806
|
+
const updatedAt = nowMs();
|
|
807
|
+
|
|
808
|
+
const tx = sqlite.transaction(() => {
|
|
809
|
+
const existingChunkRows = sqlite
|
|
810
|
+
.query(
|
|
811
|
+
`
|
|
812
|
+
SELECT id
|
|
813
|
+
FROM memory_chunks
|
|
814
|
+
WHERE path = ?1
|
|
815
|
+
`,
|
|
816
|
+
)
|
|
817
|
+
.all(relPath) as Array<{ id: string }>;
|
|
818
|
+
if (sqliteVecWriteEnabled && existingChunkRows.length) {
|
|
819
|
+
deleteVecRowsByChunkIds(existingChunkRows.map(row => row.id));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
sqlite.query("DELETE FROM memory_chunks_fts WHERE path = ?1").run(relPath);
|
|
823
|
+
sqlite.query("DELETE FROM memory_chunks WHERE path = ?1").run(relPath);
|
|
824
|
+
sqlite.query("DELETE FROM memory_records WHERE path = ?1").run(relPath);
|
|
825
|
+
|
|
826
|
+
const insertChunk = sqlite.query(`
|
|
827
|
+
INSERT INTO memory_chunks (
|
|
828
|
+
id, path, source, start_line, end_line, hash, text, embedding_json, updated_at
|
|
829
|
+
)
|
|
830
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
831
|
+
`);
|
|
832
|
+
const insertFts = sqlite.query(`
|
|
833
|
+
INSERT INTO memory_chunks_fts (text, chunk_id, path, start_line, end_line, updated_at)
|
|
834
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
835
|
+
`);
|
|
836
|
+
|
|
837
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
838
|
+
const chunk = chunks[index];
|
|
839
|
+
if (!chunk) continue;
|
|
840
|
+
const embedding = embeddings[index] ?? [];
|
|
841
|
+
insertChunk.run(
|
|
842
|
+
chunk.id,
|
|
843
|
+
relPath,
|
|
844
|
+
SOURCE,
|
|
845
|
+
chunk.startLine,
|
|
846
|
+
chunk.endLine,
|
|
847
|
+
chunk.hash,
|
|
848
|
+
chunk.text,
|
|
849
|
+
embedding.length ? JSON.stringify(embedding) : null,
|
|
850
|
+
updatedAt,
|
|
851
|
+
);
|
|
852
|
+
insertFts.run(chunk.text, chunk.id, relPath, chunk.startLine, chunk.endLine, updatedAt);
|
|
853
|
+
}
|
|
854
|
+
if (sqliteVecWriteEnabled) {
|
|
855
|
+
upsertVecRows(vecRows);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const records = parseMemoryRecords(content);
|
|
859
|
+
const insertRecord = sqlite.query(`
|
|
860
|
+
INSERT INTO memory_records (
|
|
861
|
+
id, path, source, content, entities_json, confidence, supersedes_json, superseded_by, recorded_at, updated_at
|
|
862
|
+
)
|
|
863
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)
|
|
864
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
865
|
+
path = excluded.path,
|
|
866
|
+
source = excluded.source,
|
|
867
|
+
content = excluded.content,
|
|
868
|
+
entities_json = excluded.entities_json,
|
|
869
|
+
confidence = excluded.confidence,
|
|
870
|
+
supersedes_json = excluded.supersedes_json,
|
|
871
|
+
recorded_at = excluded.recorded_at,
|
|
872
|
+
updated_at = excluded.updated_at
|
|
873
|
+
`);
|
|
874
|
+
const markSuperseded = sqlite.query(`
|
|
875
|
+
UPDATE memory_records
|
|
876
|
+
SET superseded_by = ?2, updated_at = ?3
|
|
877
|
+
WHERE id = ?1
|
|
878
|
+
`);
|
|
879
|
+
|
|
880
|
+
for (const record of records) {
|
|
881
|
+
const recordedMs = Number.isFinite(Date.parse(record.recordedAt))
|
|
882
|
+
? Date.parse(record.recordedAt)
|
|
883
|
+
: updatedAt;
|
|
884
|
+
insertRecord.run(
|
|
885
|
+
record.id,
|
|
886
|
+
relPath,
|
|
887
|
+
record.source,
|
|
888
|
+
record.content,
|
|
889
|
+
JSON.stringify(record.entities ?? []),
|
|
890
|
+
typeof record.confidence === "number" ? record.confidence : 0.75,
|
|
891
|
+
JSON.stringify(record.supersedes ?? []),
|
|
892
|
+
recordedMs,
|
|
893
|
+
updatedAt,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const record of records) {
|
|
898
|
+
for (const supersededId of record.supersedes ?? []) {
|
|
899
|
+
markSuperseded.run(supersededId, record.id, updatedAt);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
sqlite
|
|
904
|
+
.query(
|
|
905
|
+
`
|
|
906
|
+
INSERT INTO memory_files (path, source, hash, mtime, size, indexed_at)
|
|
907
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
908
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
909
|
+
source = excluded.source,
|
|
910
|
+
hash = excluded.hash,
|
|
911
|
+
mtime = excluded.mtime,
|
|
912
|
+
size = excluded.size,
|
|
913
|
+
indexed_at = excluded.indexed_at
|
|
914
|
+
`,
|
|
915
|
+
)
|
|
916
|
+
.run(relPath, SOURCE, fileHash, Math.floor(fileStat.mtimeMs), fileStat.size, updatedAt);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
tx();
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function pruneStaleFiles(activePaths: Set<string>) {
|
|
924
|
+
const staleRows = sqlite.query("SELECT path FROM memory_files").all() as Array<{ path: string }>;
|
|
925
|
+
const tx = sqlite.transaction(() => {
|
|
926
|
+
for (const stale of staleRows) {
|
|
927
|
+
if (activePaths.has(stale.path)) continue;
|
|
928
|
+
const chunkRows = sqlite
|
|
929
|
+
.query(
|
|
930
|
+
`
|
|
931
|
+
SELECT id
|
|
932
|
+
FROM memory_chunks
|
|
933
|
+
WHERE path = ?1
|
|
934
|
+
`,
|
|
935
|
+
)
|
|
936
|
+
.all(stale.path) as Array<{ id: string }>;
|
|
937
|
+
deleteVecRowsByChunkIds(chunkRows.map(row => row.id));
|
|
938
|
+
sqlite.query("DELETE FROM memory_files WHERE path = ?1").run(stale.path);
|
|
939
|
+
sqlite.query("DELETE FROM memory_chunks WHERE path = ?1").run(stale.path);
|
|
940
|
+
sqlite.query("DELETE FROM memory_chunks_fts WHERE path = ?1").run(stale.path);
|
|
941
|
+
sqlite.query("DELETE FROM memory_records WHERE path = ?1").run(stale.path);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
tx();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function runSync(force = false) {
|
|
948
|
+
const memoryConfig = currentMemoryConfig();
|
|
949
|
+
await ensureWorkspaceScaffold();
|
|
950
|
+
await ensureSchema();
|
|
951
|
+
const priorMeta = memoryIndexMeta();
|
|
952
|
+
const embeddingModelChanged =
|
|
953
|
+
priorMeta?.provider !== memoryConfig.embedProvider || priorMeta?.model !== memoryConfig.embedModel;
|
|
954
|
+
const effectiveForce = force || embeddingModelChanged;
|
|
955
|
+
|
|
956
|
+
if (effectiveForce && vecTableExists()) {
|
|
957
|
+
sqlite.exec(`DELETE FROM ${MEMORY_VEC_TABLE}`);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const files = await listMemoryFiles();
|
|
961
|
+
const activePaths = new Set<string>();
|
|
962
|
+
for (const filePath of files) {
|
|
963
|
+
const relPath = normalizeRelPath(filePath);
|
|
964
|
+
if (!isMemoryPath(relPath)) continue;
|
|
965
|
+
activePaths.add(relPath);
|
|
966
|
+
await indexMemoryFile(filePath, { force: effectiveForce });
|
|
967
|
+
}
|
|
968
|
+
await pruneStaleFiles(activePaths);
|
|
969
|
+
const indexedAt = nowMs();
|
|
970
|
+
const vecState = getSqliteVecState();
|
|
971
|
+
const vecDims = vecTableDimensions();
|
|
972
|
+
sqlite
|
|
973
|
+
.query(
|
|
974
|
+
`
|
|
975
|
+
INSERT INTO memory_meta (key, value_json)
|
|
976
|
+
VALUES (?1, ?2)
|
|
977
|
+
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
|
|
978
|
+
`,
|
|
979
|
+
)
|
|
980
|
+
.run(
|
|
981
|
+
MEMORY_META_KEY,
|
|
982
|
+
JSON.stringify({
|
|
983
|
+
provider: memoryConfig.embedProvider,
|
|
984
|
+
model: memoryConfig.embedModel,
|
|
985
|
+
chunkTokens: memoryConfig.chunkTokens,
|
|
986
|
+
chunkOverlap: memoryConfig.chunkOverlap,
|
|
987
|
+
indexedAt,
|
|
988
|
+
}),
|
|
989
|
+
);
|
|
990
|
+
const vecRowCount = vecTableExists()
|
|
991
|
+
? ((sqlite.query(`SELECT COUNT(*) as count FROM ${MEMORY_VEC_TABLE}`).get() as { count: number }).count ?? 0)
|
|
992
|
+
: 0;
|
|
993
|
+
sqlite
|
|
994
|
+
.query(
|
|
995
|
+
`
|
|
996
|
+
INSERT INTO memory_meta (key, value_json)
|
|
997
|
+
VALUES (?1, ?2)
|
|
998
|
+
ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
|
|
999
|
+
`,
|
|
1000
|
+
)
|
|
1001
|
+
.run(
|
|
1002
|
+
MEMORY_VEC_META_KEY,
|
|
1003
|
+
JSON.stringify({
|
|
1004
|
+
enabled: memoryConfig.retrieval.vectorBackend === "sqlite_vec",
|
|
1005
|
+
provider: memoryConfig.embedProvider,
|
|
1006
|
+
model: memoryConfig.embedModel,
|
|
1007
|
+
dims: vecDims,
|
|
1008
|
+
count: vecRowCount,
|
|
1009
|
+
error: vecState.error,
|
|
1010
|
+
updatedAt: indexedAt,
|
|
1011
|
+
}),
|
|
1012
|
+
);
|
|
1013
|
+
lastSyncMs = indexedAt;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export async function syncMemoryIndex(options?: { force?: boolean }) {
|
|
1017
|
+
if (!currentMemoryConfig().enabled) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
if (syncPromise) {
|
|
1021
|
+
await syncPromise;
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
syncPromise = runSync(Boolean(options?.force)).finally(() => {
|
|
1025
|
+
syncPromise = null;
|
|
1026
|
+
});
|
|
1027
|
+
await syncPromise;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function ensureFreshIndex() {
|
|
1031
|
+
const needsSync = lastSyncMs === 0 || nowMs() - lastSyncMs > currentMemoryConfig().syncCooldownMs;
|
|
1032
|
+
if (needsSync) {
|
|
1033
|
+
await syncMemoryIndex();
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function findDuplicateRecordId(input: MemoryRecordInput) {
|
|
1038
|
+
await ensureSchema();
|
|
1039
|
+
const row = sqlite
|
|
1040
|
+
.query(
|
|
1041
|
+
`
|
|
1042
|
+
SELECT id
|
|
1043
|
+
FROM memory_records
|
|
1044
|
+
WHERE content = ?1
|
|
1045
|
+
AND superseded_by IS NULL
|
|
1046
|
+
ORDER BY recorded_at DESC
|
|
1047
|
+
LIMIT 1
|
|
1048
|
+
`,
|
|
1049
|
+
)
|
|
1050
|
+
.get(input.content) as { id: string } | null;
|
|
1051
|
+
return row?.id;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export async function validateMemoryRememberInput(input: MemoryRememberInput): Promise<MemoryWriteValidation> {
|
|
1055
|
+
const normalized = normalizeRecordInput(input);
|
|
1056
|
+
const content = normalized.content;
|
|
1057
|
+
const confidence = typeof normalized.confidence === "number" ? normalized.confidence : 0.75;
|
|
1058
|
+
|
|
1059
|
+
if (!content) {
|
|
1060
|
+
return {
|
|
1061
|
+
accepted: false,
|
|
1062
|
+
reason: "content is required",
|
|
1063
|
+
normalizedContent: content,
|
|
1064
|
+
normalizedConfidence: confidence,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const duplicateRecordId = await findDuplicateRecordId(normalized);
|
|
1069
|
+
if (duplicateRecordId) {
|
|
1070
|
+
return {
|
|
1071
|
+
accepted: false,
|
|
1072
|
+
reason: "duplicate memory already exists",
|
|
1073
|
+
normalizedContent: content,
|
|
1074
|
+
normalizedConfidence: confidence,
|
|
1075
|
+
duplicateRecordId,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return {
|
|
1080
|
+
accepted: true,
|
|
1081
|
+
reason: "accepted",
|
|
1082
|
+
normalizedContent: content,
|
|
1083
|
+
normalizedConfidence: confidence,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function logMemoryWriteEvent(input: {
|
|
1088
|
+
status: "accepted" | "rejected";
|
|
1089
|
+
reason: string;
|
|
1090
|
+
source: MemoryRecordInput["source"];
|
|
1091
|
+
content: string;
|
|
1092
|
+
confidence: number;
|
|
1093
|
+
sessionId?: string;
|
|
1094
|
+
topic?: string;
|
|
1095
|
+
recordId?: string;
|
|
1096
|
+
path?: string;
|
|
1097
|
+
}) {
|
|
1098
|
+
await ensureSchema();
|
|
1099
|
+
const createdAt = nowMs();
|
|
1100
|
+
sqlite
|
|
1101
|
+
.query(
|
|
1102
|
+
`
|
|
1103
|
+
INSERT INTO memory_write_events (
|
|
1104
|
+
id, status, reason, source, content, confidence, session_id, topic, record_id, path, created_at
|
|
1105
|
+
)
|
|
1106
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
|
1107
|
+
`,
|
|
1108
|
+
)
|
|
1109
|
+
.run(
|
|
1110
|
+
crypto.randomUUID(),
|
|
1111
|
+
input.status,
|
|
1112
|
+
input.reason,
|
|
1113
|
+
input.source,
|
|
1114
|
+
input.content,
|
|
1115
|
+
input.confidence,
|
|
1116
|
+
input.sessionId ?? null,
|
|
1117
|
+
input.topic ?? null,
|
|
1118
|
+
input.recordId ?? null,
|
|
1119
|
+
input.path ?? null,
|
|
1120
|
+
createdAt,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
export async function listMemoryWriteEvents(limit = 20): Promise<MemoryWriteEvent[]> {
|
|
1125
|
+
if (!currentMemoryConfig().enabled) {
|
|
1126
|
+
return [];
|
|
1127
|
+
}
|
|
1128
|
+
await ensureSchema();
|
|
1129
|
+
const normalizedLimit = Number.isFinite(limit) ? Math.floor(limit) : 20;
|
|
1130
|
+
const safeLimit = Math.max(1, Math.min(100, normalizedLimit));
|
|
1131
|
+
const rows = sqlite
|
|
1132
|
+
.query(
|
|
1133
|
+
`
|
|
1134
|
+
SELECT id, status, reason, source, content, confidence, session_id, topic, record_id, path, created_at
|
|
1135
|
+
FROM memory_write_events
|
|
1136
|
+
ORDER BY created_at DESC
|
|
1137
|
+
LIMIT ?1
|
|
1138
|
+
`,
|
|
1139
|
+
)
|
|
1140
|
+
.all(safeLimit) as MemoryWriteEventRow[];
|
|
1141
|
+
return rows.map(row => ({
|
|
1142
|
+
id: row.id,
|
|
1143
|
+
status: row.status,
|
|
1144
|
+
reason: row.reason,
|
|
1145
|
+
source: row.source as MemoryRecordInput["source"],
|
|
1146
|
+
content: row.content,
|
|
1147
|
+
confidence: row.confidence,
|
|
1148
|
+
sessionId: row.session_id,
|
|
1149
|
+
topic: row.topic,
|
|
1150
|
+
recordId: row.record_id,
|
|
1151
|
+
path: row.path,
|
|
1152
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
1153
|
+
}));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export async function lintMemory(): Promise<MemoryLintReport> {
|
|
1157
|
+
if (!currentMemoryConfig().enabled) {
|
|
1158
|
+
return {
|
|
1159
|
+
ok: true,
|
|
1160
|
+
totalRecords: 0,
|
|
1161
|
+
duplicateActiveRecords: [],
|
|
1162
|
+
danglingSupersedes: [],
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
await ensureSchema();
|
|
1167
|
+
const allRows = sqlite
|
|
1168
|
+
.query(
|
|
1169
|
+
`
|
|
1170
|
+
SELECT id, content, supersedes_json, superseded_by
|
|
1171
|
+
FROM memory_records
|
|
1172
|
+
`,
|
|
1173
|
+
)
|
|
1174
|
+
.all() as Array<{
|
|
1175
|
+
id: string;
|
|
1176
|
+
content: string;
|
|
1177
|
+
supersedes_json: string;
|
|
1178
|
+
superseded_by: string | null;
|
|
1179
|
+
}>;
|
|
1180
|
+
const idSet = new Set(allRows.map(row => row.id));
|
|
1181
|
+
|
|
1182
|
+
const activeRows = allRows.filter(row => !row.superseded_by);
|
|
1183
|
+
const duplicateKeyMap = new Map<string, { content: string; ids: string[] }>();
|
|
1184
|
+
for (const row of activeRows) {
|
|
1185
|
+
const key = row.content;
|
|
1186
|
+
const existing = duplicateKeyMap.get(key);
|
|
1187
|
+
if (existing) {
|
|
1188
|
+
existing.ids.push(row.id);
|
|
1189
|
+
} else {
|
|
1190
|
+
duplicateKeyMap.set(key, {
|
|
1191
|
+
content: row.content,
|
|
1192
|
+
ids: [row.id],
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const duplicateActiveRecords = [...duplicateKeyMap.values()]
|
|
1198
|
+
.filter(entry => entry.ids.length > 1)
|
|
1199
|
+
.map(entry => ({
|
|
1200
|
+
content: clipSnippet(entry.content, 240),
|
|
1201
|
+
count: entry.ids.length,
|
|
1202
|
+
recordIds: entry.ids,
|
|
1203
|
+
}));
|
|
1204
|
+
|
|
1205
|
+
const danglingSupersedes = allRows
|
|
1206
|
+
.map(row => {
|
|
1207
|
+
let supersedes: string[] = [];
|
|
1208
|
+
try {
|
|
1209
|
+
const parsed = JSON.parse(row.supersedes_json) as unknown;
|
|
1210
|
+
if (Array.isArray(parsed)) {
|
|
1211
|
+
supersedes = parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
1212
|
+
}
|
|
1213
|
+
} catch {
|
|
1214
|
+
supersedes = [];
|
|
1215
|
+
}
|
|
1216
|
+
const missing = supersedes.filter(id => !idSet.has(id));
|
|
1217
|
+
if (!missing.length) return null;
|
|
1218
|
+
return {
|
|
1219
|
+
recordId: row.id,
|
|
1220
|
+
missingSupersedes: missing,
|
|
1221
|
+
};
|
|
1222
|
+
})
|
|
1223
|
+
.filter((item): item is { recordId: string; missingSupersedes: string[] } => Boolean(item));
|
|
1224
|
+
|
|
1225
|
+
return {
|
|
1226
|
+
ok: duplicateActiveRecords.length === 0 && danglingSupersedes.length === 0,
|
|
1227
|
+
totalRecords: allRows.length,
|
|
1228
|
+
duplicateActiveRecords,
|
|
1229
|
+
danglingSupersedes,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
export async function rememberMemory(
|
|
1234
|
+
input: MemoryRememberInput,
|
|
1235
|
+
options?: { validateOnly?: boolean },
|
|
1236
|
+
): Promise<MemoryRememberResult> {
|
|
1237
|
+
if (!currentMemoryConfig().enabled) {
|
|
1238
|
+
throw new Error("Memory is disabled.");
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const normalized = normalizeRecordInput(input);
|
|
1242
|
+
const validation = await validateMemoryRememberInput(input);
|
|
1243
|
+
|
|
1244
|
+
if (!validation.accepted) {
|
|
1245
|
+
await logMemoryWriteEvent({
|
|
1246
|
+
status: "rejected",
|
|
1247
|
+
reason: validation.reason,
|
|
1248
|
+
source: normalized.source,
|
|
1249
|
+
content: validation.normalizedContent,
|
|
1250
|
+
confidence: validation.normalizedConfidence,
|
|
1251
|
+
sessionId: input.sessionId,
|
|
1252
|
+
topic: input.topic,
|
|
1253
|
+
});
|
|
1254
|
+
return {
|
|
1255
|
+
accepted: false,
|
|
1256
|
+
reason: validation.reason,
|
|
1257
|
+
validation,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (options?.validateOnly) {
|
|
1262
|
+
return {
|
|
1263
|
+
accepted: true,
|
|
1264
|
+
reason: validation.reason,
|
|
1265
|
+
validation,
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const persisted = await appendStructuredMemory({
|
|
1270
|
+
source: normalized.source,
|
|
1271
|
+
content: validation.normalizedContent,
|
|
1272
|
+
entities: normalized.entities,
|
|
1273
|
+
confidence: validation.normalizedConfidence,
|
|
1274
|
+
supersedes: normalized.supersedes,
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
await logMemoryWriteEvent({
|
|
1278
|
+
status: "accepted",
|
|
1279
|
+
reason: validation.reason,
|
|
1280
|
+
source: normalized.source,
|
|
1281
|
+
content: validation.normalizedContent,
|
|
1282
|
+
confidence: validation.normalizedConfidence,
|
|
1283
|
+
sessionId: input.sessionId,
|
|
1284
|
+
topic: input.topic,
|
|
1285
|
+
recordId: persisted.record.id,
|
|
1286
|
+
path: persisted.path,
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
accepted: true,
|
|
1291
|
+
reason: validation.reason,
|
|
1292
|
+
validation,
|
|
1293
|
+
record: persisted.record,
|
|
1294
|
+
path: persisted.path,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function parseUpdatedAtFromMeta(): number | null {
|
|
1299
|
+
if (!sqliteTableExists("memory_meta")) return null;
|
|
1300
|
+
const row = sqlite
|
|
1301
|
+
.query("SELECT value_json FROM memory_meta WHERE key = ?1")
|
|
1302
|
+
.get(MEMORY_META_KEY) as { value_json: string } | null;
|
|
1303
|
+
if (!row?.value_json) return null;
|
|
1304
|
+
try {
|
|
1305
|
+
const parsed = JSON.parse(row.value_json) as { indexedAt?: number };
|
|
1306
|
+
return typeof parsed.indexedAt === "number" ? parsed.indexedAt : null;
|
|
1307
|
+
} catch {
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function selectRecentChunkIds(limit: number): string[] {
|
|
1313
|
+
if (limit <= 0) return [];
|
|
1314
|
+
const rows = sqlite
|
|
1315
|
+
.query(
|
|
1316
|
+
`
|
|
1317
|
+
SELECT id
|
|
1318
|
+
FROM memory_chunks
|
|
1319
|
+
ORDER BY updated_at DESC
|
|
1320
|
+
LIMIT ?1
|
|
1321
|
+
`,
|
|
1322
|
+
)
|
|
1323
|
+
.all(limit) as Array<{ id: string }>;
|
|
1324
|
+
return rows.map(row => row.id);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function resolveVectorBackend() {
|
|
1328
|
+
const memoryConfig = currentMemoryConfig();
|
|
1329
|
+
const configured = memoryConfig.retrieval.vectorBackend;
|
|
1330
|
+
const fallback = memoryConfig.retrieval.vectorUnavailableFallback;
|
|
1331
|
+
const vecState = getSqliteVecState();
|
|
1332
|
+
if (configured === "disabled") {
|
|
1333
|
+
return {
|
|
1334
|
+
configured,
|
|
1335
|
+
active: "disabled" as const,
|
|
1336
|
+
available: false,
|
|
1337
|
+
error: null as string | null,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
if (configured === "legacy_json") {
|
|
1341
|
+
return {
|
|
1342
|
+
configured,
|
|
1343
|
+
active: "legacy_json" as const,
|
|
1344
|
+
available: true,
|
|
1345
|
+
error: null as string | null,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (vecState.available) {
|
|
1349
|
+
return {
|
|
1350
|
+
configured,
|
|
1351
|
+
active: "sqlite_vec" as const,
|
|
1352
|
+
available: true,
|
|
1353
|
+
error: null as string | null,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
if (fallback === "legacy_json") {
|
|
1357
|
+
return {
|
|
1358
|
+
configured,
|
|
1359
|
+
active: "legacy_json" as const,
|
|
1360
|
+
available: false,
|
|
1361
|
+
error: vecState.error,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
return {
|
|
1365
|
+
configured,
|
|
1366
|
+
active: "disabled" as const,
|
|
1367
|
+
available: false,
|
|
1368
|
+
error: vecState.error,
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function searchVectorCandidates(
|
|
1373
|
+
queryVector: number[],
|
|
1374
|
+
candidateLimit: number,
|
|
1375
|
+
seedChunkIds: string[] = [],
|
|
1376
|
+
options?: { exhaustive?: boolean },
|
|
1377
|
+
) {
|
|
1378
|
+
if (!queryVector.length) return new Map<string, number>();
|
|
1379
|
+
const backend = resolveVectorBackend();
|
|
1380
|
+
const memoryConfig = currentMemoryConfig();
|
|
1381
|
+
if (backend.active === "disabled") return new Map<string, number>();
|
|
1382
|
+
const hasVecTable = vecTableExists();
|
|
1383
|
+
const useLegacyFallback =
|
|
1384
|
+
backend.active === "sqlite_vec" && !hasVecTable && memoryConfig.retrieval.vectorUnavailableFallback === "legacy_json";
|
|
1385
|
+
if (backend.active === "sqlite_vec" && hasVecTable) {
|
|
1386
|
+
const probeLimit = Math.max(1, memoryConfig.retrieval.vectorProbeLimit);
|
|
1387
|
+
const configuredK = Math.max(1, memoryConfig.retrieval.vectorK);
|
|
1388
|
+
const queryK = Math.max(1, Math.max(candidateLimit, probeLimit, configuredK));
|
|
1389
|
+
const rows = sqlite
|
|
1390
|
+
.query(
|
|
1391
|
+
`
|
|
1392
|
+
SELECT chunk_id, distance
|
|
1393
|
+
FROM ${MEMORY_VEC_TABLE}
|
|
1394
|
+
WHERE embedding MATCH ?1
|
|
1395
|
+
AND k = ?2
|
|
1396
|
+
`,
|
|
1397
|
+
)
|
|
1398
|
+
.all(new Float32Array(queryVector), queryK) as Array<{ chunk_id: string; distance: number }>;
|
|
1399
|
+
if (!rows.length) return new Map<string, number>();
|
|
1400
|
+
const scored = rows
|
|
1401
|
+
.map(row => ({
|
|
1402
|
+
id: row.chunk_id,
|
|
1403
|
+
score: Math.max(0, 1 / (1 + Math.max(0, Number(row.distance ?? 0)))),
|
|
1404
|
+
}))
|
|
1405
|
+
.filter(row => Number.isFinite(row.score) && row.score > 0)
|
|
1406
|
+
.sort((a, b) => b.score - a.score)
|
|
1407
|
+
.slice(0, candidateLimit);
|
|
1408
|
+
return new Map(scored.map(row => [row.id, row.score]));
|
|
1409
|
+
}
|
|
1410
|
+
if (backend.active === "sqlite_vec" && !useLegacyFallback) {
|
|
1411
|
+
return new Map<string, number>();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const exhaustive = options?.exhaustive === true;
|
|
1415
|
+
const prefilterLimit = Math.max(VECTOR_PREFILTER_MIN, Math.min(VECTOR_PREFILTER_MAX, candidateLimit * 12));
|
|
1416
|
+
const prefilterIds = exhaustive
|
|
1417
|
+
? []
|
|
1418
|
+
: [...new Set([...seedChunkIds, ...selectRecentChunkIds(prefilterLimit)])].slice(0, SQLITE_IN_BIND_LIMIT);
|
|
1419
|
+
|
|
1420
|
+
const rows = exhaustive
|
|
1421
|
+
? (sqlite
|
|
1422
|
+
.query(
|
|
1423
|
+
`
|
|
1424
|
+
SELECT id, embedding_json
|
|
1425
|
+
FROM memory_chunks
|
|
1426
|
+
WHERE embedding_json IS NOT NULL
|
|
1427
|
+
`,
|
|
1428
|
+
)
|
|
1429
|
+
.all() as Array<{ id: string; embedding_json: string }>)
|
|
1430
|
+
: prefilterIds.length
|
|
1431
|
+
? (sqlite
|
|
1432
|
+
.query(
|
|
1433
|
+
`
|
|
1434
|
+
SELECT id, embedding_json
|
|
1435
|
+
FROM memory_chunks
|
|
1436
|
+
WHERE embedding_json IS NOT NULL
|
|
1437
|
+
AND id IN (${prefilterIds.map(() => "?").join(", ")})
|
|
1438
|
+
`,
|
|
1439
|
+
)
|
|
1440
|
+
.all(...prefilterIds) as Array<{ id: string; embedding_json: string }>)
|
|
1441
|
+
: [];
|
|
1442
|
+
if (!rows.length) return new Map<string, number>();
|
|
1443
|
+
|
|
1444
|
+
const scored = rows
|
|
1445
|
+
.map(row => ({
|
|
1446
|
+
id: row.id,
|
|
1447
|
+
score: cosineSimilarity(queryVector, parseEmbedding(row.embedding_json)),
|
|
1448
|
+
}))
|
|
1449
|
+
.filter(row => Number.isFinite(row.score) && row.score > 0)
|
|
1450
|
+
.sort((a, b) => b.score - a.score)
|
|
1451
|
+
.slice(0, candidateLimit);
|
|
1452
|
+
return new Map(scored.map(row => [row.id, row.score]));
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function toRankedResults(scores: Map<string, number>, limit: number) {
|
|
1456
|
+
return [...scores.entries()]
|
|
1457
|
+
.map(([id, score]) => ({ id, score }))
|
|
1458
|
+
.sort((a, b) => b.score - a.score)
|
|
1459
|
+
.slice(0, limit);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function searchTextCandidates(query: string, candidateLimit: number) {
|
|
1463
|
+
const ftsQuery = buildFtsQuery(query);
|
|
1464
|
+
if (!ftsQuery) return new Map<string, number>();
|
|
1465
|
+
const rows = sqlite
|
|
1466
|
+
.query(
|
|
1467
|
+
`
|
|
1468
|
+
SELECT chunk_id, path, start_line, end_line, text, bm25(memory_chunks_fts) AS rank
|
|
1469
|
+
FROM memory_chunks_fts
|
|
1470
|
+
WHERE memory_chunks_fts MATCH ?1
|
|
1471
|
+
ORDER BY rank ASC
|
|
1472
|
+
LIMIT ?2
|
|
1473
|
+
`,
|
|
1474
|
+
)
|
|
1475
|
+
.all(ftsQuery, candidateLimit) as MemoryFtsRow[];
|
|
1476
|
+
const scores = new Map<string, number>();
|
|
1477
|
+
for (const row of rows) {
|
|
1478
|
+
const rank = Number.isFinite(row.rank) ? Math.abs(row.rank) : 1;
|
|
1479
|
+
scores.set(row.chunk_id, 1 / (1 + rank));
|
|
1480
|
+
}
|
|
1481
|
+
return scores;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function applyRecordStateAdjustments(candidates: SearchCandidate[]) {
|
|
1485
|
+
const recordIds = [...new Set(candidates.map(candidate => candidate.recordId).filter((id): id is string => Boolean(id)))];
|
|
1486
|
+
if (!recordIds.length) return;
|
|
1487
|
+
const placeholders = recordIds.map(() => "?").join(", ");
|
|
1488
|
+
const rows = sqlite
|
|
1489
|
+
.query(
|
|
1490
|
+
`
|
|
1491
|
+
SELECT id, confidence, superseded_by
|
|
1492
|
+
FROM memory_records
|
|
1493
|
+
WHERE id IN (${placeholders})
|
|
1494
|
+
`,
|
|
1495
|
+
)
|
|
1496
|
+
.all(...recordIds) as MemoryRecordRow[];
|
|
1497
|
+
const rowById = new Map(rows.map(row => [row.id, row]));
|
|
1498
|
+
for (const candidate of candidates) {
|
|
1499
|
+
if (!candidate.recordId) continue;
|
|
1500
|
+
const row = rowById.get(candidate.recordId);
|
|
1501
|
+
if (!row) continue;
|
|
1502
|
+
const confidence = Math.max(0, Math.min(1, Number.isFinite(row.confidence) ? row.confidence : 0.75));
|
|
1503
|
+
candidate.score *= 0.85 + 0.3 * confidence;
|
|
1504
|
+
if (row.superseded_by) {
|
|
1505
|
+
candidate.score = Math.max(0, candidate.score - 0.25);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function applyRecencyBoost(candidates: SearchCandidate[]) {
|
|
1511
|
+
const current = nowMs();
|
|
1512
|
+
for (const candidate of candidates) {
|
|
1513
|
+
const ageDays = Math.max(0, (current - candidate.updatedAt) / (24 * 60 * 60 * 1000));
|
|
1514
|
+
const recency = 1 / (1 + ageDays / 14);
|
|
1515
|
+
candidate.score += recency * 0.08;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function mmrRerank(candidates: SearchCandidate[], maxResults: number) {
|
|
1520
|
+
if (candidates.length <= maxResults) {
|
|
1521
|
+
return [...candidates].sort((a, b) => b.score - a.score);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const selected: SearchCandidate[] = [];
|
|
1525
|
+
const pool = [...candidates];
|
|
1526
|
+
|
|
1527
|
+
while (selected.length < maxResults && pool.length) {
|
|
1528
|
+
let bestIndex = 0;
|
|
1529
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
1530
|
+
|
|
1531
|
+
for (let index = 0; index < pool.length; index += 1) {
|
|
1532
|
+
const candidate = pool[index];
|
|
1533
|
+
if (!candidate) continue;
|
|
1534
|
+
const relevance = candidate.score;
|
|
1535
|
+
let maxSimilarity = 0;
|
|
1536
|
+
if (candidate.embedding?.length) {
|
|
1537
|
+
for (const picked of selected) {
|
|
1538
|
+
if (!picked.embedding?.length) continue;
|
|
1539
|
+
maxSimilarity = Math.max(maxSimilarity, cosineSimilarity(candidate.embedding, picked.embedding));
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
const mmrScore = MMR_LAMBDA * relevance - (1 - MMR_LAMBDA) * maxSimilarity;
|
|
1543
|
+
if (mmrScore > bestScore) {
|
|
1544
|
+
bestScore = mmrScore;
|
|
1545
|
+
bestIndex = index;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const [picked] = pool.splice(bestIndex, 1);
|
|
1550
|
+
if (!picked) break;
|
|
1551
|
+
selected.push(picked);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
return selected;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function mapCandidatesToResults(candidates: SearchCandidate[]): MemorySearchResult[] {
|
|
1558
|
+
return candidates.map(candidate => ({
|
|
1559
|
+
id: candidate.id,
|
|
1560
|
+
path: candidate.path,
|
|
1561
|
+
startLine: candidate.startLine,
|
|
1562
|
+
endLine: candidate.endLine,
|
|
1563
|
+
source: "memory",
|
|
1564
|
+
score: Number(candidate.score.toFixed(4)),
|
|
1565
|
+
snippet: normalizeSnippetFromChunk(candidate.text),
|
|
1566
|
+
citation: `${candidate.path}#L${candidate.startLine}`,
|
|
1567
|
+
}));
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
interface MemorySearchDebug {
|
|
1571
|
+
engine: "legacy" | "qmd_hybrid";
|
|
1572
|
+
strongSignalSkippedExpansion: boolean;
|
|
1573
|
+
matchedConceptPacks: string[];
|
|
1574
|
+
semanticRescues: number;
|
|
1575
|
+
expansionQueries: Array<{ type: string; text: string }>;
|
|
1576
|
+
rankedLists: Array<{ name: string; count: number }>;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function applyFinalFiltering(
|
|
1580
|
+
candidates: SearchCandidate[],
|
|
1581
|
+
normalizedQuery: string,
|
|
1582
|
+
minScore: number,
|
|
1583
|
+
maxResults: number,
|
|
1584
|
+
options?: {
|
|
1585
|
+
expandedTokens?: string[];
|
|
1586
|
+
semanticRescueEnabled?: boolean;
|
|
1587
|
+
semanticRescueMinVectorScore?: number;
|
|
1588
|
+
semanticRescueMaxResults?: number;
|
|
1589
|
+
},
|
|
1590
|
+
) {
|
|
1591
|
+
const queryTokens = collectQueryTokens(normalizedQuery);
|
|
1592
|
+
const expandedTokens = new Set((options?.expandedTokens ?? []).map(token => token.toLowerCase()));
|
|
1593
|
+
const allowedTokens = new Set<string>([...queryTokens, ...expandedTokens]);
|
|
1594
|
+
const recallIntent = isMemoryRecallIntentQuery(normalizedQuery);
|
|
1595
|
+
const memoryManagementQuery = isMemoryManagementQuery(normalizedQuery);
|
|
1596
|
+
const semanticRescueEnabled = options?.semanticRescueEnabled === true;
|
|
1597
|
+
const semanticRescueMinVectorScore = Math.max(0, Math.min(1, options?.semanticRescueMinVectorScore ?? 0));
|
|
1598
|
+
const semanticRescueMaxResults = Math.max(0, options?.semanticRescueMaxResults ?? 0);
|
|
1599
|
+
|
|
1600
|
+
const baseFiltered = candidates
|
|
1601
|
+
.filter(candidate => {
|
|
1602
|
+
if (!recallIntent && !memoryManagementQuery && isLikelyBoilerplateIndexCandidate(candidate)) {
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
return candidate.score >= minScore;
|
|
1606
|
+
})
|
|
1607
|
+
.sort((a, b) => b.score - a.score);
|
|
1608
|
+
|
|
1609
|
+
if (recallIntent) {
|
|
1610
|
+
return { candidates: mmrRerank(baseFiltered, maxResults), semanticRescues: 0 };
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const lexical = baseFiltered.filter(candidate => hasLexicalSignal(candidate, allowedTokens));
|
|
1614
|
+
if (!semanticRescueEnabled || semanticRescueMaxResults <= 0) {
|
|
1615
|
+
return { candidates: mmrRerank(lexical, maxResults), semanticRescues: 0 };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const lexicalIds = new Set(lexical.map(candidate => candidate.id));
|
|
1619
|
+
const rescued = baseFiltered
|
|
1620
|
+
.filter(candidate => !lexicalIds.has(candidate.id))
|
|
1621
|
+
.filter(candidate => candidate.vectorScore >= semanticRescueMinVectorScore)
|
|
1622
|
+
.sort((a, b) => b.vectorScore - a.vectorScore)
|
|
1623
|
+
.slice(0, semanticRescueMaxResults);
|
|
1624
|
+
const merged = [...lexical, ...rescued];
|
|
1625
|
+
return {
|
|
1626
|
+
candidates: mmrRerank(merged, maxResults),
|
|
1627
|
+
semanticRescues: rescued.length,
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function loadSearchCandidates(
|
|
1632
|
+
candidateIds: string[],
|
|
1633
|
+
textScores: Map<string, number>,
|
|
1634
|
+
vectorScores: Map<string, number>,
|
|
1635
|
+
baseScoreById?: Map<string, number>,
|
|
1636
|
+
) {
|
|
1637
|
+
if (!candidateIds.length) return [] as SearchCandidate[];
|
|
1638
|
+
const placeholders = candidateIds.map(() => "?").join(", ");
|
|
1639
|
+
const chunkRows = sqlite
|
|
1640
|
+
.query(
|
|
1641
|
+
`
|
|
1642
|
+
SELECT id, path, start_line, end_line, text, embedding_json, updated_at
|
|
1643
|
+
FROM memory_chunks
|
|
1644
|
+
WHERE id IN (${placeholders})
|
|
1645
|
+
`,
|
|
1646
|
+
)
|
|
1647
|
+
.all(...candidateIds) as MemoryChunkRow[];
|
|
1648
|
+
|
|
1649
|
+
return chunkRows.map(row => {
|
|
1650
|
+
const vectorScore = vectorScores.get(row.id) ?? 0;
|
|
1651
|
+
const textScore = textScores.get(row.id) ?? 0;
|
|
1652
|
+
const fallbackHybrid = 0.72 * vectorScore + 0.28 * textScore;
|
|
1653
|
+
const embedding = parseEmbedding(row.embedding_json);
|
|
1654
|
+
return {
|
|
1655
|
+
id: row.id,
|
|
1656
|
+
path: row.path,
|
|
1657
|
+
startLine: row.start_line,
|
|
1658
|
+
endLine: row.end_line,
|
|
1659
|
+
text: row.text,
|
|
1660
|
+
embedding: embedding.length ? embedding : null,
|
|
1661
|
+
vectorScore,
|
|
1662
|
+
textScore,
|
|
1663
|
+
score: baseScoreById?.get(row.id) ?? fallbackHybrid,
|
|
1664
|
+
updatedAt: row.updated_at,
|
|
1665
|
+
recordId: extractRecordIdFromChunk(row.text),
|
|
1666
|
+
};
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function runLegacySearch(
|
|
1671
|
+
normalizedQuery: string,
|
|
1672
|
+
maxResults: number,
|
|
1673
|
+
minScore: number,
|
|
1674
|
+
): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
|
|
1675
|
+
const candidateLimit = Math.max(maxResults * 6, DEFAULT_CANDIDATE_LIMIT);
|
|
1676
|
+
return (async () => {
|
|
1677
|
+
let queryVector: number[] = [];
|
|
1678
|
+
try {
|
|
1679
|
+
const vectors = await embedTexts([normalizedQuery]);
|
|
1680
|
+
queryVector = vectors[0] ?? [];
|
|
1681
|
+
} catch {
|
|
1682
|
+
queryVector = [];
|
|
1683
|
+
}
|
|
1684
|
+
const textScores = searchTextCandidates(normalizedQuery, candidateLimit);
|
|
1685
|
+
const vectorScores = searchVectorCandidates(queryVector, candidateLimit, [...textScores.keys()]);
|
|
1686
|
+
const candidateIds = [...new Set([...vectorScores.keys(), ...textScores.keys()])];
|
|
1687
|
+
const candidates = loadSearchCandidates(candidateIds, textScores, vectorScores);
|
|
1688
|
+
applyRecencyBoost(candidates);
|
|
1689
|
+
applyRecordStateAdjustments(candidates);
|
|
1690
|
+
const filtered = applyFinalFiltering(candidates, normalizedQuery, minScore, maxResults, {
|
|
1691
|
+
semanticRescueEnabled: false,
|
|
1692
|
+
});
|
|
1693
|
+
return {
|
|
1694
|
+
results: mapCandidatesToResults(filtered.candidates),
|
|
1695
|
+
debug: {
|
|
1696
|
+
engine: "legacy",
|
|
1697
|
+
strongSignalSkippedExpansion: false,
|
|
1698
|
+
matchedConceptPacks: [],
|
|
1699
|
+
semanticRescues: 0,
|
|
1700
|
+
expansionQueries: [] as Array<{ type: string; text: string }>,
|
|
1701
|
+
rankedLists: [] as Array<{ name: string; count: number }>,
|
|
1702
|
+
},
|
|
1703
|
+
};
|
|
1704
|
+
})();
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function rerankScoreFromOverlap(candidate: SearchCandidate, queryTokens: Set<string>) {
|
|
1708
|
+
if (!queryTokens.size) return candidate.score;
|
|
1709
|
+
const candidateTokens = collectQueryTokens(candidate.text);
|
|
1710
|
+
let overlap = 0;
|
|
1711
|
+
for (const token of queryTokens) {
|
|
1712
|
+
if (candidateTokens.has(token)) overlap += 1;
|
|
1713
|
+
}
|
|
1714
|
+
const overlapScore = overlap / Math.max(1, queryTokens.size);
|
|
1715
|
+
return Math.max(overlapScore, candidate.textScore, candidate.vectorScore);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
async function runQmdHybridSearch(
|
|
1719
|
+
normalizedQuery: string,
|
|
1720
|
+
maxResults: number,
|
|
1721
|
+
minScore: number,
|
|
1722
|
+
): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
|
|
1723
|
+
const memoryConfig = currentMemoryConfig();
|
|
1724
|
+
const retrieval = memoryConfig.retrieval;
|
|
1725
|
+
const candidateLimit = Math.max(maxResults * 6, retrieval.candidateLimit);
|
|
1726
|
+
|
|
1727
|
+
const initialFtsScores = searchTextCandidates(normalizedQuery, 20);
|
|
1728
|
+
const initialFts = toRankedResults(initialFtsScores, 20);
|
|
1729
|
+
const hasStrongSignal = hasStrongBm25Signal(initialFts, {
|
|
1730
|
+
minScore: retrieval.strongSignalMinScore,
|
|
1731
|
+
minGap: retrieval.strongSignalMinGap,
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
const conceptExpansion = hasStrongSignal
|
|
1735
|
+
? { expandedQueries: [], expandedTokens: [], matchedConceptPacks: [] }
|
|
1736
|
+
: buildConceptExpandedQueries(normalizedQuery, {
|
|
1737
|
+
enabled: retrieval.expansionEnabled && retrieval.conceptExpansionEnabled,
|
|
1738
|
+
maxPacks: retrieval.conceptExpansionMaxPacks,
|
|
1739
|
+
maxTerms: retrieval.conceptExpansionMaxTerms,
|
|
1740
|
+
});
|
|
1741
|
+
const expandedQueries = conceptExpansion.expandedQueries;
|
|
1742
|
+
const rankedLists: Array<{ name: string; list: Array<{ id: string; score: number }> }> = [];
|
|
1743
|
+
const textScoreById = new Map(initialFtsScores);
|
|
1744
|
+
const vectorScoreById = new Map<string, number>();
|
|
1745
|
+
|
|
1746
|
+
if (initialFts.length) {
|
|
1747
|
+
rankedLists.push({ name: "fts:original", list: initialFts });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const vectorQueries = [{ type: "vec", text: normalizedQuery }, ...expandedQueries.filter(q => q.type !== "lex")];
|
|
1751
|
+
const vectorTexts = vectorQueries.map(entry => entry.text);
|
|
1752
|
+
let queryVectors: number[][] = [];
|
|
1753
|
+
try {
|
|
1754
|
+
queryVectors = vectorTexts.length ? await embedTexts(vectorTexts) : [];
|
|
1755
|
+
} catch {
|
|
1756
|
+
queryVectors = [];
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
const exhaustiveVector = initialFts.length === 0;
|
|
1760
|
+
for (let index = 0; index < vectorQueries.length; index += 1) {
|
|
1761
|
+
const queryEntry = vectorQueries[index];
|
|
1762
|
+
const vector = queryVectors[index] ?? [];
|
|
1763
|
+
const scores = searchVectorCandidates(vector, 20, [...initialFtsScores.keys()], { exhaustive: exhaustiveVector });
|
|
1764
|
+
if (!scores.size) continue;
|
|
1765
|
+
for (const [id, score] of scores.entries()) {
|
|
1766
|
+
const existing = vectorScoreById.get(id) ?? 0;
|
|
1767
|
+
if (score > existing) vectorScoreById.set(id, score);
|
|
1768
|
+
}
|
|
1769
|
+
rankedLists.push({
|
|
1770
|
+
name: `${queryEntry?.type ?? "vec"}:${index === 0 ? "original" : "expanded"}`,
|
|
1771
|
+
list: toRankedResults(scores, 20),
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
for (const expansion of expandedQueries) {
|
|
1776
|
+
if (expansion.type !== "lex") continue;
|
|
1777
|
+
const lexScores = searchTextCandidates(expansion.text, 20);
|
|
1778
|
+
if (!lexScores.size) continue;
|
|
1779
|
+
for (const [id, score] of lexScores.entries()) {
|
|
1780
|
+
const existing = textScoreById.get(id) ?? 0;
|
|
1781
|
+
if (score > existing) textScoreById.set(id, score);
|
|
1782
|
+
}
|
|
1783
|
+
rankedLists.push({
|
|
1784
|
+
name: "fts:lex-expanded",
|
|
1785
|
+
list: toRankedResults(lexScores, 20),
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const fused = reciprocalRankFusion(
|
|
1790
|
+
rankedLists.map(item => item.list),
|
|
1791
|
+
rankedLists.map((_, index) => (index < 2 ? 2 : 1)),
|
|
1792
|
+
retrieval.rrfK,
|
|
1793
|
+
);
|
|
1794
|
+
|
|
1795
|
+
const candidateIds = fused.slice(0, candidateLimit).map(entry => entry.id);
|
|
1796
|
+
const baseScoreById = new Map(fused.map(entry => [entry.id, entry.score]));
|
|
1797
|
+
const candidates = loadSearchCandidates(candidateIds, textScoreById, vectorScoreById, baseScoreById);
|
|
1798
|
+
applyRecencyBoost(candidates);
|
|
1799
|
+
applyRecordStateAdjustments(candidates);
|
|
1800
|
+
|
|
1801
|
+
if (retrieval.rerankEnabled && candidates.length) {
|
|
1802
|
+
const queryTokens = collectQueryTokens(normalizedQuery);
|
|
1803
|
+
const rankById = new Map(candidateIds.map((id, index) => [id, index + 1]));
|
|
1804
|
+
const rerankLimit = Math.max(1, Math.min(candidates.length, retrieval.rerankTopN));
|
|
1805
|
+
const sorted = [...candidates].sort((a, b) => b.score - a.score);
|
|
1806
|
+
for (let index = 0; index < rerankLimit; index += 1) {
|
|
1807
|
+
const candidate = sorted[index];
|
|
1808
|
+
if (!candidate) continue;
|
|
1809
|
+
const rerankScore = rerankScoreFromOverlap(candidate, queryTokens);
|
|
1810
|
+
const rank = rankById.get(candidate.id) ?? index + 1;
|
|
1811
|
+
candidate.score = blendRrfAndRerank(rank, rerankScore);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const filtered = applyFinalFiltering(candidates, normalizedQuery, minScore, maxResults, {
|
|
1816
|
+
expandedTokens: conceptExpansion.expandedTokens,
|
|
1817
|
+
semanticRescueEnabled: retrieval.semanticRescueEnabled,
|
|
1818
|
+
semanticRescueMinVectorScore: retrieval.semanticRescueMinVectorScore,
|
|
1819
|
+
semanticRescueMaxResults: retrieval.semanticRescueMaxResults,
|
|
1820
|
+
});
|
|
1821
|
+
return {
|
|
1822
|
+
results: mapCandidatesToResults(filtered.candidates),
|
|
1823
|
+
debug: {
|
|
1824
|
+
engine: "qmd_hybrid",
|
|
1825
|
+
strongSignalSkippedExpansion: hasStrongSignal,
|
|
1826
|
+
matchedConceptPacks: conceptExpansion.matchedConceptPacks,
|
|
1827
|
+
semanticRescues: filtered.semanticRescues,
|
|
1828
|
+
expansionQueries: expandedQueries,
|
|
1829
|
+
rankedLists: rankedLists.map(item => ({ name: item.name, count: item.list.length })),
|
|
1830
|
+
},
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
export async function searchMemoryDetailed(
|
|
1835
|
+
query: string,
|
|
1836
|
+
options?: { maxResults?: number; minScore?: number },
|
|
1837
|
+
): Promise<{ results: MemorySearchResult[]; debug: MemorySearchDebug }> {
|
|
1838
|
+
const memoryConfig = currentMemoryConfig();
|
|
1839
|
+
if (!memoryConfig.enabled) {
|
|
1840
|
+
return {
|
|
1841
|
+
results: [] as MemorySearchResult[],
|
|
1842
|
+
debug: {
|
|
1843
|
+
engine: memoryConfig.retrieval.engine,
|
|
1844
|
+
strongSignalSkippedExpansion: false,
|
|
1845
|
+
matchedConceptPacks: [],
|
|
1846
|
+
semanticRescues: 0,
|
|
1847
|
+
expansionQueries: [],
|
|
1848
|
+
rankedLists: [],
|
|
1849
|
+
},
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
const normalizedQuery = query.trim();
|
|
1853
|
+
if (!normalizedQuery) {
|
|
1854
|
+
return {
|
|
1855
|
+
results: [] as MemorySearchResult[],
|
|
1856
|
+
debug: {
|
|
1857
|
+
engine: memoryConfig.retrieval.engine,
|
|
1858
|
+
strongSignalSkippedExpansion: false,
|
|
1859
|
+
matchedConceptPacks: [],
|
|
1860
|
+
semanticRescues: 0,
|
|
1861
|
+
expansionQueries: [],
|
|
1862
|
+
rankedLists: [],
|
|
1863
|
+
},
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
await ensureFreshIndex();
|
|
1868
|
+
|
|
1869
|
+
const maxResults = Math.max(1, options?.maxResults ?? memoryConfig.maxResults);
|
|
1870
|
+
const minScore = Math.max(0, Math.min(1, options?.minScore ?? memoryConfig.minScore));
|
|
1871
|
+
if (memoryConfig.retrieval.engine === "legacy") {
|
|
1872
|
+
return runLegacySearch(normalizedQuery, maxResults, minScore);
|
|
1873
|
+
}
|
|
1874
|
+
return runQmdHybridSearch(normalizedQuery, maxResults, minScore);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
export async function searchMemory(query: string, options?: { maxResults?: number; minScore?: number }) {
|
|
1878
|
+
const detailed = await searchMemoryDetailed(query, options);
|
|
1879
|
+
return detailed.results;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
export async function readMemoryFileSlice(input: { relPath: string; from?: number; lines?: number }) {
|
|
1883
|
+
if (!currentMemoryConfig().enabled) {
|
|
1884
|
+
throw new Error("Memory is disabled.");
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const relPath = input.relPath.trim();
|
|
1888
|
+
if (!isMemoryPath(relPath)) {
|
|
1889
|
+
throw new Error("Invalid memory path.");
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
const workspaceDir = resolveWorkspaceDir();
|
|
1893
|
+
const absPath = path.resolve(workspaceDir, relPath);
|
|
1894
|
+
if (normalizeRelPath(absPath) !== relPath.replaceAll("\\", "/")) {
|
|
1895
|
+
throw new Error("Invalid memory path.");
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const content = await readFile(absPath, "utf8");
|
|
1899
|
+
if (!input.from && !input.lines) {
|
|
1900
|
+
return { path: relPath, text: content };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const from = Math.max(1, input.from ?? 1);
|
|
1904
|
+
const lines = Math.max(1, input.lines ?? 120);
|
|
1905
|
+
const slice = content.split("\n").slice(from - 1, from - 1 + lines).join("\n");
|
|
1906
|
+
return { path: relPath, text: slice };
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
async function appendStructuredMemory(input: MemoryRecordInput): Promise<{
|
|
1910
|
+
record: MemoryRecord;
|
|
1911
|
+
path: string;
|
|
1912
|
+
}> {
|
|
1913
|
+
if (!currentMemoryConfig().enabled) {
|
|
1914
|
+
throw new Error("Memory is disabled.");
|
|
1915
|
+
}
|
|
1916
|
+
const normalized = normalizeRecordInput(input);
|
|
1917
|
+
if (!normalized.content) {
|
|
1918
|
+
throw new Error("Memory content is required.");
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
await ensureWorkspaceScaffold();
|
|
1922
|
+
await ensureSchema();
|
|
1923
|
+
|
|
1924
|
+
const record = createMemoryRecord(normalized);
|
|
1925
|
+
const dayStamp = record.recordedAt.slice(0, 10);
|
|
1926
|
+
const relPath = `memory/${dayStamp}.md`;
|
|
1927
|
+
const absPath = path.join(resolveWorkspaceDir(), relPath);
|
|
1928
|
+
await mkdir(path.dirname(absPath), { recursive: true });
|
|
1929
|
+
|
|
1930
|
+
let previous = "";
|
|
1931
|
+
try {
|
|
1932
|
+
previous = await readFile(absPath, "utf8");
|
|
1933
|
+
} catch {
|
|
1934
|
+
previous = "";
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const block = formatMemoryRecord(record);
|
|
1938
|
+
const separator = previous.trim().length ? "\n" : "";
|
|
1939
|
+
await writeFile(absPath, `${previous}${separator}${block}`, "utf8");
|
|
1940
|
+
|
|
1941
|
+
await indexMemoryFile(absPath, { force: true });
|
|
1942
|
+
lastSyncMs = nowMs();
|
|
1943
|
+
|
|
1944
|
+
return { record, path: relPath };
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
export async function getMemoryStatus(): Promise<MemoryStatus> {
|
|
1948
|
+
const memoryConfig = currentMemoryConfig();
|
|
1949
|
+
if (!memoryConfig.enabled) {
|
|
1950
|
+
const vectorBackend = resolveVectorBackend();
|
|
1951
|
+
return {
|
|
1952
|
+
enabled: false,
|
|
1953
|
+
workspaceDir: resolveWorkspaceDir(),
|
|
1954
|
+
provider: memoryConfig.embedProvider,
|
|
1955
|
+
model: memoryConfig.embedModel,
|
|
1956
|
+
toolMode: memoryConfig.toolMode,
|
|
1957
|
+
vectorBackendConfigured: memoryConfig.retrieval.vectorBackend,
|
|
1958
|
+
vectorBackendActive: vectorBackend.active,
|
|
1959
|
+
vectorAvailable: vectorBackend.available,
|
|
1960
|
+
vectorDims: null,
|
|
1961
|
+
vectorIndexedChunks: 0,
|
|
1962
|
+
vectorLastError: vectorBackend.error,
|
|
1963
|
+
files: 0,
|
|
1964
|
+
chunks: 0,
|
|
1965
|
+
records: 0,
|
|
1966
|
+
cacheEntries: 0,
|
|
1967
|
+
indexedAt: null,
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
await ensureSchema();
|
|
1972
|
+
const vectorBackend = resolveVectorBackend();
|
|
1973
|
+
const vecMeta = memoryVecMeta();
|
|
1974
|
+
const vecDims = vecTableDimensions();
|
|
1975
|
+
const vecCount = vecTableExists()
|
|
1976
|
+
? ((sqlite.query(`SELECT COUNT(*) as count FROM ${MEMORY_VEC_TABLE}`).get() as { count: number }).count ?? 0)
|
|
1977
|
+
: 0;
|
|
1978
|
+
const files = sqlite.query("SELECT COUNT(*) as count FROM memory_files").get() as { count: number };
|
|
1979
|
+
const chunks = sqlite.query("SELECT COUNT(*) as count FROM memory_chunks").get() as { count: number };
|
|
1980
|
+
const records = sqlite.query("SELECT COUNT(*) as count FROM memory_records").get() as { count: number };
|
|
1981
|
+
const cache = sqlite.query("SELECT COUNT(*) as count FROM memory_embedding_cache").get() as {
|
|
1982
|
+
count: number;
|
|
1983
|
+
};
|
|
1984
|
+
const indexedAt = parseUpdatedAtFromMeta();
|
|
1985
|
+
|
|
1986
|
+
return {
|
|
1987
|
+
enabled: true,
|
|
1988
|
+
workspaceDir: resolveWorkspaceDir(),
|
|
1989
|
+
provider: memoryConfig.embedProvider,
|
|
1990
|
+
model: memoryConfig.embedModel,
|
|
1991
|
+
toolMode: memoryConfig.toolMode,
|
|
1992
|
+
vectorBackendConfigured: memoryConfig.retrieval.vectorBackend,
|
|
1993
|
+
vectorBackendActive: vectorBackend.active,
|
|
1994
|
+
vectorAvailable: vectorBackend.available,
|
|
1995
|
+
vectorDims: vecDims ?? vecMeta?.dims ?? null,
|
|
1996
|
+
vectorIndexedChunks: vecCount,
|
|
1997
|
+
vectorLastError: vectorBackend.error ?? vecMeta?.error ?? null,
|
|
1998
|
+
files: files.count,
|
|
1999
|
+
chunks: chunks.count,
|
|
2000
|
+
records: records.count,
|
|
2001
|
+
cacheEntries: cache.count,
|
|
2002
|
+
indexedAt: indexedAt ? new Date(indexedAt).toISOString() : null,
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
export async function initializeMemory() {
|
|
2007
|
+
if (!currentMemoryConfig().enabled) {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
await ensureWorkspaceScaffold();
|
|
2011
|
+
await ensureSchema();
|
|
2012
|
+
}
|