memorix 1.9.0 → 1.9.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +22 -5
  3. package/README.zh-CN.md +9 -5
  4. package/dist/cli/index.js +2635 -1942
  5. package/dist/index.js +2445 -1766
  6. package/dist/maintenance-runner.js +1696 -1036
  7. package/dist/media-audio-runner.js +2197 -1537
  8. package/dist/media-video-runner.js +2195 -1535
  9. package/dist/memcode-runtime/CHANGELOG.md +39 -0
  10. package/dist/sdk.js +2507 -1764
  11. package/dist/vector-backfill-runner.js +2193 -1533
  12. package/docs/CONFIGURATION.md +22 -0
  13. package/docs/DEVELOPMENT.md +2 -2
  14. package/docs/INTEGRATIONS.md +4 -0
  15. package/docs/PERFORMANCE.md +77 -20
  16. package/docs/README.md +1 -1
  17. package/docs/SETUP.md +5 -0
  18. package/package.json +2 -2
  19. package/plugins/antigravity/memorix/plugin.json +1 -1
  20. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  21. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  22. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/copilot/memorix/plugin.json +1 -1
  24. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  25. package/plugins/hermes/memorix/plugin.yaml +1 -1
  26. package/plugins/omp/memorix/package.json +1 -1
  27. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  28. package/plugins/pi/memorix/package.json +1 -1
  29. package/server.json +2 -2
  30. package/src/cli/commands/memory.ts +2 -1
  31. package/src/cli/default-mode.ts +15 -0
  32. package/src/cli/index.ts +10 -1
  33. package/src/codegraph/lite-provider.ts +10 -1
  34. package/src/compact/engine.ts +14 -4
  35. package/src/dashboard/maintenance.ts +11 -2
  36. package/src/memory/consolidation.ts +15 -6
  37. package/src/memory/freshness.ts +7 -4
  38. package/src/memory/observations.ts +118 -6
  39. package/src/runtime/vector-backfill-runner.ts +1 -1
  40. package/src/sdk.ts +13 -6
  41. package/src/search/semantic-index.ts +340 -0
  42. package/src/server.ts +1 -1
  43. package/src/store/obs-store.ts +54 -2
  44. package/src/store/orama-store.ts +300 -12
  45. package/src/store/sqlite-db.ts +6 -0
  46. package/src/store/sqlite-fts.ts +164 -0
  47. package/src/store/sqlite-store.ts +83 -6
  48. package/assets/star-history-dark.svg +0 -30
  49. package/assets/star-history-light.svg +0 -30
  50. package/assets/star-history.json +0 -712
@@ -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.
@@ -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 search
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.0** and package metadata is kept synchronized
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:
@@ -122,6 +122,10 @@ If an agent only needs a raw stdio MCP entry:
122
122
 
123
123
  Avoid `npx` in persistent MCP configs. Use the globally installed `memorix` binary so startup is predictable.
124
124
 
125
+ For npm-based MCP clients or registry testers, the complete package command is
126
+ `npx -y memorix serve`. Do not omit `serve`: bare `memorix` is the interactive
127
+ memcode entry in a human terminal.
128
+
125
129
  For HTTP mode:
126
130
 
