memorix 1.9.0 → 1.9.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/CHANGELOG.md +28 -0
- package/README.md +5 -1
- package/README.zh-CN.md +1 -1
- package/dist/cli/index.js +2508 -1843
- package/dist/index.js +2387 -1722
- package/dist/maintenance-runner.js +1686 -1031
- package/dist/media-audio-runner.js +2185 -1530
- package/dist/media-video-runner.js +2185 -1530
- package/dist/memcode-runtime/CHANGELOG.md +28 -0
- package/dist/sdk.js +2449 -1720
- package/dist/vector-backfill-runner.js +2183 -1528
- package/docs/CONFIGURATION.md +22 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/PERFORMANCE.md +77 -20
- package/docs/README.md +1 -1
- package/package.json +2 -1
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/compact/engine.ts +14 -4
- package/src/memory/freshness.ts +7 -4
- package/src/memory/observations.ts +118 -6
- package/src/runtime/vector-backfill-runner.ts +1 -1
- package/src/sdk.ts +13 -6
- package/src/search/semantic-index.ts +340 -0
- package/src/server.ts +1 -1
- package/src/store/obs-store.ts +54 -2
- package/src/store/orama-store.ts +300 -12
- package/src/store/sqlite-db.ts +6 -0
- package/src/store/sqlite-fts.ts +164 -0
- package/src/store/sqlite-store.ts +83 -6
package/docs/CONFIGURATION.md
CHANGED
|
@@ -195,6 +195,28 @@ OpenAI-only default. The equivalent env-var form is
|
|
|
195
195
|
`MEMORIX_EMBEDDING=api`, `MEMORIX_EMBEDDING_BASE_URL=https://openrouter.ai/api/v1`,
|
|
196
196
|
`MEMORIX_EMBEDDING_MODEL=qwen/qwen3-embedding-8b`.
|
|
197
197
|
|
|
198
|
+
### Large-store retrieval
|
|
199
|
+
|
|
200
|
+
Memorix does not cap the number of durable observations to make search fast.
|
|
201
|
+
For a large store, the runtime keeps the complete records in SQLite and uses
|
|
202
|
+
derived indexes for bounded candidate retrieval. The optional LanceDB package
|
|
203
|
+
is installed with the package's optional dependencies and loaded only at
|
|
204
|
+
runtime; if its native binary is unavailable, SQLite FTS5 and the Orama
|
|
205
|
+
fallback continue to work.
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
MEMORIX_ORAMA_HYDRATION_THRESHOLD=10000 # working-set switch, not a quota
|
|
209
|
+
MEMORIX_SEMANTIC_INDEX=auto # auto | off | orama
|
|
210
|
+
MEMORIX_SEMANTIC_INDEX_THRESHOLD=10000 # train ANN index after this many vectors
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`MEMORIX_SEMANTIC_INDEX=auto` enables the local LanceDB shadow index when the
|
|
214
|
+
optional native package is available. It stores vector IDs and filter metadata,
|
|
215
|
+
while full memory text remains in SQLite. The semantic index uses HNSW with
|
|
216
|
+
scalar quantization after its threshold is reached; before that, its table is
|
|
217
|
+
still searchable without a training step. The derived index can be deleted and
|
|
218
|
+
recreated by the background vector backfill without affecting durable memories.
|
|
219
|
+
|
|
198
220
|
### `[rerank]`
|
|
199
221
|
|
|
200
222
|
Optional HTTP rerank for thorough search. Off by default.
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -8,12 +8,12 @@ Memorix is a TypeScript project built around:
|
|
|
8
8
|
- memcode bundled terminal-agent runtime
|
|
9
9
|
- CLI workflows
|
|
10
10
|
- SQLite canonical persistence with compatibility/fallback layers
|
|
11
|
-
- Orama
|
|
11
|
+
- layered retrieval: SQLite FTS5, optional LanceDB semantic index, and Orama fallback
|
|
12
12
|
- dashboard and HTTP service
|
|
13
13
|
|
|
14
14
|
## Current Development Baseline
|
|
15
15
|
|
|
16
|
-
The current release line is **1.9.
|
|
16
|
+
The current release line is **1.9.1** and package metadata is kept synchronized
|
|
17
17
|
with the root package and MCP Registry manifest.
|
|
18
18
|
|
|
19
19
|
Contributors should assume the following areas are part of the current release line:
|
package/docs/PERFORMANCE.md
CHANGED
|
@@ -12,7 +12,10 @@ Memorix is designed to be light for everyday memory use and explicit about heavi
|
|
|
12
12
|
| `memorix` | Interactive terminal UI | Human operator workbench |
|
|
13
13
|
| `memorix orchestrate` | Supervisor plus spawned CLI agent workers | Orchestrated subagent loops |
|
|
14
14
|
|
|
15
|
-
The default memory path uses local SQLite as the canonical store
|
|
15
|
+
The default memory path uses local SQLite as the canonical store. Orama remains
|
|
16
|
+
the in-process compatibility/semantic fallback; large corpora use SQLite FTS5
|
|
17
|
+
and, when available, the local LanceDB semantic shadow index. No cloud service
|
|
18
|
+
is required.
|
|
16
19
|
|
|
17
20
|
## Resource Safety Model
|
|
18
21
|
|
|
@@ -66,11 +69,11 @@ project-scoped tools (`memorix_project_context`, `memorix_context_pack`,
|
|
|
66
69
|
CodeGraph Memory directly, so a new agent can get a useful brief without
|
|
67
70
|
waiting for unrelated historical records to hydrate into Orama.
|
|
68
71
|
|
|
69
|
-
Search
|
|
70
|
-
|
|
71
|
-
Memory refresh are queued durably and run in an isolated child process.
|
|
72
|
-
backfill
|
|
73
|
-
|
|
72
|
+
Search and ordinary writes use the durable SQLite path directly when the
|
|
73
|
+
persistent indexes are available. Corpus-scale retention, consolidation, and
|
|
74
|
+
Code Memory refresh are queued durably and run in an isolated child process.
|
|
75
|
+
Vector backfill updates the persistent semantic shadow index and keeps Orama as
|
|
76
|
+
the compatibility fallback.
|
|
74
77
|
|
|
75
78
|
## What Is Lightweight
|
|
76
79
|
|
|
@@ -105,6 +108,9 @@ On the release development machine used for this check, the healthy HTTP service
|
|
|
105
108
|
| `MEMORIX_RERANK_PROVIDER` | `off` | Set `http` to enable optional HTTP rerank |
|
|
106
109
|
| `MEMORIX_RERANK_BASE_URL` | `[memory.llm].base_url` | Compatible `/rerank` API root (path `/rerank` is appended) |
|
|
107
110
|
| `MEMORIX_EMBEDDING_CACHE_MAX_BYTES` | `268435456` | Disposable in-process API-vector cache budget; raise for large warm caches, or lower when the host is memory constrained |
|
|
111
|
+
| `MEMORIX_ORAMA_HYDRATION_THRESHOLD` | `10000` | Above this durable corpus size, skip full Orama hydration and use persistent SQLite/semantic indexes; this is a working-set threshold, not a memory quota |
|
|
112
|
+
| `MEMORIX_SEMANTIC_INDEX` | `auto` | `auto` uses the optional local LanceDB shadow index when installed; `off` or `orama` keeps the legacy semantic path |
|
|
113
|
+
| `MEMORIX_SEMANTIC_INDEX_THRESHOLD` | `10000` | Minimum vectors before the optional HNSW/SQ index is trained; vectors remain searchable before training |
|
|
108
114
|
| `memorix memory search --quality fast` | n/a | Force a fully local retrieval path for a latency-sensitive call |
|
|
109
115
|
| `npm run benchmark:retrieval -- --records 1000 --runs 100` | n/a | Reproduce hot in-process lexical retrieval latency; not an end-to-end claim |
|
|
110
116
|
| `npm run gate:large-store -- --records 40000` | n/a | Exercise SDK, HTTP MCP, hook persistence, cache integrity, and reopen behavior against a large isolated store |
|
|
@@ -130,6 +136,10 @@ On the release development machine used for this check, the healthy HTTP service
|
|
|
130
136
|
rising `rss` with stable heap, or rising V8 `heapUsed`; those indicate
|
|
131
137
|
different classes of problem and should not be “fixed” by lowering the
|
|
132
138
|
memory corpus limit.
|
|
139
|
+
- When a corpus crosses `MEMORIX_ORAMA_HYDRATION_THRESHOLD`, the complete
|
|
140
|
+
durable corpus remains in SQLite. Only the per-query candidate set is loaded
|
|
141
|
+
into the agent process. Management actions such as export, retention, and
|
|
142
|
+
detail views may intentionally load the requested full/project slice.
|
|
133
143
|
|
|
134
144
|
## Large-Store Release Gate
|
|
135
145
|
|
|
@@ -143,22 +153,69 @@ report fails when an interactive path exceeds its release budget. It also
|
|
|
143
153
|
verifies that the personal hook candidate remains durable on disk without
|
|
144
154
|
becoming visible to an unbound SDK reader.
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
search number is intentionally a cold
|
|
149
|
-
|
|
156
|
+
Small stores may use bounded Orama batches. Large stores skip that hydration and
|
|
157
|
+
use the persistent FTS5 path immediately; the optional semantic shadow index is
|
|
158
|
+
opened independently. The MCP search number is intentionally a cold
|
|
159
|
+
control-plane measurement and includes only the work still owed by the selected
|
|
160
|
+
derived indexes.
|
|
150
161
|
|
|
151
162
|
Results are machine-specific and should be compared on the same OS and Node
|
|
152
163
|
version. The release gate is an integrity and regression check, not a public
|
|
153
164
|
latency guarantee.
|
|
154
165
|
|
|
155
|
-
##
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
## 1.9.1 Large-Scale Retrieval Contract
|
|
167
|
+
|
|
168
|
+
The 1.9.1 line improves retrieval latency without shrinking the user's durable
|
|
169
|
+
memory corpus. SQLite remains the source of truth. Search indexes are derived,
|
|
170
|
+
rebuildable working structures, following the same boundary used by mature
|
|
171
|
+
memory systems that keep human-readable or durable records separate from their
|
|
172
|
+
vector/lexical indexes.
|
|
173
|
+
|
|
174
|
+
The release contract is:
|
|
175
|
+
|
|
176
|
+
1. A normal lexical or exact-identifier query must be able to use a persistent
|
|
177
|
+
SQLite FTS5 index and fetch only bounded candidates. It must not require
|
|
178
|
+
hydrating every observation into Orama first.
|
|
179
|
+
2. FTS5 must be feature-detected. Older or degraded SQLite runtimes fall back
|
|
180
|
+
to the existing Orama path without losing data or changing write semantics.
|
|
181
|
+
3. Inserts, updates, deletes, imports, and sync applies must keep the derived
|
|
182
|
+
lexical index current. A rebuild command/test path must repair it after an
|
|
183
|
+
interrupted migration or manual database recovery.
|
|
184
|
+
4. Candidate generation, graph/code references, and optional reranking must be
|
|
185
|
+
separately measurable. Reranking is applied only to a small candidate set;
|
|
186
|
+
it must never scan or send the whole corpus to a provider.
|
|
187
|
+
5. No durable observation count, retention rule, or user-facing memory quota is
|
|
188
|
+
introduced by this work. Limits apply only to per-query candidates, caches,
|
|
189
|
+
and concurrent maintenance work.
|
|
190
|
+
6. Large-store acceptance must report cold start, warm search, MCP search, RSS,
|
|
191
|
+
heap, index mode, result integrity, and recall of planted records at 1k,
|
|
192
|
+
10k, 40k, and 100k records. A slower machine is evidence to report, not a
|
|
193
|
+
reason to quietly lower the corpus size.
|
|
194
|
+
|
|
195
|
+
The implementation target is SQLite FTS5 plus an optional local LanceDB shadow
|
|
196
|
+
index. LanceDB stores only vector IDs and filter metadata, and is loaded at
|
|
197
|
+
runtime so the main package still builds on platforms where its native binary
|
|
198
|
+
is unavailable. HNSW with scalar quantization is trained only after the vector
|
|
199
|
+
table crosses the semantic-index threshold; before that, the table remains
|
|
200
|
+
queryable without paying a large training spike. Orama remains the compatibility
|
|
201
|
+
fallback. A server-only vector database is not a default dependency for the
|
|
202
|
+
local-first product.
|
|
203
|
+
|
|
204
|
+
The retrieval shape is:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
SQLite observations (durable truth)
|
|
208
|
+
-> FTS5 exact/lexical candidates
|
|
209
|
+
-> optional vector candidates
|
|
210
|
+
-> optional CodeGraph/knowledge candidates
|
|
211
|
+
-> rank fusion
|
|
212
|
+
-> optional rerank on the short list
|
|
213
|
+
-> compact result IDs, then detail on demand
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Measured Windows/Node 22 evidence for this implementation: 1k, 10k, 40k, and
|
|
217
|
+
100k records all passed the large-store gate. At 100k, peak RSS was about
|
|
218
|
+
419MB, SDK reopen was about 131ms, and the cold HTTP MCP search was about
|
|
219
|
+
296ms. These are same-machine acceptance numbers, not universal latency
|
|
220
|
+
promises. The release is complete only when the measurements and fallback
|
|
221
|
+
behavior remain recorded alongside the implementation.
|
package/docs/README.md
CHANGED
|
@@ -125,7 +125,7 @@ Historical/deep-reference documents may describe older designs. If they conflict
|
|
|
125
125
|
|
|
126
126
|
## Current Product Line
|
|
127
127
|
|
|
128
|
-
The current product line is **1.9.
|
|
128
|
+
The current product line is **1.9.1**. Multi-device store sync is opt-in and
|
|
129
129
|
keeps local SQLite canonical; existing users do not upload anything until they
|
|
130
130
|
configure a relay provider explicitly.
|
|
131
131
|
The authoritative acceptance contract for the current implementation is
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memorix",
|
|
3
3
|
"mcpName": "io.github.AVIDS2/memorix",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -155,6 +155,7 @@
|
|
|
155
155
|
"zod": "^3.24.0"
|
|
156
156
|
},
|
|
157
157
|
"optionalDependencies": {
|
|
158
|
+
"@lancedb/lancedb": "^0.38.0",
|
|
158
159
|
"better-sqlite3": "^12.11.1"
|
|
159
160
|
},
|
|
160
161
|
"overrides": {
|
package/server.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"url": "https://github.com/AVIDS2/memorix",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "1.9.
|
|
10
|
+
"version": "1.9.1",
|
|
11
11
|
"websiteUrl": "https://mem.rglens.com",
|
|
12
12
|
"icons": [
|
|
13
13
|
{
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "memorix",
|
|
25
|
-
"version": "1.9.
|
|
25
|
+
"version": "1.9.1",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"packageArguments": [
|
|
28
28
|
{
|
package/src/compact/engine.ts
CHANGED
|
@@ -71,7 +71,7 @@ export async function compactSearch(options: SearchOptions, surface: RetrievalSu
|
|
|
71
71
|
formatted: string;
|
|
72
72
|
totalTokens: number;
|
|
73
73
|
}> {
|
|
74
|
-
await ensureFreshIndex();
|
|
74
|
+
await ensureFreshIndex({ loadCorpus: false });
|
|
75
75
|
const searchOptions = { ...options, query: normalizeMemoryBrowseQuery(options.query) };
|
|
76
76
|
let entries = (await searchObservations(searchOptions)).map((entry) =>
|
|
77
77
|
entry.projectId || !options.projectId ? entry : { ...entry, projectId: options.projectId },
|
|
@@ -102,9 +102,19 @@ export async function compactSearch(options: SearchOptions, surface: RetrievalSu
|
|
|
102
102
|
let formatted = formatIndexTable(entries, searchOptions.query, !options.projectId, surface);
|
|
103
103
|
|
|
104
104
|
if (entries.length === 0 && options.projectId) {
|
|
105
|
-
const allObservations = filterReadableObservations(getAllObservations(), options.reader);
|
|
106
105
|
const projectAliases = new Set(await resolveAliases(options.projectId).catch(() => [options.projectId]));
|
|
107
|
-
|
|
106
|
+
let projectHasStoredMemory = false;
|
|
107
|
+
try {
|
|
108
|
+
const store = getObservationStore();
|
|
109
|
+
projectHasStoredMemory = (await Promise.all(
|
|
110
|
+
[...projectAliases].filter((projectId): projectId is string => Boolean(projectId)).map((projectId) => store.countByProject(projectId, { status: 'active' })),
|
|
111
|
+
)).some((count) => count > 0);
|
|
112
|
+
} catch {
|
|
113
|
+
const allObservations = filterReadableObservations(getAllObservations(), options.reader);
|
|
114
|
+
projectHasStoredMemory = allObservations.some((observation) =>
|
|
115
|
+
observation.projectId !== undefined && projectAliases.has(observation.projectId),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
108
118
|
if (!projectHasStoredMemory) {
|
|
109
119
|
formatted =
|
|
110
120
|
options.reader
|
|
@@ -115,7 +125,7 @@ export async function compactSearch(options: SearchOptions, surface: RetrievalSu
|
|
|
115
125
|
`Memories will start appearing after observations, session summaries, hook captures, or git-memory are written.`;
|
|
116
126
|
} else {
|
|
117
127
|
formatted =
|
|
118
|
-
`No memories found
|
|
128
|
+
`No memories found for the current query.\n\n` +
|
|
119
129
|
`This project does have stored Memorix memories, but none matched the current query/filter. ` +
|
|
120
130
|
`Try a more specific topic, use /memory status for runtime diagnostics, or ask for recent memory if you want a broad overview.`;
|
|
121
131
|
}
|
package/src/memory/freshness.ts
CHANGED
|
@@ -90,9 +90,9 @@ export async function reindexMiniSkills(): Promise<number> {
|
|
|
90
90
|
* Ensure both observations and mini-skills are fresh in the Orama index.
|
|
91
91
|
* Returns true if any data source was refreshed.
|
|
92
92
|
*/
|
|
93
|
-
export async function ensureFreshIndex(): Promise<boolean> {
|
|
93
|
+
export async function ensureFreshIndex(options: { loadCorpus?: boolean } = {}): Promise<boolean> {
|
|
94
94
|
let anyStale = false;
|
|
95
|
-
const obsStale = await ensureFreshObservations();
|
|
95
|
+
const obsStale = await ensureFreshObservations(options);
|
|
96
96
|
if (obsStale) anyStale = true;
|
|
97
97
|
const skillsStale = await ensureFreshMiniSkills();
|
|
98
98
|
if (skillsStale) anyStale = true;
|
|
@@ -108,8 +108,11 @@ export async function ensureFreshIndex(): Promise<boolean> {
|
|
|
108
108
|
*
|
|
109
109
|
* Phase 3a: replaces withFreshObservations() at all retrieval call sites.
|
|
110
110
|
*/
|
|
111
|
-
export async function withFreshIndex<T>(
|
|
112
|
-
|
|
111
|
+
export async function withFreshIndex<T>(
|
|
112
|
+
fn: () => T | Promise<T>,
|
|
113
|
+
options: { loadCorpus?: boolean } = {},
|
|
114
|
+
): Promise<T> {
|
|
115
|
+
await ensureFreshIndex(options);
|
|
113
116
|
return fn();
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
getDb,
|
|
30
30
|
getDeferredCachedVectorHydration,
|
|
31
31
|
getVectorDimensions,
|
|
32
|
+
queueSemanticVector,
|
|
32
33
|
hydrateIndexForStartup,
|
|
33
34
|
hasObservationVector,
|
|
34
35
|
isEmbeddingEnabled,
|
|
@@ -64,13 +65,25 @@ export interface ObservationRuntimeOptions {
|
|
|
64
65
|
* tens of thousands of rows on every PostToolUse.
|
|
65
66
|
*/
|
|
66
67
|
skipCorpusLoad?: boolean;
|
|
68
|
+
/** Force a maintenance worker to load the corpus for vector backfill. */
|
|
69
|
+
forceCorpusLoad?: boolean;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
let embeddingWriteMode: ObservationEmbeddingWriteMode = 'background';
|
|
70
73
|
let embeddingWorkerProjectRoot: string | undefined;
|
|
71
74
|
let corpusLoaded = false;
|
|
75
|
+
let largeCorpusLazyMode = false;
|
|
72
76
|
let loadedObservationStore: ObservationStore | null = null;
|
|
73
77
|
|
|
78
|
+
const DEFAULT_ORAMA_HYDRATION_THRESHOLD = 10_000;
|
|
79
|
+
|
|
80
|
+
function resolveOramaHydrationThreshold(): number {
|
|
81
|
+
const configured = Number.parseInt(process.env.MEMORIX_ORAMA_HYDRATION_THRESHOLD ?? '', 10);
|
|
82
|
+
return Number.isSafeInteger(configured) && configured > 0
|
|
83
|
+
? configured
|
|
84
|
+
: DEFAULT_ORAMA_HYDRATION_THRESHOLD;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
/** @internal Reset in-process observation state between tests. */
|
|
75
88
|
export function resetObservationRuntime(): void {
|
|
76
89
|
observations = [];
|
|
@@ -78,6 +91,7 @@ export function resetObservationRuntime(): void {
|
|
|
78
91
|
projectDir = null;
|
|
79
92
|
searchIndexPrepared = false;
|
|
80
93
|
corpusLoaded = false;
|
|
94
|
+
largeCorpusLazyMode = false;
|
|
81
95
|
loadedObservationStore = null;
|
|
82
96
|
embeddingWriteMode = 'background';
|
|
83
97
|
embeddingWorkerProjectRoot = undefined;
|
|
@@ -148,6 +162,22 @@ function vectorBackfillError(error: unknown): string {
|
|
|
148
162
|
return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
|
|
149
163
|
}
|
|
150
164
|
|
|
165
|
+
function queueSemanticVectorSafely(
|
|
166
|
+
dataDir: string,
|
|
167
|
+
record: Parameters<typeof queueSemanticVector>[1],
|
|
168
|
+
): void {
|
|
169
|
+
try {
|
|
170
|
+
// The derived index is best-effort. Catch both synchronous compatibility
|
|
171
|
+
// failures from older test adapters and asynchronous native-index errors.
|
|
172
|
+
void Promise.resolve(queueSemanticVector(dataDir, record)).catch(() => {
|
|
173
|
+
// Durable SQLite/FTS remains authoritative; backfill can rebuild vectors.
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
// Test adapters and older runtimes may not expose the optional derived
|
|
177
|
+
// index hook. The durable observation and Orama fallback remain valid.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
function queueVectorBackfill(
|
|
152
182
|
projectId: string,
|
|
153
183
|
options: { detachedWorker?: boolean; projectRoot?: string } = {},
|
|
@@ -300,6 +330,22 @@ export async function initObservations(
|
|
|
300
330
|
loadedObservationStore = store;
|
|
301
331
|
return;
|
|
302
332
|
}
|
|
333
|
+
|
|
334
|
+
const durableCount = await store.countAll?.();
|
|
335
|
+
largeCorpusLazyMode = Boolean(
|
|
336
|
+
!options.forceCorpusLoad &&
|
|
337
|
+
durableCount !== undefined &&
|
|
338
|
+
durableCount >= resolveOramaHydrationThreshold() &&
|
|
339
|
+
store.hasLexicalIndex?.(),
|
|
340
|
+
);
|
|
341
|
+
if (largeCorpusLazyMode) {
|
|
342
|
+
// SQLite/FTS5 and the optional persistent semantic index serve the search
|
|
343
|
+
// path. Management/detail paths explicitly call ensureCorpusLoaded().
|
|
344
|
+
observations = [];
|
|
345
|
+
corpusLoaded = false;
|
|
346
|
+
loadedObservationStore = store;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
303
349
|
observations = await store.loadAll();
|
|
304
350
|
corpusLoaded = true;
|
|
305
351
|
loadedObservationStore = store;
|
|
@@ -349,6 +395,15 @@ function scheduleObservationEmbedding(input: {
|
|
|
349
395
|
try {
|
|
350
396
|
await removeObservation(makeOramaObservationId(projectId, observationId));
|
|
351
397
|
await insertObservation(Object.assign({}, doc, { embedding }));
|
|
398
|
+
if (projectDir) {
|
|
399
|
+
queueSemanticVectorSafely(projectDir, {
|
|
400
|
+
observationId,
|
|
401
|
+
projectId,
|
|
402
|
+
status: doc.status,
|
|
403
|
+
visibility: doc.visibility,
|
|
404
|
+
vector: embedding,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
352
407
|
vectorMissingIds.delete(observationId);
|
|
353
408
|
} catch {
|
|
354
409
|
console.error(`[memorix] Embedding index update failed for obs-${observationId} (kept in backfill queue)`);
|
|
@@ -387,16 +442,27 @@ function scheduleObservationEmbedding(input: {
|
|
|
387
442
|
*
|
|
388
443
|
* For DegradedBackend this is a no-op (always returns false).
|
|
389
444
|
*/
|
|
390
|
-
export async function ensureFreshObservations(): Promise<boolean> {
|
|
391
|
-
if (!projectDir
|
|
445
|
+
export async function ensureFreshObservations(options: { loadCorpus?: boolean } = {}): Promise<boolean> {
|
|
446
|
+
if (!projectDir) return false;
|
|
447
|
+
const loadCorpus = options.loadCorpus !== false;
|
|
392
448
|
try {
|
|
393
449
|
const store = getObservationStore();
|
|
394
450
|
const wasStale = await store.ensureFresh();
|
|
395
|
-
if (
|
|
451
|
+
if (!corpusLoaded && loadCorpus) {
|
|
396
452
|
observations = await store.loadAll();
|
|
397
453
|
nextId = await store.loadIdCounter();
|
|
398
|
-
|
|
399
|
-
|
|
454
|
+
corpusLoaded = true;
|
|
455
|
+
largeCorpusLazyMode = false;
|
|
456
|
+
searchIndexPrepared = false;
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (wasStale) {
|
|
460
|
+
nextId = await store.loadIdCounter();
|
|
461
|
+
if (corpusLoaded) {
|
|
462
|
+
observations = await store.loadAll();
|
|
463
|
+
await reindexObservations();
|
|
464
|
+
searchIndexPrepared = true;
|
|
465
|
+
}
|
|
400
466
|
return true;
|
|
401
467
|
}
|
|
402
468
|
} catch {
|
|
@@ -405,6 +471,11 @@ export async function ensureFreshObservations(): Promise<boolean> {
|
|
|
405
471
|
return false;
|
|
406
472
|
}
|
|
407
473
|
|
|
474
|
+
/** Load the full durable corpus for management/detail operations only. */
|
|
475
|
+
export async function ensureCorpusLoaded(): Promise<void> {
|
|
476
|
+
await ensureFreshObservations({ loadCorpus: true });
|
|
477
|
+
}
|
|
478
|
+
|
|
408
479
|
/**
|
|
409
480
|
* @internal Observation-only freshness gate.
|
|
410
481
|
*
|
|
@@ -491,7 +562,7 @@ export async function storeObservation(input: {
|
|
|
491
562
|
// Sync the local cache before using it as the topicKey fast path. This costs a
|
|
492
563
|
// generation read in the normal case and only reloads when another process wrote.
|
|
493
564
|
// Write-only / hook processes skip the corpus cache; disk findByTopicKey is authoritative.
|
|
494
|
-
await ensureFreshObservations();
|
|
565
|
+
await ensureFreshObservations({ loadCorpus: false });
|
|
495
566
|
|
|
496
567
|
// Topic key upsert: fast-path check in-memory (optimistic, may be stale).
|
|
497
568
|
// A second authoritative check happens inside the file lock to prevent TOCTOU races
|
|
@@ -612,6 +683,21 @@ export async function storeObservation(input: {
|
|
|
612
683
|
// Phase 4a: confirm writeGeneration matches actual post-bump value
|
|
613
684
|
observation.writeGeneration = store.getGeneration();
|
|
614
685
|
|
|
686
|
+
// A long-lived writer can cross the hydration threshold after startup.
|
|
687
|
+
// Release the hot Orama corpus at that point and let FTS5/semantic shadow
|
|
688
|
+
// indexes serve subsequent searches. Durable SQLite rows are untouched.
|
|
689
|
+
if (
|
|
690
|
+
corpusLoaded &&
|
|
691
|
+
observations.length >= resolveOramaHydrationThreshold() &&
|
|
692
|
+
store.hasLexicalIndex?.()
|
|
693
|
+
) {
|
|
694
|
+
observations = [];
|
|
695
|
+
corpusLoaded = false;
|
|
696
|
+
largeCorpusLazyMode = true;
|
|
697
|
+
searchIndexPrepared = false;
|
|
698
|
+
await resetDb();
|
|
699
|
+
}
|
|
700
|
+
|
|
615
701
|
if (upsertedInsideLock || reloadCacheAfterCommit) {
|
|
616
702
|
if (corpusLoaded) {
|
|
617
703
|
observations = await store.loadAll();
|
|
@@ -1101,6 +1187,11 @@ export function getObservationCount(): number {
|
|
|
1101
1187
|
return observations.length;
|
|
1102
1188
|
}
|
|
1103
1189
|
|
|
1190
|
+
/** The current durable data directory used by the observation runtime. */
|
|
1191
|
+
export function getObservationDataDir(): string | null {
|
|
1192
|
+
return projectDir;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1104
1195
|
/**
|
|
1105
1196
|
* Suggest a stable topic key from type + title.
|
|
1106
1197
|
* Uses family heuristics (architecture/*, bug/*, decision/*, etc.)
|
|
@@ -1246,6 +1337,18 @@ export async function reindexObservations(): Promise<number> {
|
|
|
1246
1337
|
export async function prepareSearchIndex(options: { skipCachedVectors?: boolean } = {}): Promise<number> {
|
|
1247
1338
|
if (searchIndexPrepared) return 0;
|
|
1248
1339
|
|
|
1340
|
+
if (largeCorpusLazyMode && getObservationStore().hasLexicalIndex?.()) {
|
|
1341
|
+
// Search is served by SQLite FTS5 plus the optional persistent semantic
|
|
1342
|
+
// shadow index. Do not pay the old full-corpus Orama hydration cost.
|
|
1343
|
+
searchIndexPrepared = true;
|
|
1344
|
+
if (!isEmbeddingExplicitlyDisabled()) {
|
|
1345
|
+
const store = getObservationStore();
|
|
1346
|
+
const projects = await store.listProjectIds?.() ?? [];
|
|
1347
|
+
for (const projectId of projects) queueVectorBackfill(projectId);
|
|
1348
|
+
}
|
|
1349
|
+
return 0;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1249
1352
|
const count = await hydrateIndexForStartup(observations as unknown as any[], {
|
|
1250
1353
|
skipCachedVectors: options.skipCachedVectors,
|
|
1251
1354
|
});
|
|
@@ -1487,6 +1590,15 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1487
1590
|
embedding,
|
|
1488
1591
|
};
|
|
1489
1592
|
await insertObservation(doc);
|
|
1593
|
+
if (projectDir) {
|
|
1594
|
+
queueSemanticVectorSafely(projectDir, {
|
|
1595
|
+
observationId: id,
|
|
1596
|
+
projectId: obs.projectId,
|
|
1597
|
+
status: doc.status,
|
|
1598
|
+
visibility: doc.visibility,
|
|
1599
|
+
vector: embedding,
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1490
1602
|
vectorMissingIds.delete(id);
|
|
1491
1603
|
succeeded++;
|
|
1492
1604
|
} catch (error) {
|
|
@@ -252,7 +252,7 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
|
|
|
252
252
|
initProjectRoot(request.projectRoot);
|
|
253
253
|
loadDotenv(request.projectRoot);
|
|
254
254
|
await initObservationStore(request.dataDir);
|
|
255
|
-
await initObservations(request.dataDir);
|
|
255
|
+
await initObservations(request.dataDir, { forceCorpusLoad: true });
|
|
256
256
|
await prepareSearchIndex();
|
|
257
257
|
await getDeferredCachedVectorHydration()?.catch(() => {});
|
|
258
258
|
|