@elvisw/dsh-magic-context 0.0.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +23 -0
  2. package/NOTICE +93 -0
  3. package/README.md +453 -0
  4. package/README.zh-CN.md +368 -0
  5. package/cordis.patch.yml +20 -0
  6. package/lib/client.js +2270 -0
  7. package/lib/core/compartments.js +424 -0
  8. package/lib/core/corpus-store.js +176 -0
  9. package/lib/core/directive-detect.js +147 -0
  10. package/lib/core/domain-reconcile.js +394 -0
  11. package/lib/core/domains.js +127 -0
  12. package/lib/core/embedding-local.js +247 -0
  13. package/lib/core/global-autostage.js +223 -0
  14. package/lib/core/global-domains.js +302 -0
  15. package/lib/core/journal.js +248 -0
  16. package/lib/core/llm-deadline.js +53 -0
  17. package/lib/core/machine-domain.js +106 -0
  18. package/lib/core/maintenance.js +380 -0
  19. package/lib/core/memory-bridge.js +818 -0
  20. package/lib/core/memory-categories.js +84 -0
  21. package/lib/core/memory-scoring.js +65 -0
  22. package/lib/core/memory-search.js +414 -0
  23. package/lib/core/memory-write.js +1176 -0
  24. package/lib/core/policy.js +111 -0
  25. package/lib/core/profile-confirmation.js +55 -0
  26. package/lib/core/profile-review.js +375 -0
  27. package/lib/core/profile-write.js +283 -0
  28. package/lib/core/reference-seeds.js +63 -0
  29. package/lib/core/route-health.js +106 -0
  30. package/lib/core/stores.js +1021 -0
  31. package/lib/core/tagger.js +7 -0
  32. package/lib/core/types.js +15 -0
  33. package/lib/core/vector-backfill.js +140 -0
  34. package/lib/core/vector-lane.js +184 -0
  35. package/lib/dsh/command-surface.js +376 -0
  36. package/lib/dsh/compaction-engine.js +231 -0
  37. package/lib/dsh/config-switches.js +56 -0
  38. package/lib/dsh/config.js +13 -0
  39. package/lib/dsh/context-pressure.js +36 -0
  40. package/lib/dsh/drop-landing.js +42 -0
  41. package/lib/dsh/emergency.js +89 -0
  42. package/lib/dsh/flush-command.js +148 -0
  43. package/lib/dsh/global-review.js +412 -0
  44. package/lib/dsh/historian.js +907 -0
  45. package/lib/dsh/live-journal.js +81 -0
  46. package/lib/dsh/maintenance-wiring.js +229 -0
  47. package/lib/dsh/management-pass.js +564 -0
  48. package/lib/dsh/memory-lane.js +535 -0
  49. package/lib/dsh/pre-step.js +675 -0
  50. package/lib/dsh/project-drift.js +116 -0
  51. package/lib/dsh/recomp.js +192 -0
  52. package/lib/dsh/reduce-guidance.js +188 -0
  53. package/lib/dsh/seams.js +588 -0
  54. package/lib/dsh/search-hint.js +186 -0
  55. package/lib/dsh/session-notice.js +100 -0
  56. package/lib/dsh/status-command.js +75 -0
  57. package/lib/dsh/telemetry.js +349 -0
  58. package/lib/dsh/tool-bridge.js +1009 -0
  59. package/lib/dsh/volatile.js +89 -0
  60. package/lib/index.js +931 -0
  61. package/package.json +110 -0
  62. package/tools/migrate-profile-package-name.mjs +317 -0
  63. package/vendor/onnx/LICENSE +27 -0
  64. package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
  65. package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
  66. package/vendor/onnx/ort.node.min.mjs +8 -0