127
131
  ```bash
@@ -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 and Orama for search/indexing. No cloud service is required.
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, writes, and session operations still wait for the in-process retrieval
70
- runtime when they need it. Corpus-scale retention, consolidation, and Code
71
- Memory refresh are queued durably and run in an isolated child process. Vector
72
- backfill stays with the owning process because the Orama index is local to that
73
- process.
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
- Startup hydration uses bounded Orama batches. This keeps HTTP health responsive
147
- while avoiding a full index rebalance for every persisted observation. The MCP
148
- search number is intentionally a cold control-plane measurement: it includes
149
- any lexical hydration the fresh service still owes before its first search.
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
- ## Current Optimization Opportunities
156
-
157
- These are not release blockers, but they are reasonable future improvements:
158
-
159
- - Extend the retrieval benchmark with separately labelled cold CLI, MCP, and remote-provider cases when a release needs those comparisons.
160
- - Add dashboard-side performance telemetry for API latency and payload sizes.
161
- - Document recommended retention schedules for large projects.
162
- - Evaluate a persistent cross-process retrieval index only as a major-version
163
- architecture change; Orama is intentionally process-local today.
164
- - Explore slimmer Docker layers if image size becomes a common pain point.
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.0**. Multi-device store sync is opt-in and
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/docs/SETUP.md CHANGED
@@ -170,6 +170,11 @@ Generic stdio MCP config:
170
170
 
171
171
  Avoid `npx` in persistent MCP configs. Use the globally installed `memorix` binary so startup is predictable.
172
172
 
173
+ When an npm-based MCP client or registry tester must launch the package
174
+ directly, use `npx -y memorix serve`. The explicit `serve` argument selects
175
+ the stdio MCP server; a human running bare `memorix` in a terminal gets the
176
+ bundled memcode agent instead.
177
+
173
178
  ### Option C: memcode terminal agent
174
179
 
175
180
  ```bash
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.0",
4
+ "version": "1.9.2",
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,
@@ -77,7 +77,6 @@
77
77
  "benchmark:codegraph:scale": "node scripts/benchmark-codegraph-scale.mjs",
78
78
  "benchmark:retrieval": "npm run build && node scripts/benchmark-retrieval.mjs",
79
79
  "gate:large-store": "npm run build && node scripts/large-store-gate.mjs",
80
- "generate:star-history": "node scripts/generate-star-history.mjs",
81
80
  "sync:mcp-registry": "node scripts/sync-mcp-registry-manifest.mjs",
82
81
  "check:mcp-registry": "node scripts/check-mcp-registry-manifest.mjs",
83
82
  "sync:plugin-release": "node scripts/sync-plugin-release-manifests.mjs",
@@ -155,6 +154,7 @@
155
154
  "zod": "^3.24.0"
156
155
  },
157
156
  "optionalDependencies": {
157
+ "@lancedb/lancedb": "^0.38.0",
158
158
  "better-sqlite3": "^12.11.1"
159
159
  },
160
160
  "overrides": {
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0"
3
+ "version": "1.9.2"
4
4
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memorix",
4
- "version": "1.9.0",
4
+ "version": "1.9.2",
5
5
  "description": "Shared workspace memory for Claude Code and other AI coding agents.",
6
6
  "author": {
7
7
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Local-first project memory for CodeBuddy Code and other coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,5 +1,5 @@
1
1
  name: memorix
2
- version: "1.9.0"
2
+ version: "1.9.2"
3
3
  description: Shared workspace memory bridge for Hermes Agent.
4
4
  author: AVIDS2
5
5
  license: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
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.0",
10
+ "version": "1.9.2",
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.0",
25
+ "version": "1.9.2",
26
26
  "runtimeHint": "npx",
27
27
  "packageArguments": [
28
28
  {
@@ -280,7 +280,8 @@ export default defineCommand({
280
280
  case 'deduplicate': {
281
281
  const query = asStringArg(args.query);
282
282
  const dryRun = !!args.dryRun || !!args['dry-run'];
283
- const { isLLMEnabled } = await import('../../llm/provider.js');
283
+ const { initLLM, isLLMEnabled } = await import('../../llm/provider.js');
284
+ initLLM({ scope: 'memory', projectRoot: project.rootPath });
284
285
  if (!isLLMEnabled()) {
285
286
  emitResult(
286
287
  { project, available: false, usedLLM: false },
@@ -0,0 +1,15 @@
1
+ export interface CliInteractivity {
2
+ stdinIsTTY?: boolean;
3
+ stdoutIsTTY?: boolean;
4
+ }
5
+
6
+ /**
7
+ * MCP hosts start package commands with piped stdio, while a human terminal
8
+ * has TTYs on both streams. Keep the friendly memcode default for humans and
9
+ * make the package safe for registry probes and generic MCP launchers.
10
+ */
11
+ export function shouldDefaultToMcp(options: CliInteractivity = {}): boolean {
12
+ const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY;
13
+ const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY;
14
+ return stdinIsTTY !== true || stdoutIsTTY !== true;
15
+ }
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { importBundledMemcode } from './memcode-bootstrap.js';
20
20
  import { installCliPipeErrorGuard } from './pipe-errors.js';
21
21
  import { normalizeCliInvocation } from './invocation.js';
22
22
  import { printCliGuideForHelp, renderCliGuide } from './command-guide.js';
23
+ import { shouldDefaultToMcp } from './default-mode.js';
23
24
 
24
25
  installCliPipeErrorGuard();
25
26
 
@@ -327,8 +328,16 @@ const main = defineCommand({
327
328
  'background', 'bg', 'bs', 'doctor', 'repair', 'dashboard', 'cleanup', 'purge', 'uninstall', 'orchestrate'];
328
329
  if (firstArg && knownSubs.includes(firstArg)) return;
329
330
 
330
- // No subcommand provided — enter memcode TUI (native coding agent)
331
+ // No subcommand provided — keep the interactive memcode default for a
332
+ // human terminal, but make piped package launches an MCP server. Generic
333
+ // MCP directories and clients commonly execute `npx memorix` without
334
+ // reading server.json's package arguments.
331
335
  if (!firstArg) {
336
+ if (shouldDefaultToMcp()) {
337
+ const serve = await import('./commands/serve.js');
338
+ await serve.default.run?.({ args: {}, rawArgs: [], cmd: serve.default } as any);
339
+ return;
340
+ }
332
341
  try {
333
342
  const { runCli } = await importBundledMemcode();
334
343
  await runCli(process.argv.slice(2));
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
2
+ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
  import type { CodeEdge, CodeFile, CodeStateSnapshot, CodeSymbol } from './types.js';
5
5
  import { collectCodeStateSnapshot } from './code-state.js';
@@ -210,6 +210,14 @@ function resolveMaxFileBytes(value: number | undefined): number {
210
210
  return Math.max(1, Math.floor(value!));
211
211
  }
212
212
 
213
+ function isPythonVirtualenvDir(dir: string): boolean {
214
+ // PEP 405 virtualenvs place a `pyvenv.cfg` at their root regardless of the
215
+ // directory name (`.venv`, `.venv-leann`, `env`, uv/poetry envs, ...). Their
216
+ // `site-packages` trees are large and generated; descending into one can stall
217
+ // `codegraph refresh`, so skip virtualenvs by this marker rather than by name.
218
+ return existsSync(join(dir, 'pyvenv.cfg'));
219
+ }
220
+
213
221
  function walk(root: string, exclude: string[], maxFiles: number): string[] {
214
222
  const out: string[] = [];
215
223
  const visit = (dir: string) => {
@@ -228,6 +236,7 @@ function walk(root: string, exclude: string[], maxFiles: number): string[] {
228
236
  if (entry.isDirectory()) {
229
237
  if (entry.name === '.git' || entry.name === '.worktrees' || entry.name === '.tmp' || entry.name === 'node_modules') continue;
230
238
  if (rel === '.claude/worktrees' || rel.startsWith('.claude/worktrees/')) continue;
239
+ if (isPythonVirtualenvDir(abs)) continue;
231
240
  visit(abs);
232
241
  if (out.length >= maxFiles) return;
233
242
  continue;
@@ -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
- const projectHasStoredMemory = allObservations.some((obs) => obs.projectId && projectAliases.has(obs.projectId));
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 matching "${options.query}".\n\n` +
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
  }
@@ -221,6 +221,7 @@ export async function previewConsolidate(context: DashboardMaintenanceContext) {
221
221
  const clusters = await findConsolidationCandidates(
222
222
  context.projectRoot ?? context.dataDir,
223
223
  context.projectId,
224
+ { store: context.store },
224
225
  );
225
226
  const payload: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusters.map((cluster) => cluster.ids)) };
226
227
  return {
@@ -248,11 +249,19 @@ export async function executeConsolidate(
248
249
  const clusterIds = record.clusterIds.map((ids, index) => payloadIds(ids, `clusterIds[${index}]`));
249
250
  const normalized: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusterIds) };
250
251
  verify('consolidate', context.projectId, normalized, token);
251
- const current = await findConsolidationCandidates(context.projectRoot ?? context.dataDir, context.projectId);
252
+ const current = await findConsolidationCandidates(
253
+ context.projectRoot ?? context.dataDir,
254
+ context.projectId,
255
+ { store: context.store },
256
+ );
252
257
  if (JSON.stringify(normalized.clusterIds) !== JSON.stringify(normalizeClusters(current.map((cluster) => cluster.ids)))) {
253
258
  throw new DashboardMaintenanceError('Memory changed after the preview. Refresh before consolidating.', 409);
254
259
  }
255
- const result = await executeConsolidation(context.projectRoot ?? context.dataDir, context.projectId);
260
+ const result = await executeConsolidation(
261
+ context.projectRoot ?? context.dataDir,
262
+ context.projectId,
263
+ { store: context.store },
264
+ );
256
265
  return { action: 'consolidate', projectId: context.projectId, ...result };
257
266
  }
258
267
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { Observation } from '../types.js';
19
- import { getObservationStore } from '../store/obs-store.js';
19
+ import { getObservationStore, type ObservationStore } from '../store/obs-store.js';
20
20
  import { isEligibleForAutomaticDelivery } from './admission.js';
21
21
  import { resolveObservationVisibility } from './visibility.js';
22
22
 
@@ -108,6 +108,14 @@ interface ConsolidationPage {
108
108
  nextCursor?: number;
109
109
  }
110
110
 
111
+ export interface ConsolidationOptions {
112
+ threshold?: number;
113
+ limit?: number;
114
+ afterId?: number;
115
+ /** Use a request-scoped store when running inside a multi-project host. */
116
+ store?: ObservationStore;
117
+ }
118
+
111
119
  function clampBatchSize(limit: number | undefined): number {
112
120
  if (!Number.isFinite(limit)) return MAX_BATCH_SIZE;
113
121
  return Math.min(MAX_BATCH_SIZE, Math.max(1, Math.floor(limit!)));
@@ -120,10 +128,11 @@ function clampCursor(afterId: number | undefined): number {
120
128
 
121
129
  async function loadConsolidationPage(
122
130
  projectId: string,
123
- options: { limit?: number; afterId?: number },
131
+ options: ConsolidationOptions,
124
132
  ): Promise<ConsolidationPage> {
125
133
  const limit = clampBatchSize(options.limit);
126
- const page = await getObservationStore().loadByProject(projectId, {
134
+ const store = options.store ?? getObservationStore();
135
+ const page = await store.loadByProject(projectId, {
127
136
  status: 'active',
128
137
  afterId: clampCursor(options.afterId),
129
138
  limit: limit + 1,
@@ -205,7 +214,7 @@ function findClusters(observations: Observation[], threshold: number): Consolida
205
214
  export async function findConsolidationCandidates(
206
215
  _projectDir: string,
207
216
  projectId: string,
208
- opts?: { threshold?: number; limit?: number; afterId?: number },
217
+ opts?: ConsolidationOptions,
209
218
  ): Promise<ConsolidationCluster[]> {
210
219
  const threshold = opts?.threshold ?? DEFAULT_SIMILARITY_THRESHOLD;
211
220
  const page = await loadConsolidationPage(projectId, opts ?? {});
@@ -224,9 +233,9 @@ export async function findConsolidationCandidates(
224
233
  export async function executeConsolidation(
225
234
  _projectDir: string,
226
235
  projectId: string,
227
- opts?: { threshold?: number; limit?: number; afterId?: number },
236
+ opts?: ConsolidationOptions,
228
237
  ): Promise<ConsolidationResult> {
229
- const store = getObservationStore();
238
+ const store = opts?.store ?? getObservationStore();
230
239
  const page = await loadConsolidationPage(projectId, opts ?? {});
231
240
  const clusters = findClusters(page.observations, opts?.threshold ?? DEFAULT_SIMILARITY_THRESHOLD);
232
241
 
@@ -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>(fn: () => T | Promise<T>): Promise<T> {
112
- await ensureFreshIndex();
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