@@ -0,0 +1,247 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { createHash } from 'node:crypto';
10
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { Readable } from 'node:stream';
13
+ import { pipeline as streamPipeline } from 'node:stream/promises';
14
+ /**
15
+ * Local MiniLM embedding provider — the adapter's twin of Magic Context's
16
+ * `memory/embedding-local.ts` (v0.45.0 defaults), running on **onnxruntime-web's
17
+ * WASM backend** instead of the native onnxruntime-node build.
18
+ *
19
+ * Why WASM: the native stack (~287 MB of platform binaries via
20
+ * `@huggingface/transformers`'s hard dependency) made the distributed package
21
+ * prohibitive; the WASM runtime is a vendored 27 MB binary with zero native
22
+ * install steps. Verified 2026-10-09: this path reproduces the native fp32
23
+ * pipeline byte-for-byte at cosine 1.0000000 over a Chinese/English probe set,
24
+ * so vectors stay inside Magic Context's shared space (see the parity gate in
25
+ * memory-lane.ts — verified empirically, never assumed).
26
+ *
27
+ * Why this exists at all: `ctx_search` scores meaning with
28
+ * `cosineSimilarity(query, stored)` (search.ts:722-727). The vectors in the
29
+ * shared `memory_embeddings` table were produced by Magic Context's own local
30
+ * pipeline, and our acceptance probe (2026-10-07) proved a locally-embedded
31
+ * fp32 vector matches a stored one with cosine 1.000000 — the same vector
32
+ * space, byte for byte. Reading that table and embedding queries here
33
+ * therefore needs no server.
34
+ *
35
+ * Discipline borrowed from MC's `embedding-identity.ts`: local vectors are
36
+ * fenced to the runtime stack that produced them. The fold below reproduces
37
+ * MC's identity input exactly (`provider/model/endpoint/apiKeyPresent/
38
+ * runtimeFingerprint`, fp32 omitted as the default) so `embeddingIdentityHash()`
39
+ * names the adapter's own vectors honestly; shared-space parity against a store
40
+ * is checked at runtime by the composition root's `resolveSharedSpace` closure
41
+ * (cosine against a stored vector), never assumed. The fingerprint names THIS
42
+ * stack (wasm), which differs from MC's native fold — that is honest naming,
43
+ * not a compatibility break: reads are gated by the empirical parity check.
44
+ *
45
+ * Everything here is lazy and fail-open: the model assets download on the first
46
+ * `embed()` call, the heavy modules load then too, and any failure resolves to
47
+ * `null`-shaped telemetry results — search degrades to its FTS lane rather
48
+ * than failing the tool call.
49
+ */
50
+ export const EMBEDDING_MODEL = 'Xenova/all-MiniLM-L6-v2';
51
+ /** MC default dtype (`DEFAULT_LOCAL_DTYPE`, embedding-local.ts) — must stay
52
+ * fp32 or the vectors leave MC's space and every stored comparison degrades. */
53
+ export const EMBEDDING_DTYPE = 'fp32';
54
+ export const EMBEDDING_DIMS = 384;
55
+ /** Sentence-transformers' convention for this model (tokenizer_config.json
56
+ * `model_max_length`); the native pipeline truncated here too. */
57
+ export const EMBEDDING_MAX_SEQ = 512;
58
+ /** The runtime stack behind every vector we produce — onnxruntime-web's wasm
59
+ * backend (vendored, see `vendor/onnx/`) plus the official tokenizers binding. */
60
+ export const LOCAL_EMBEDDING_RUNTIME_FINGERPRINT = 'onnxruntime-web@1.31.0-dev.20260914-8d85527a0(wasm);@huggingface/tokenizers@0.2.0';
61
+ /** Model assets, resolved once into `cacheDir` and reused forever after.
62
+ * Both the upstream revision AND the sha256 of every byte are pinned: the
63
+ * ONNX file is executed by the vendored WASM runtime, so a floating
64
+ * `resolve/main` is both a silent-quality hazard (weights change → the
65
+ * vector space drifts) and a supply-chain one (hf-mirror is a third party
66
+ * serving executable input). Pinned at upstream sha 751bff37182d3f1213fa05d7196b954e230abad9
67
+ * (2026-10-14, hashes verified against the mirror download); `minBytes`
68
+ * stays as a cheap size sanity check, the hash is the authority. */
69
+ export const EMBEDDING_ASSETS = [
70
+ { name: 'tokenizer.json', path: 'Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/tokenizer.json', minBytes: 100_000, sha256: 'da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0' },
71
+ { name: 'tokenizer_config.json', path: 'Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/tokenizer_config.json', minBytes: 32, sha256: '9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3' },
72
+ { name: 'model.onnx', path: 'Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/onnx/model.onnx', minBytes: 10_000_000, sha256: '759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e' },
73
+ ];
74
+ /** HuggingFace first, the public mirror as fallback — both plain GETs. */
75
+ const MIRRORS = ['https://huggingface.co/', 'https://hf-mirror.com/'];
76
+ /** MC `computeNormalizedHash` — the same md5 fold the memory rows use (§2.3). */
77
+ export function normalizedEmbeddingHash(content) {
78
+ return createHash('md5').update(content.toLowerCase().replace(/\s+/g, ' ').trim()).digest('hex');
79
+ }
80
+ /** MC `getEmbeddingProviderIdentity` local branch (embedding-identity.ts:79-88). */
81
+ export function embeddingIdentityHash() {
82
+ return `embedding-provider:${normalizedEmbeddingHash(JSON.stringify({
83
+ provider: 'local',
84
+ model: EMBEDDING_MODEL,
85
+ endpoint: '',
86
+ apiKeyPresent: false,
87
+ runtimeFingerprint: LOCAL_EMBEDDING_RUNTIME_FINGERPRINT,
88
+ }))}`;
89
+ }
90
+ /** Cosine similarity over equal-length vectors (MC cosine-similarity.ts). */
91
+ export function cosineSimilarity(a, b) {
92
+ let dot = 0;
93
+ let na = 0;
94
+ let nb = 0;
95
+ const n = Math.min(a.length, b.length);
96
+ for (let i = 0; i < n; i++) {
97
+ dot += a[i] * b[i];
98
+ na += a[i] * a[i];
99
+ nb += b[i] * b[i];
100
+ }
101
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
102
+ return denom === 0 ? 0 : dot / denom;
103
+ }
104
+ /**
105
+ * Mean-pool the model's `last_hidden_state` over the attention mask, then
106
+ * L2-normalize — the exact recipe of transformers.js' `pooling: 'mean',
107
+ * normalize: true`. The fp64 accumulator keeps the summation order-stable; the
108
+ * spike measured cosine 1.0000000 against the native pipeline with it.
109
+ * Exported for tests: this function IS the vector space.
110
+ */
111
+ export function meanPoolNormalize(hidden, seq, dims, mask) {
112
+ const acc = new Float64Array(dims);
113
+ let maskSum = 0;
114
+ for (let s = 0; s < seq; s++) {
115
+ const m = mask[s] ?? 0;
116
+ maskSum += m;
117
+ for (let h = 0; h < dims; h++)
118
+ acc[h] = acc[h] + hidden[s * dims + h] * m;
119
+ }
120
+ if (maskSum === 0)
121
+ return new Float32Array(dims);
122
+ let norm = 0;
123
+ for (let h = 0; h < dims; h++) {
124
+ acc[h] = acc[h] / maskSum;
125
+ norm += acc[h] * acc[h];
126
+ }
127
+ norm = Math.sqrt(norm);
128
+ const out = new Float32Array(dims);
129
+ for (let h = 0; h < dims; h++)
130
+ out[h] = acc[h] / norm;
131
+ return out;
132
+ }
133
+ /** The vendored ORT node entry sits beside the built lib at `<pkg>/vendor/onnx/`;
134
+ * `src/core/*` and `lib/core/*` are both two levels below the package root, so
135
+ * one relative URL serves the source tree (vitest) and the built tree (DSH). */
136
+ function ortVendorUrl() {
137
+ return new URL('../../vendor/onnx/ort.node.min.mjs', import.meta.url).href;
138
+ }
139
+ async function ensureAsset(cacheDir, asset, fetchImpl, logger) {
140
+ const dest = join(cacheDir, asset.name);
141
+ // Cached files were hash-verified when written by this code path; the size
142
+ // floor keeps the warm path free of any hashing cost.
143
+ if (existsSync(dest) && statSync(dest).size >= asset.minBytes)
144
+ return;
145
+ mkdirSync(cacheDir, { recursive: true });
146
+ for (const mirror of MIRRORS) {
147
+ try {
148
+ logger?.info?.(`magic-context embedding assets: fetching ${asset.name} from ${mirror}`);
149
+ const res = await fetchImpl(mirror + asset.path, { redirect: 'follow' });
150
+ if (!res.ok || !res.body)
151
+ throw new Error(`HTTP ${res.status}`);
152
+ const tmp = `${dest}.part`;
153
+ await streamPipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
154
+ if (statSync(tmp).size < asset.minBytes)
155
+ throw new Error(`suspiciously small (${statSync(tmp).size} bytes)`);
156
+ const digest = createHash('sha256').update(readFileSync(tmp)).digest('hex');
157
+ if (digest !== asset.sha256)
158
+ throw new Error(`sha256 mismatch (got ${digest.slice(0, 16)}…, want ${asset.sha256.slice(0, 16)}…)`);
159
+ renameSync(tmp, dest);
160
+ logger?.info?.(`magic-context embedding assets: ${asset.name} cached (${statSync(dest).size} bytes)`);
161
+ return;
162
+ }
163
+ catch (error) {
164
+ logger?.warn?.(`magic-context embedding assets: ${asset.name} from ${mirror} failed: ${error instanceof Error ? error.message : String(error)}`);
165
+ }
166
+ }
167
+ throw new Error(`all mirrors failed for ${asset.name}`);
168
+ }
169
+ /**
170
+ * Open the lazy local embedder. `embed()` fetches the model assets (first use),
171
+ * loads the vendored onnxruntime-web WASM backend and the official tokenizers
172
+ * binding once, then reuses both. Every failure mode resolves to `[]`-shaped
173
+ * results — telemetry and recall quality, never correctness.
174
+ */
175
+ export function openLocalEmbedder(options) {
176
+ const logger = options.logger;
177
+ let runnerPromise = null;
178
+ const load = async () => {
179
+ if (runnerPromise === null) {
180
+ runnerPromise = (async () => {
181
+ // The vendored ORT loads first: a broken runtime fails fast and cheap,
182
+ // before any model asset traffic is even considered.
183
+ const ort = await import(__rewriteRelativeImportExtension(/* webpackIgnore: true */ options.ortUrl ?? ortVendorUrl()));
184
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
185
+ for (const asset of options.assets ?? EMBEDDING_ASSETS)
186
+ await ensureAsset(options.cacheDir, asset, fetchImpl, logger);
187
+ const { Tokenizer } = await import('@huggingface/tokenizers');
188
+ const tok = new Tokenizer(JSON.parse(readFileSync(join(options.cacheDir, 'tokenizer.json'), 'utf8')), JSON.parse(readFileSync(join(options.cacheDir, 'tokenizer_config.json'), 'utf8')));
189
+ const session = await ort.InferenceSession.create(join(options.cacheDir, 'model.onnx'), {
190
+ executionProviders: ['wasm'],
191
+ });
192
+ logger?.info?.(`magic-context local embedder ready (${EMBEDDING_MODEL} ${EMBEDDING_DTYPE}, wasm backend)`);
193
+ return async (texts) => {
194
+ const vectors = [];
195
+ for (const text of texts) {
196
+ const raw = tok.encode(text, { add_special_tokens: true, return_token_type_ids: true });
197
+ const seq = Math.min(raw.ids.length, EMBEDDING_MAX_SEQ);
198
+ const ids = BigInt64Array.from(raw.ids.slice(0, seq).map((v) => BigInt(v)));
199
+ const mask = BigInt64Array.from(raw.attention_mask.slice(0, seq).map((v) => BigInt(v)));
200
+ const types = BigInt64Array.from((raw.token_type_ids ?? raw.ids.map(() => 0)).slice(0, seq).map((v) => BigInt(v)));
201
+ const out = await session.run({
202
+ input_ids: new ort.Tensor('int64', ids, [1, seq]),
203
+ attention_mask: new ort.Tensor('int64', mask, [1, seq]),
204
+ token_type_ids: new ort.Tensor('int64', types, [1, seq]),
205
+ });
206
+ const hidden = out.last_hidden_state ?? Object.values(out)[0];
207
+ const modelDims = hidden.dims.at(-1) ?? EMBEDDING_DIMS;
208
+ if (modelDims !== EMBEDDING_DIMS) {
209
+ logger?.warn?.(`magic-context embedder returned unexpected dims ${hidden.dims.join(',')}`);
210
+ }
211
+ const maskNums = raw.attention_mask.slice(0, seq);
212
+ vectors.push(meanPoolNormalize(hidden.data, seq, modelDims, maskNums));
213
+ }
214
+ return vectors;
215
+ };
216
+ })().catch((error) => {
217
+ runnerPromise = null; // allow a later retry
218
+ throw error;
219
+ });
220
+ }
221
+ return runnerPromise;
222
+ };
223
+ return {
224
+ modelId: embeddingIdentityHash(),
225
+ dims: EMBEDDING_DIMS,
226
+ async embed(texts) {
227
+ if (texts.length === 0)
228
+ return [];
229
+ if (options.embedOverride !== undefined)
230
+ return options.embedOverride(texts);
231
+ try {
232
+ const run = await load();
233
+ return await run(texts);
234
+ }
235
+ catch (error) {
236
+ logger?.warn?.(`magic-context local embedder unavailable (search degrades to FTS): `
237
+ + `${error instanceof Error ? error.message : String(error)}`);
238
+ return [];
239
+ }
240
+ },
241
+ };
242
+ }
243
+ /** Default model cache directory, derived from the adapter's own data dir
244
+ * (the directory holding the adapter's SQLite store). */
245
+ export function defaultEmbeddingCacheDir(adapterDbPath) {
246
+ return join(dirname(adapterDbPath), 'models');
247
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * L1① v10 — classify-then-route machine/org auto-stage (2026-10-12, user-
3
+ * directed). The previous leg scanned the contract store and mechanically
4
+ * promoted every qualifying memory into the MACHINE domain — and that default
5
+ * was wrong in both directions: an organisation workflow ("project-a/project-b must
6
+ * deploy serially") got routed to machine because the SQL rule cannot read
7
+ * content, and a classification failure silently assumed machine. The user's
8
+ * correction: the LLM decides the domain, and when it cannot, the memory STAYS
9
+ * AT PROJECT LEVEL — never a guessed fallback domain (machine or org can both
10
+ * be "seen" by other projects, so guessing either one hides the misstep).
11
+ *
12
+ * Flow per maintenance tick:
13
+ * 1. scan recurred, allowlisted, non-global project memories (union of the
14
+ * ENABLED global domains' shareCategories — machine and org together, so
15
+ * neither audience is invisible to the classifier);
16
+ * 2. one LLM call per candidate: {"domain": "machine"|"org"|"project",
17
+ * "reason": "<one line>"};
18
+ * 3. route: machine → machine pool, org → org pool (only when that domain is
19
+ * enabled AND the candidate's category is on its current allowlist —
20
+ * otherwise NOT staged); project / unparseable / no-LLM → NOT staged.
21
+ *
22
+ * The suggestion rides the candidate row (`suggested_domain` +
23
+ * `suggestion_reason`), so the review card can show what the classifier said
24
+ * and the human can overrule it (the card's "publish to another domain"
25
+ * option). Publishing stays human — this leg only fills the pools.
26
+ */
27
+ import { DatabaseSync } from 'node:sqlite';
28
+ const DEFAULT_LIMIT = 3;
29
+ const DEFAULT_MAX_PENDING = 10;
30
+ const CLASSIFY_MAX_TOKENS = 2048;
31
+ const CLASSIFY_INSTRUCTION = [
32
+ 'You are classifying a recurring project memory for possible global promotion. Judge which audience it belongs to:',
33
+ '- "machine" (the GLOBAL domain, visible to every project on this machine): local tooling, machine-wide conventions, environment facts.',
34
+ '- "org" (the WORK domain, visible only to a curated set of work projects): company/team infrastructure, cross-repo workflows, shared internal systems.',
35
+ '- "project": project-specific only; must NOT be promoted.',
36
+ 'Reply with ONLY a JSON object: {"domain":"machine"|"org"|"project","reason":"<one line>"}',
37
+ ].join('\n');
38
+ /** Extract the classification from a raw model reply; undefined on unparseable/absent. */
39
+ function parseClassification(text) {
40
+ const start = text.indexOf('{');
41
+ const end = text.lastIndexOf('}');
42
+ if (start < 0 || end <= start)
43
+ return undefined;
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(text.slice(start, end + 1));
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ const record = parsed;
52
+ const domain = record.domain;
53
+ if (domain !== 'machine' && domain !== 'org' && domain !== 'project')
54
+ return undefined;
55
+ const reason = typeof record.reason === 'string' && record.reason.trim() !== '' ? record.reason.trim() : undefined;
56
+ return { domain, ...(reason === undefined ? {} : { reason }) };
57
+ }
58
+ async function classify(llm, route, row, sessionId) {
59
+ const prompt = CLASSIFY_INSTRUCTION
60
+ + '\n\nMemory content:\n' + row.content.slice(0, 600).replace(/\s+/g, ' ')
61
+ + '\nCategory: ' + row.category;
62
+ let text = '';
63
+ try {
64
+ const chunks = llm.stream({
65
+ provider: route.provider,
66
+ model: route.model,
67
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
68
+ maxTokens: CLASSIFY_MAX_TOKENS,
69
+ sessionId,
70
+ purpose: 'compaction',
71
+ });
72
+ for await (const raw of chunks) {
73
+ const chunk = raw;
74
+ if (chunk.type === 'text-delta' && typeof chunk.index === 'number' && typeof chunk.text === 'string') {
75
+ text += chunk.text;
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ return parseClassification(text);
83
+ }
84
+ export async function autoStageGlobalCandidates(input) {
85
+ const enabled = input.store.listDomains()
86
+ .filter(domain => domain.enabled && (domain.kind === 'machine' || domain.kind === 'org'));
87
+ if (enabled.length === 0)
88
+ return { staged: [], skipped: [], reason: 'domain-missing' };
89
+ if (input.llm === undefined || input.route === undefined) {
90
+ // The user's fail-closed direction: without a classifier nothing moves —
91
+ // the memories stay at project level instead of being silently guessed
92
+ // into a domain.
93
+ return { staged: [], skipped: [], reason: 'llm-unavailable' };
94
+ }
95
+ const limit = input.limit ?? DEFAULT_LIMIT;
96
+ const maxPending = input.maxPending ?? DEFAULT_MAX_PENDING;
97
+ // The queue guard is domain-level: each pool pauses independently when IT is
98
+ // full (a full org pool must not starve the machine pool). When EVERY
99
+ // enabled target pool is full the whole pass pauses up front.
100
+ const queueGuard = (domainId) => input.store.listGlobalCandidates({ state: 'pending', domainId }).length >= maxPending;
101
+ if (enabled.every(domain => queueGuard(domain.domainId))) {
102
+ return { staged: [], skipped: [], reason: 'queue-full' };
103
+ }
104
+ let scan;
105
+ try {
106
+ const db = new DatabaseSync(input.corpusDbPath, { readOnly: true });
107
+ try {
108
+ const categories = JSON.stringify(enabled.flatMap(domain => [...domain.shareCategories]));
109
+ scan = db.prepare(`SELECT id, content, category FROM memories
110
+ WHERE status = 'active' AND scope = 'project' AND seen_count >= 2
111
+ AND category IN (SELECT value FROM json_each(?))
112
+ AND project_path NOT LIKE 'dir:global:%' AND project_path NOT LIKE 'git:global:%'
113
+ ORDER BY updated_at DESC LIMIT 50`).all(categories);
114
+ }
115
+ finally {
116
+ db.close();
117
+ }
118
+ }
119
+ catch {
120
+ return { staged: [], skipped: [], reason: 'domain-missing' };
121
+ }
122
+ const staged = [];
123
+ const skipped = [];
124
+ const seenRefusals = new Set();
125
+ const sessionId = 'mc-maintenance';
126
+ // Exact "created this pass" detection: everything already in any pool is
127
+ // known before the loop; the upsert is idempotent per (domain, hash), so a
128
+ // re-scan returns the SAME row and must not count as new.
129
+ const known = new Set(input.store.listGlobalCandidates().map(candidate => candidate.id));
130
+ for (const row of scan) {
131
+ if (staged.length >= limit)
132
+ break;
133
+ const verdict = await classify(input.llm, input.route, row, sessionId);
134
+ if (verdict === undefined || verdict.domain === 'project') {
135
+ const reason = verdict === undefined ? 'unclassified' : 'project-scope';
136
+ if (!seenRefusals.has(`${String(row.id)}:${reason}`)) {
137
+ skipped.push({ memoryId: row.id, reason });
138
+ seenRefusals.add(`${String(row.id)}:${reason}`);
139
+ }
140
+ continue;
141
+ }
142
+ const target = enabled.find(domain => domain.kind === verdict.domain);
143
+ if (target === undefined) {
144
+ if (!seenRefusals.has(`${String(row.id)}:target-disabled`)) {
145
+ skipped.push({ memoryId: row.id, reason: 'target-disabled' });
146
+ seenRefusals.add(`${String(row.id)}:target-disabled`);
147
+ }
148
+ continue;
149
+ }
150
+ if (queueGuard(target.domainId)) {
151
+ if (!seenRefusals.has(`${String(row.id)}:queue-full`)) {
152
+ skipped.push({ memoryId: row.id, reason: 'queue-full' });
153
+ seenRefusals.add(`${String(row.id)}:queue-full`);
154
+ }
155
+ continue;
156
+ }
157
+ if (!target.shareCategories.includes(row.category)) {
158
+ if (!seenRefusals.has(`${String(row.id)}:outside-allowlist`)) {
159
+ skipped.push({ memoryId: row.id, reason: 'outside-allowlist' });
160
+ seenRefusals.add(`${String(row.id)}:outside-allowlist`);
161
+ }
162
+ continue;
163
+ }
164
+ const result = promoteMemoryToCandidate(input.store, { corpusDbPath: input.corpusDbPath, memoryId: row.id, domainId: target.domainId, now: input.now }, verdict.domain, verdict.reason);
165
+ if (result.ok) {
166
+ if (!known.has(result.candidate.id)) {
167
+ known.add(result.candidate.id);
168
+ staged.push({ candidateId: result.candidate.id, memoryId: row.id, domainId: target.domainId });
169
+ }
170
+ continue;
171
+ }
172
+ const key = `${String(row.id)}:${result.error}`;
173
+ if (!seenRefusals.has(key)) {
174
+ skipped.push({ memoryId: row.id, reason: result.error });
175
+ seenRefusals.add(key);
176
+ }
177
+ }
178
+ return { staged, skipped };
179
+ }
180
+ function promoteMemoryToCandidate(store, options, suggestedDomain, suggestionReason) {
181
+ const memory = readCorpusMemory(options.corpusDbPath, options.memoryId);
182
+ if (memory === undefined)
183
+ return { ok: false, error: 'no memory in shared store' };
184
+ if (memory.status !== 'active')
185
+ return { ok: false, error: `memory is ${memory.status}` };
186
+ const candidate = store.upsertGlobalCandidate({
187
+ domainId: options.domainId,
188
+ content: memory.content,
189
+ normalizedHash: memory.normalizedHash,
190
+ firstIdentity: memory.projectPath,
191
+ category: memory.category,
192
+ sourceMemoryId: memory.id,
193
+ suggestedDomain,
194
+ ...(suggestionReason === undefined ? {} : { suggestionReason }),
195
+ }, options.now);
196
+ store.recordCandidateObservation({ candidateId: candidate.id, identity: memory.projectPath, sessionId: `memory:${String(memory.id)}` }, options.now);
197
+ const refreshed = store.getGlobalCandidate(candidate.id) ?? candidate;
198
+ return { ok: true, candidate: refreshed };
199
+ }
200
+ function readCorpusMemory(corpusDbPath, memoryId) {
201
+ try {
202
+ const db = new DatabaseSync(corpusDbPath, { readOnly: true });
203
+ try {
204
+ const row = db.prepare('SELECT id, content, category, normalized_hash, project_path, status FROM memories WHERE id = ?').get(memoryId);
205
+ if (row === undefined)
206
+ return undefined;
207
+ return {
208
+ id: Number(row.id),
209
+ content: String(row.content),
210
+ category: String(row.category),
211
+ normalizedHash: String(row.normalized_hash),
212
+ projectPath: String(row.project_path),
213
+ status: String(row.status),
214
+ };
215
+ }
216
+ finally {
217
+ db.close();
218
+ }
219
+ }
220
+ catch {
221
+ return undefined;
222
+ }
223
+ }