@elvisw/dsh-magic-context 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/NOTICE +93 -0
- package/README.md +452 -2
- package/README.zh-CN.md +368 -0
- package/cordis.patch.yml +20 -0
- package/lib/client.js +2270 -0
- package/lib/core/compartments.js +424 -0
- package/lib/core/corpus-store.js +176 -0
- package/lib/core/directive-detect.js +147 -0
- package/lib/core/domain-reconcile.js +394 -0
- package/lib/core/domains.js +127 -0
- package/lib/core/embedding-local.js +247 -0
- package/lib/core/global-autostage.js +223 -0
- package/lib/core/global-domains.js +302 -0
- package/lib/core/journal.js +248 -0
- package/lib/core/llm-deadline.js +53 -0
- package/lib/core/machine-domain.js +106 -0
- package/lib/core/maintenance.js +380 -0
- package/lib/core/memory-bridge.js +818 -0
- package/lib/core/memory-categories.js +84 -0
- package/lib/core/memory-scoring.js +65 -0
- package/lib/core/memory-search.js +414 -0
- package/lib/core/memory-write.js +1176 -0
- package/lib/core/policy.js +111 -0
- package/lib/core/profile-confirmation.js +55 -0
- package/lib/core/profile-review.js +375 -0
- package/lib/core/profile-write.js +283 -0
- package/lib/core/reference-seeds.js +63 -0
- package/lib/core/route-health.js +106 -0
- package/lib/core/stores.js +1021 -0
- package/lib/core/tagger.js +7 -0
- package/lib/core/types.js +15 -0
- package/lib/core/vector-backfill.js +140 -0
- package/lib/core/vector-lane.js +184 -0
- package/lib/dsh/command-surface.js +376 -0
- package/lib/dsh/compaction-engine.js +231 -0
- package/lib/dsh/config-switches.js +56 -0
- package/lib/dsh/config.js +13 -0
- package/lib/dsh/context-pressure.js +36 -0
- package/lib/dsh/drop-landing.js +42 -0
- package/lib/dsh/emergency.js +89 -0
- package/lib/dsh/flush-command.js +148 -0
- package/lib/dsh/global-review.js +412 -0
- package/lib/dsh/historian.js +907 -0
- package/lib/dsh/live-journal.js +81 -0
- package/lib/dsh/maintenance-wiring.js +229 -0
- package/lib/dsh/management-pass.js +564 -0
- package/lib/dsh/memory-lane.js +535 -0
- package/lib/dsh/pre-step.js +675 -0
- package/lib/dsh/project-drift.js +116 -0
- package/lib/dsh/recomp.js +192 -0
- package/lib/dsh/reduce-guidance.js +188 -0
- package/lib/dsh/seams.js +588 -0
- package/lib/dsh/search-hint.js +186 -0
- package/lib/dsh/session-notice.js +100 -0
- package/lib/dsh/status-command.js +75 -0
- package/lib/dsh/telemetry.js +349 -0
- package/lib/dsh/tool-bridge.js +1009 -0
- package/lib/dsh/volatile.js +89 -0
- package/lib/index.js +931 -0
- package/package.json +108 -4
- package/tools/migrate-profile-package-name.mjs +317 -0
- package/vendor/onnx/LICENSE +27 -0
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +59 -0
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnx/ort.node.min.mjs +8 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project-memory bridge — the plugin's OWN corpus (`memories.db`, cut
|
|
3
|
+
* over from the legacy shared store 2026-10-13), read by the DSH adapter.
|
|
4
|
+
*
|
|
5
|
+
* Facts this module is built on (shape inherited verbatim from Magic
|
|
6
|
+
* Context's v95 storage layer; the schema is OURS now — see corpus-store.ts):
|
|
7
|
+
* - `memories` has NO `harness` column: project memories are shared across
|
|
8
|
+
* sessions on this machine through the corpus;
|
|
9
|
+
* - `project_path` stores an IDENTITY (`git:<root-commit-sha>` or
|
|
10
|
+
* `dir:<hash>`), not a path; legacy rows may still hold raw paths, so the
|
|
11
|
+
* bridge matches the identity and the normalized path;
|
|
12
|
+
* - the schema version lives in `schema_migrations` (lane `< 10000`);
|
|
13
|
+
* a database newer than `MEMORY_SCHEMA_MAX_VERSION` is
|
|
14
|
+
* fail-closed (read nothing) rather than guessed at;
|
|
15
|
+
* - injectable rows are `status IN ('active','permanent')` and not expired;
|
|
16
|
+
* domain-shared rows additionally need `shareable = 1`,
|
|
17
|
+
* `scope IN ('project','ecosystem','universe')` and a category in the
|
|
18
|
+
* active domains' `share_categories` allowlist;
|
|
19
|
+
* - selection priority is permanent → importance → max(last_seen_at,
|
|
20
|
+
* verified_at) → id, trimmed to a token budget.
|
|
21
|
+
*
|
|
22
|
+
* WRITES ARE DELIBERATELY ABSENT: inserting memories also means computing
|
|
23
|
+
* Magic Context's normalized hash, bumping `project_state.project_memory_epoch`
|
|
24
|
+
* only when the injectable set changes, appending `memory_mutation_log`, and
|
|
25
|
+
* honoring the authority guard — invariants owned by Magic Context's storage
|
|
26
|
+
* layer. The adapter recalls; it does not fork the memory store.
|
|
27
|
+
*/
|
|
28
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { existsSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { join, normalize, resolve } from 'node:path';
|
|
34
|
+
import { estimateTokens, MEMORY_VISIBILITY_SENTINEL } from "./compartments.js";
|
|
35
|
+
import { memoryCategoryOrder } from "./memory-categories.js";
|
|
36
|
+
import { scoreMemory } from "./memory-scoring.js";
|
|
37
|
+
import { errText } from "./types.js";
|
|
38
|
+
/** The corpus schema ceiling this build understands. Own lineage: the shape
|
|
39
|
+
* was inherited verbatim from upstream MC's v95 store at the 2026-10-13
|
|
40
|
+
* cutover (the adapter stopped sharing `context.db`; see corpus-store.ts),
|
|
41
|
+
* and every future `schema_migrations` row is OURS — a version above this
|
|
42
|
+
* ceiling can only mean the corpus is newer than this build, which must
|
|
43
|
+
* fence to read-only exactly as before. */
|
|
44
|
+
export const MEMORY_SCHEMA_MAX_VERSION = 95;
|
|
45
|
+
/** MC's terminal mutation types (`storage-memory-mutation-log.ts:9-15`): an
|
|
46
|
+
* `update` queued after one of these must not resurrect the memory. */
|
|
47
|
+
export const TERMINAL_MUTATION_TYPES = new Set(['archive', 'delete', 'superseded']);
|
|
48
|
+
/** MC's `MAX_MEMORY_REPLACEMENT_DEPTH` — the supersede-chain walk cap. */
|
|
49
|
+
export const MAX_MEMORY_REPLACEMENT_DEPTH = 8;
|
|
50
|
+
/** Default page size for one cursor read (the real log holds hundreds of rows). */
|
|
51
|
+
export const DEFAULT_MUTATION_PAGE_LIMIT = 1_000;
|
|
52
|
+
/** The default domain sharing allowlist when `share_categories` is absent. */
|
|
53
|
+
export const DEFAULT_SHARE_CATEGORIES = ['CONSTRAINTS'];
|
|
54
|
+
/** Direct-caller fallback for the corpus path (scripts, tools). The lane itself
|
|
55
|
+
* resolves the corpus explicitly — beside the adapter store in the profile's
|
|
56
|
+
* dataDir — and never consults this (cutover 2026-10-13: no more sharing
|
|
57
|
+
* upstream MC's `context.db`). */
|
|
58
|
+
export function defaultMemoryDbPath() {
|
|
59
|
+
return join(homedir(), '.local', 'share', 'cortexkit', 'magic-context-dsh-poc', 'memories.db');
|
|
60
|
+
}
|
|
61
|
+
/** Default m[0] budget for `<user-profile>` (Magic Context's own number). */
|
|
62
|
+
export const PROFILE_M0_BUDGET_TOKENS = 4000;
|
|
63
|
+
const SELECT_COLUMNS = 'id, project_path, category, content, importance, shareable, status, expires_at, updated_at, last_seen_at, verified_at';
|
|
64
|
+
/** Magic Context's normalized-hash whitespace set, for stable digests only. */
|
|
65
|
+
export function normalizeMemoryContent(content) {
|
|
66
|
+
return content.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Directory identity, byte-for-byte compatible with Magic Context's
|
|
70
|
+
* `directoryFallback` (`project-identity.ts`): MD5 of `path.resolve(directory)`
|
|
71
|
+
* truncated to 12 hex characters.
|
|
72
|
+
*
|
|
73
|
+
* Do NOT normalize separators here: Magic Context hashes the resolved path
|
|
74
|
+
* verbatim, so on Windows the digest contains the backslashes. A
|
|
75
|
+
* forward-slash canonicalisation silently orphans every memory of a non-git
|
|
76
|
+
* project (measured: `dir:1afbff92eb86` vs a wrong `dir:931289036571` for the
|
|
77
|
+
* same directory).
|
|
78
|
+
*/
|
|
79
|
+
export function directoryIdentity(directory) {
|
|
80
|
+
const canonical = resolve(directory);
|
|
81
|
+
return `dir:${createHash('md5').update(canonical, 'utf8').digest('hex').slice(0, 12)}`;
|
|
82
|
+
}
|
|
83
|
+
/** Cache of git identities, since a directory's root commit cannot change. */
|
|
84
|
+
const gitIdentityCache = new Map();
|
|
85
|
+
/**
|
|
86
|
+
* `git:<root commit sha>` for a directory inside a git repository, matching
|
|
87
|
+
* Magic Context's primary identity. Best effort by design: a missing git, a
|
|
88
|
+
* non-repository, or an empty history yields undefined and the caller falls
|
|
89
|
+
* back to {@link directoryIdentity}. Magic Context itself retries git with a
|
|
90
|
+
* cooldown; the adapter instead caches the verdict for the process lifetime.
|
|
91
|
+
*/
|
|
92
|
+
export function gitProjectIdentity(directory, timeoutMs = 3_000) {
|
|
93
|
+
const key = resolve(directory);
|
|
94
|
+
const cached = gitIdentityCache.get(key);
|
|
95
|
+
if (cached !== undefined || gitIdentityCache.has(key))
|
|
96
|
+
return cached;
|
|
97
|
+
let identity;
|
|
98
|
+
try {
|
|
99
|
+
const result = spawnSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
|
|
100
|
+
cwd: key,
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
timeout: timeoutMs,
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
});
|
|
105
|
+
// Multi-root histories: pick the lexicographically smallest root sha so the
|
|
106
|
+
// identity is a pure function of the root-commit set (MC project-identity.ts).
|
|
107
|
+
const first = result.status === 0
|
|
108
|
+
? (result.stdout ?? '').split('\n').map(line => line.trim().slice(0, 64)).filter(line => /^[0-9a-f]{7,64}$/.test(line)).sort()[0]
|
|
109
|
+
: undefined;
|
|
110
|
+
identity = first === undefined ? undefined : `git:${first}`;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
identity = undefined;
|
|
114
|
+
}
|
|
115
|
+
gitIdentityCache.set(key, identity);
|
|
116
|
+
return identity;
|
|
117
|
+
}
|
|
118
|
+
/** The identity Magic Context would use for a directory: git when possible. */
|
|
119
|
+
export function resolveProjectIdentity(directory) {
|
|
120
|
+
return gitProjectIdentity(directory) ?? directoryIdentity(directory);
|
|
121
|
+
}
|
|
122
|
+
/** The one identity normalizer: `git:`/`dir:` identities pass through, any
|
|
123
|
+
* other string resolves as a directory. Every caller (session scope, the
|
|
124
|
+
* `ctx_memory` `project` argument, recall scoping) shares this spelling. */
|
|
125
|
+
export function resolveIdentitySpec(spec) {
|
|
126
|
+
if (spec.startsWith('git:') || spec.startsWith('dir:'))
|
|
127
|
+
return spec;
|
|
128
|
+
return resolveProjectIdentity(spec);
|
|
129
|
+
}
|
|
130
|
+
function identityCandidates(project) {
|
|
131
|
+
if (project === undefined || project.length === 0)
|
|
132
|
+
return [];
|
|
133
|
+
if (project.startsWith('git:') || project.startsWith('dir:'))
|
|
134
|
+
return [project];
|
|
135
|
+
// A directory: resolve it the way Magic Context would (git root commit, then
|
|
136
|
+
// the dir hash), and keep the raw normalized path as a legacy candidate for
|
|
137
|
+
// rows written before identities replaced paths.
|
|
138
|
+
const primary = resolveProjectIdentity(project);
|
|
139
|
+
const legacy = normalize(project).replace(/\\/g, '/');
|
|
140
|
+
return primary === legacy ? [primary] : [primary, legacy];
|
|
141
|
+
}
|
|
142
|
+
function priorityOf(row) {
|
|
143
|
+
return [
|
|
144
|
+
row.permanent ? 1 : 0,
|
|
145
|
+
row.importance,
|
|
146
|
+
Math.max(row.lastSeenAt, row.verifiedAt ?? 0),
|
|
147
|
+
row.id,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
function comparePriority(left, right) {
|
|
151
|
+
const a = priorityOf(left);
|
|
152
|
+
const b = priorityOf(right);
|
|
153
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
154
|
+
const difference = (b[index] ?? 0) - (a[index] ?? 0);
|
|
155
|
+
if (difference !== 0)
|
|
156
|
+
return difference;
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
function categoryRank(category) {
|
|
161
|
+
return memoryCategoryOrder(category);
|
|
162
|
+
}
|
|
163
|
+
/** Render one memory as a baseline line (category-tagged, MC render order). */
|
|
164
|
+
export function renderMemoryLine(row) {
|
|
165
|
+
const badge = row.permanent ? ', permanent' : '';
|
|
166
|
+
return `- [${row.category || 'MEMORY'}${badge}] ${row.content.replace(/\s+/g, ' ').trim()}`;
|
|
167
|
+
}
|
|
168
|
+
/** Render one profile row as a baseline line. */
|
|
169
|
+
export function renderProfileLine(row) {
|
|
170
|
+
return `- ${row.content.replace(/\s+/g, ' ').trim()}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Open the shared memory database read-only. Any incompatibility (missing
|
|
174
|
+
* file, unknown schema version, missing table) disables recall with a loud
|
|
175
|
+
* warning — never an exception into the turn, never a write.
|
|
176
|
+
*/
|
|
177
|
+
export function openMemoryBridge(options) {
|
|
178
|
+
const dbPath = options.dbPath ?? defaultMemoryDbPath();
|
|
179
|
+
const unavailable = (detail) => {
|
|
180
|
+
options.logger.warn(`magic-context memory bridge disabled: ${detail} (${dbPath})`);
|
|
181
|
+
return {
|
|
182
|
+
dbPath,
|
|
183
|
+
status: { ok: false, detail },
|
|
184
|
+
recall: () => undefined,
|
|
185
|
+
// No space resolution without a store: recall is off, and so is every
|
|
186
|
+
// domain-scoped read that would have used this.
|
|
187
|
+
searchSpace: () => undefined,
|
|
188
|
+
maxMemoryId: () => 0,
|
|
189
|
+
mutations: () => undefined,
|
|
190
|
+
maxMemoryMutationId: () => 0,
|
|
191
|
+
projectMemoryEpochFingerprint: () => '',
|
|
192
|
+
profileRecall: () => undefined,
|
|
193
|
+
close: () => undefined,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
if (!existsSync(dbPath))
|
|
197
|
+
return unavailable('database not found');
|
|
198
|
+
let db;
|
|
199
|
+
try {
|
|
200
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return unavailable(`cannot open (${errText(error)})`);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const versionRow = db.prepare('SELECT MAX(version) AS version FROM schema_migrations WHERE version < 10000').get();
|
|
207
|
+
const schemaVersion = versionRow?.version ?? undefined;
|
|
208
|
+
if (schemaVersion === undefined || schemaVersion === null) {
|
|
209
|
+
db.close();
|
|
210
|
+
return unavailable('schema_migrations carries no recognized version');
|
|
211
|
+
}
|
|
212
|
+
if (schemaVersion > MEMORY_SCHEMA_MAX_VERSION) {
|
|
213
|
+
db.close();
|
|
214
|
+
return unavailable(`schema v${String(schemaVersion)} is newer than the adapter understands (v${String(MEMORY_SCHEMA_MAX_VERSION)})`);
|
|
215
|
+
}
|
|
216
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'memories'").get();
|
|
217
|
+
if (table === undefined) {
|
|
218
|
+
db.close();
|
|
219
|
+
return unavailable('memories table missing');
|
|
220
|
+
}
|
|
221
|
+
const columns = new Set(db.prepare('PRAGMA table_info(memories)').all().map(row => row.name));
|
|
222
|
+
for (const required of ['id', 'project_path', 'category', 'content', 'status', 'updated_at']) {
|
|
223
|
+
if (!columns.has(required)) {
|
|
224
|
+
db.close();
|
|
225
|
+
return unavailable(`memories.${required} missing (older schema than expected)`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const selfStatement = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memories
|
|
229
|
+
WHERE project_path = ?
|
|
230
|
+
AND status IN ('active','permanent')
|
|
231
|
+
AND (expires_at IS NULL OR expires_at > ?)`);
|
|
232
|
+
const foreignStatement = db.prepare(`SELECT ${SELECT_COLUMNS} FROM memories
|
|
233
|
+
WHERE project_path = ?
|
|
234
|
+
AND status IN ('active','permanent')
|
|
235
|
+
AND (expires_at IS NULL OR expires_at > ?)
|
|
236
|
+
AND shareable = 1
|
|
237
|
+
AND scope IN ('project','ecosystem','universe')`);
|
|
238
|
+
// B1: the watermark is the VISIBILITY-qualified max — expired/archived
|
|
239
|
+
// rows and non-shared peer rows must never force a HARD rebuild when they
|
|
240
|
+
// land above it (MC freezes the max over the visible pool, not the table).
|
|
241
|
+
const maxIdStatement = db.prepare(`SELECT MAX(id) AS id FROM memories WHERE project_path = ?`
|
|
242
|
+
+ ` AND status IN ('active','permanent') AND (expires_at IS NULL OR expires_at > ?)`);
|
|
243
|
+
/** WP3: THE activation function (see MemoryBridgeOptions.domainResolver). */
|
|
244
|
+
const peersFor = (identities) => {
|
|
245
|
+
const resolve = options.domainResolver;
|
|
246
|
+
if (resolve === undefined)
|
|
247
|
+
return { members: [], peers: [] };
|
|
248
|
+
const domains = [];
|
|
249
|
+
let answered = false;
|
|
250
|
+
for (const identity of identities) {
|
|
251
|
+
const answer = resolve(identity);
|
|
252
|
+
if (answer === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
answered = true;
|
|
255
|
+
for (const domain of answer) {
|
|
256
|
+
if (!domains.some(existing => existing.identity === domain.identity))
|
|
257
|
+
domains.push(domain);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// No domain has this identity yet: no peers. The old fallback to the
|
|
261
|
+
// shared store's `workspaces` membership died with the 2026-10-13
|
|
262
|
+
// corpus cutover — the own corpus carries no workspace tables, and
|
|
263
|
+
// cross-project sharing is exclusively domain-driven.
|
|
264
|
+
if (!answered)
|
|
265
|
+
return { members: [], peers: [] };
|
|
266
|
+
const members = domains.map(domain => domain.identity).filter(identity => !identities.includes(identity));
|
|
267
|
+
if (members.length === 0)
|
|
268
|
+
return { members: [], peers: [] };
|
|
269
|
+
// The SQL filter is a single `category IN (...)`, so the effective
|
|
270
|
+
// allowlist is the union of the activated domains'. That cannot widen what
|
|
271
|
+
// a domain is allowed to share because publishing pre-checks the category
|
|
272
|
+
// against ITS OWN allowlist (core/global-domains).
|
|
273
|
+
const categories = [...new Set(domains.flatMap(domain => [...domain.shareCategories]))]
|
|
274
|
+
.sort((left, right) => left.localeCompare(right));
|
|
275
|
+
return {
|
|
276
|
+
domains: domains.map(domain => domain.identity).join('+'),
|
|
277
|
+
members,
|
|
278
|
+
shareCategories: categories,
|
|
279
|
+
peers: domains,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
// WP8: the user profile. `user_memories` is optional (older contracts lack
|
|
283
|
+
// it) and its stamp columns are probed rather than assumed, so a schema
|
|
284
|
+
// drift degrades to "no profile" instead of taking the recall path down.
|
|
285
|
+
let profileStatement;
|
|
286
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_memories'").get() !== undefined) {
|
|
287
|
+
const profileColumns = new Set(db.prepare('PRAGMA table_info(user_memories)').all().map(row => row.name));
|
|
288
|
+
if (profileColumns.has('id') && profileColumns.has('content') && profileColumns.has('status')) {
|
|
289
|
+
const promoted = profileColumns.has('promoted_at')
|
|
290
|
+
? 'promoted_at'
|
|
291
|
+
: profileColumns.has('created_at') ? 'created_at' : 'id';
|
|
292
|
+
const updated = profileColumns.has('updated_at') ? 'updated_at' : promoted;
|
|
293
|
+
profileStatement = db.prepare('SELECT id, content, ' + promoted + ' AS promoted_at, ' + updated + ' AS updated_at '
|
|
294
|
+
+ "FROM user_memories WHERE status = 'active' ORDER BY " + promoted + ' ASC, id ASC');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const toRow = (row) => ({ id: row.id,
|
|
298
|
+
projectPath: row.project_path,
|
|
299
|
+
category: row.category ?? '',
|
|
300
|
+
content: row.content,
|
|
301
|
+
importance: row.importance ?? 50,
|
|
302
|
+
shareable: row.shareable === 1,
|
|
303
|
+
permanent: row.status === 'permanent',
|
|
304
|
+
updatedAt: row.updated_at,
|
|
305
|
+
lastSeenAt: row.last_seen_at ?? row.updated_at,
|
|
306
|
+
...row.verified_at === null ? {} : { verifiedAt: row.verified_at },
|
|
307
|
+
});
|
|
308
|
+
const maxFor = (identities, now) => {
|
|
309
|
+
let highest = 0;
|
|
310
|
+
for (const identity of identities) {
|
|
311
|
+
const row = maxIdStatement.get(identity, now);
|
|
312
|
+
if (row?.id != null && row.id > highest)
|
|
313
|
+
highest = row.id;
|
|
314
|
+
}
|
|
315
|
+
return highest;
|
|
316
|
+
};
|
|
317
|
+
/** B1: the peer-side max carries the share filter (shareable + scope +
|
|
318
|
+
* category allowlist) — a hidden peer row above the watermark is not
|
|
319
|
+
* "visible", so freezing it would only produce a phantom m[1] line. */
|
|
320
|
+
const maxForeignId = (peerIdentities, categories, now) => {
|
|
321
|
+
if (peerIdentities.length === 0 || categories.length === 0)
|
|
322
|
+
return 0;
|
|
323
|
+
const placeholders = peerIdentities.map(() => '?').join(', ');
|
|
324
|
+
const categoryPlaceholders = categories.map(() => '?').join(', ');
|
|
325
|
+
const row = db.prepare(`SELECT MAX(id) AS id FROM memories WHERE project_path IN (${placeholders})`
|
|
326
|
+
+ ` AND status IN ('active','permanent') AND (expires_at IS NULL OR expires_at > ?)`
|
|
327
|
+
+ ` AND shareable = 1 AND scope IN ('project','ecosystem','universe')`
|
|
328
|
+
+ ` AND category IN (${categoryPlaceholders})`).get(...peerIdentities, now, ...categories);
|
|
329
|
+
return row?.id ?? 0;
|
|
330
|
+
};
|
|
331
|
+
/** The watermark max over the visible pool: own rows (eligible) plus peer
|
|
332
|
+
* rows (eligible AND shared). */
|
|
333
|
+
const maxVisibleId = (ownIdentities, peerIdentities, categories, now) => Math.max(maxFor(ownIdentities, now), maxForeignId(peerIdentities, categories, now));
|
|
334
|
+
// ── The non-additive change lane (`memory_mutation_log`, contract §6) ────
|
|
335
|
+
// Everything below is strictly read-only. Both tables are probed, because
|
|
336
|
+
// an older schema (or a synthetic fixture) may lack them: a missing table
|
|
337
|
+
// means "no deltas", never a failure. The same applies to the optional
|
|
338
|
+
// `memories` columns — the lane must never take the recall path down.
|
|
339
|
+
const memoryColumnNames = new Set(db.prepare('PRAGMA table_info(memories)').all().map(row => row.name));
|
|
340
|
+
const memoryColumn = (name) => memoryColumnNames.has(name) ? name : `NULL AS ${name}`;
|
|
341
|
+
let hasMutationLog = false;
|
|
342
|
+
let mutationStatement;
|
|
343
|
+
let mutationMaxStatement;
|
|
344
|
+
let epochStatement;
|
|
345
|
+
let mutationMemoryStatement;
|
|
346
|
+
try {
|
|
347
|
+
hasMutationLog = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'memory_mutation_log'").get() !== undefined;
|
|
348
|
+
const hasProjectState = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'project_state'").get() !== undefined;
|
|
349
|
+
const MUTATION_COLUMNS = 'id, project_path, mutation_type, target_memory_id, superseded_by_id, category, new_content, queued_at';
|
|
350
|
+
mutationStatement = hasMutationLog
|
|
351
|
+
? db.prepare(`SELECT ${MUTATION_COLUMNS} FROM memory_mutation_log
|
|
352
|
+
WHERE project_path = ? AND id > ? ORDER BY id ASC LIMIT ?`)
|
|
353
|
+
: undefined;
|
|
354
|
+
mutationMaxStatement = hasMutationLog
|
|
355
|
+
? db.prepare('SELECT MAX(id) AS id FROM memory_mutation_log WHERE project_path = ?')
|
|
356
|
+
: undefined;
|
|
357
|
+
epochStatement = hasProjectState
|
|
358
|
+
? db.prepare('SELECT project_memory_epoch AS epoch FROM project_state WHERE project_path = ?')
|
|
359
|
+
: undefined;
|
|
360
|
+
// The mutation payload is resolved against the *live* memory row: MC
|
|
361
|
+
// re-reads the target for `update` (a later write is the truth) and walks
|
|
362
|
+
// `superseded_by_memory_id` to the terminal replacement (§6.3-§6.4).
|
|
363
|
+
mutationMemoryStatement = db.prepare(`SELECT id, project_path, category, content, ${memoryColumn('scope')}, ${memoryColumn('shareable')}, `
|
|
364
|
+
+ `status, ${memoryColumn('expires_at')}, ${memoryColumn('superseded_by_memory_id')} `
|
|
365
|
+
+ 'FROM memories WHERE id = ?');
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
mutationStatement = undefined;
|
|
369
|
+
mutationMaxStatement = undefined;
|
|
370
|
+
epochStatement = undefined;
|
|
371
|
+
mutationMemoryStatement = undefined;
|
|
372
|
+
options.logger.warn('magic-context memory mutation lane disabled: '
|
|
373
|
+
+ `${errText(error)} (${dbPath})`);
|
|
374
|
+
}
|
|
375
|
+
const epochOf = (identity) => {
|
|
376
|
+
if (epochStatement === undefined)
|
|
377
|
+
return 0;
|
|
378
|
+
const row = epochStatement.get(identity);
|
|
379
|
+
return row?.epoch ?? 0;
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* The epoch identity of a baseline. A bare epoch integer is not enough:
|
|
383
|
+
* domain membership changes move the epoch of a *peer*, and MC compares
|
|
384
|
+
* a fingerprint that hashes every member's epoch (`workspaces.ts:312-334`).
|
|
385
|
+
*/
|
|
386
|
+
const epochFingerprint = (identities) => {
|
|
387
|
+
const pairs = [...new Set(identities)].sort().map(identity => `${identity}:${String(epochOf(identity))}`);
|
|
388
|
+
return createHash('sha1').update(pairs.join('|')).digest('hex').slice(0, 16);
|
|
389
|
+
};
|
|
390
|
+
const maxMutationFor = (identities) => {
|
|
391
|
+
if (mutationMaxStatement === undefined)
|
|
392
|
+
return 0;
|
|
393
|
+
let highest = 0;
|
|
394
|
+
for (const identity of identities) {
|
|
395
|
+
const row = mutationMaxStatement.get(identity);
|
|
396
|
+
if (row?.id != null && row.id > highest)
|
|
397
|
+
highest = row.id;
|
|
398
|
+
}
|
|
399
|
+
return highest;
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* MC's `coalesceMutations` (`storage-memory-mutation-log.ts:144-229`): one
|
|
403
|
+
* row per target memory, and a terminal state beats a later non-terminal
|
|
404
|
+
* one so an `update` queued after an `archive`/`delete`/`superseded`
|
|
405
|
+
* cannot resurrect the memory. Input arrives oldest-first.
|
|
406
|
+
*/
|
|
407
|
+
const coalesceMutations = (rows) => {
|
|
408
|
+
const byTarget = new Map();
|
|
409
|
+
for (const row of rows) {
|
|
410
|
+
const selected = byTarget.get(row.targetMemoryId);
|
|
411
|
+
if (selected === undefined) {
|
|
412
|
+
byTarget.set(row.targetMemoryId, row);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (TERMINAL_MUTATION_TYPES.has(selected.mutationType)
|
|
416
|
+
&& !TERMINAL_MUTATION_TYPES.has(row.mutationType))
|
|
417
|
+
continue;
|
|
418
|
+
byTarget.set(row.targetMemoryId, row);
|
|
419
|
+
}
|
|
420
|
+
return [...byTarget.values()].sort((left, right) => left.id - right.id);
|
|
421
|
+
};
|
|
422
|
+
const eligibleRow = (row, now) => (row.status === 'active' || row.status === 'permanent')
|
|
423
|
+
&& (row.expires_at === null || row.expires_at > now);
|
|
424
|
+
/** Own rows are always ours; a peer's row only while it is shared here. */
|
|
425
|
+
const visibleRow = (row, identities, shared) => identities.includes(row.project_path)
|
|
426
|
+
|| (row.shareable === 1
|
|
427
|
+
&& row.scope !== null
|
|
428
|
+
&& ['project', 'ecosystem', 'universe'].includes(row.scope)
|
|
429
|
+
&& row.category !== null
|
|
430
|
+
&& shared.has(row.category));
|
|
431
|
+
/**
|
|
432
|
+
* Walk `superseded_by_memory_id` to the terminal replacement (§6.3 step 4),
|
|
433
|
+
* bounded by MC's depth cap. Returns undefined when the chain is broken,
|
|
434
|
+
* cyclic, too deep, or the replacement is not currently eligible/visible —
|
|
435
|
+
* the caller then degrades `<superseded .../>` to `<removed .../>`.
|
|
436
|
+
*/
|
|
437
|
+
const resolveReplacement = (startId, identities, shared, now) => {
|
|
438
|
+
let current = startId;
|
|
439
|
+
const visited = new Set();
|
|
440
|
+
for (let depth = 0; depth < MAX_MEMORY_REPLACEMENT_DEPTH; depth += 1) {
|
|
441
|
+
if (current === undefined || visited.has(current) || mutationMemoryStatement === undefined)
|
|
442
|
+
return undefined;
|
|
443
|
+
visited.add(current);
|
|
444
|
+
const row = mutationMemoryStatement.get(current);
|
|
445
|
+
if (row === undefined)
|
|
446
|
+
return undefined;
|
|
447
|
+
if (!eligibleRow(row, now) || !visibleRow(row, identities, shared))
|
|
448
|
+
return undefined;
|
|
449
|
+
const next = row.superseded_by_memory_id;
|
|
450
|
+
if (next === null || next === undefined)
|
|
451
|
+
return row;
|
|
452
|
+
current = next;
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
};
|
|
456
|
+
const toMutationRow = (raw) => ({
|
|
457
|
+
id: raw.id,
|
|
458
|
+
projectPath: raw.project_path,
|
|
459
|
+
mutationType: raw.mutation_type,
|
|
460
|
+
targetMemoryId: raw.target_memory_id,
|
|
461
|
+
...raw.superseded_by_id === null ? {} : { supersededById: raw.superseded_by_id },
|
|
462
|
+
...raw.category === null ? {} : { category: raw.category },
|
|
463
|
+
...raw.new_content === null ? {} : { newContent: raw.new_content },
|
|
464
|
+
queuedAt: raw.queued_at,
|
|
465
|
+
});
|
|
466
|
+
/** Fold a coalesced row into the value object `<memory-updates>` renders. */
|
|
467
|
+
const toUpdateLine = (row, identities, shared, now) => {
|
|
468
|
+
const target = mutationMemoryStatement?.get(row.targetMemoryId);
|
|
469
|
+
const realCategory = row.category !== undefined && row.category.length > 0
|
|
470
|
+
&& row.category !== MEMORY_VISIBILITY_SENTINEL
|
|
471
|
+
? row.category
|
|
472
|
+
: target?.category ?? undefined;
|
|
473
|
+
// A peer's change is only rendered when its category is shared with us.
|
|
474
|
+
if (!identities.includes(row.projectPath)
|
|
475
|
+
&& !(realCategory !== undefined && shared.has(realCategory)))
|
|
476
|
+
return undefined;
|
|
477
|
+
const base = {
|
|
478
|
+
id: row.id,
|
|
479
|
+
targetMemoryId: row.targetMemoryId,
|
|
480
|
+
mutationType: row.mutationType,
|
|
481
|
+
...realCategory === undefined ? {} : { category: realCategory },
|
|
482
|
+
};
|
|
483
|
+
if (row.mutationType === 'superseded') {
|
|
484
|
+
const replacement = resolveReplacement(row.supersededById, identities, shared, now);
|
|
485
|
+
return {
|
|
486
|
+
...base,
|
|
487
|
+
...row.supersededById === undefined ? {} : { supersededById: row.supersededById },
|
|
488
|
+
replacementResolvable: replacement !== undefined,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (row.mutationType !== 'update')
|
|
492
|
+
return base;
|
|
493
|
+
// MC re-reads the live row for the update payload; `new_content` is the
|
|
494
|
+
// queued snapshot and the fallback when the row is already gone.
|
|
495
|
+
const content = target?.content ?? row.newContent;
|
|
496
|
+
if (content === undefined || content.length === 0)
|
|
497
|
+
return base;
|
|
498
|
+
return { ...base, content };
|
|
499
|
+
};
|
|
500
|
+
const readMutations = (request) => {
|
|
501
|
+
try {
|
|
502
|
+
const identities = identityCandidates(request.project);
|
|
503
|
+
const auto = peersFor(identities);
|
|
504
|
+
const peers = request.domainProjects ?? auto.members;
|
|
505
|
+
const peerIdentities = peers.flatMap(candidate => identityCandidates(candidate));
|
|
506
|
+
const allIdentities = [...identities, ...peerIdentities];
|
|
507
|
+
const shared = new Set(request.shareCategories ?? auto.shareCategories ?? DEFAULT_SHARE_CATEGORIES);
|
|
508
|
+
const limit = Math.max(1, Math.floor(request.limit ?? DEFAULT_MUTATION_PAGE_LIMIT));
|
|
509
|
+
const fingerprint = epochFingerprint(allIdentities);
|
|
510
|
+
const raw = [];
|
|
511
|
+
const seenIds = new Set();
|
|
512
|
+
for (const identity of allIdentities) {
|
|
513
|
+
if (mutationStatement === undefined)
|
|
514
|
+
break;
|
|
515
|
+
// `limit + 1` so a truncated page is detectable rather than guessed.
|
|
516
|
+
const page = mutationStatement.all(identity, request.afterId, limit + 1);
|
|
517
|
+
for (const item of page) {
|
|
518
|
+
if (seenIds.has(item.id))
|
|
519
|
+
continue;
|
|
520
|
+
seenIds.add(item.id);
|
|
521
|
+
raw.push(toMutationRow(item));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const ordered = raw.sort((left, right) => left.id - right.id);
|
|
525
|
+
const hasMore = ordered.length > limit;
|
|
526
|
+
const page = hasMore ? ordered.slice(0, limit) : ordered;
|
|
527
|
+
const cursor = page.length === 0
|
|
528
|
+
? request.afterId
|
|
529
|
+
: Math.max(request.afterId, page[page.length - 1].id);
|
|
530
|
+
const coalesced = coalesceMutations(page);
|
|
531
|
+
const updates = coalesced
|
|
532
|
+
.map(row => toUpdateLine(row, identities, shared, request.now))
|
|
533
|
+
.filter((line) => line !== undefined);
|
|
534
|
+
return {
|
|
535
|
+
updates,
|
|
536
|
+
rows: coalesced,
|
|
537
|
+
cursor,
|
|
538
|
+
currentCursor: maxMutationFor(allIdentities),
|
|
539
|
+
projectEpochFingerprint: fingerprint,
|
|
540
|
+
projectEpoch: identities[0] === undefined ? 0 : epochOf(identities[0]),
|
|
541
|
+
hasMore,
|
|
542
|
+
...identities[0] === undefined ? {} : { identity: identities[0] },
|
|
543
|
+
members: peers,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
options.logger.warn(`magic-context memory mutation read failed (continuing without deltas): `
|
|
548
|
+
+ `${errText(error)}`);
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
return {
|
|
553
|
+
dbPath,
|
|
554
|
+
status: { ok: true, schemaVersion },
|
|
555
|
+
searchSpace: (project) => {
|
|
556
|
+
try {
|
|
557
|
+
const identities = identityCandidates(project);
|
|
558
|
+
if (identities.length === 0)
|
|
559
|
+
return undefined;
|
|
560
|
+
const auto = peersFor(identities);
|
|
561
|
+
const foreignIdentities = auto.members.flatMap(candidate => identityCandidates(candidate));
|
|
562
|
+
return {
|
|
563
|
+
identity: identities[0],
|
|
564
|
+
ownIdentities: identities,
|
|
565
|
+
foreignIdentities,
|
|
566
|
+
members: auto.members,
|
|
567
|
+
...auto.domains === undefined ? {} : { domains: auto.domains },
|
|
568
|
+
...auto.shareCategories === undefined ? {} : { shareCategories: auto.shareCategories },
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
maxMemoryId: (project) => {
|
|
576
|
+
try {
|
|
577
|
+
const identities = identityCandidates(project);
|
|
578
|
+
const auto = peersFor(identities);
|
|
579
|
+
const peerIdentities = auto.members.flatMap(candidate => identityCandidates(candidate));
|
|
580
|
+
return maxVisibleId(identities, peerIdentities, auto.shareCategories ?? DEFAULT_SHARE_CATEGORIES, Date.now());
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
maxMemoryMutationId: (project) => {
|
|
587
|
+
try {
|
|
588
|
+
const identities = identityCandidates(project);
|
|
589
|
+
const peers = peersFor(identities).members.flatMap(candidate => identityCandidates(candidate));
|
|
590
|
+
return maxMutationFor([...identities, ...peers]);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
projectMemoryEpochFingerprint: (project) => {
|
|
597
|
+
try {
|
|
598
|
+
const identities = identityCandidates(project);
|
|
599
|
+
const peers = peersFor(identities).members.flatMap(candidate => identityCandidates(candidate));
|
|
600
|
+
return epochFingerprint([...identities, ...peers]);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return '';
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
mutations: readMutations,
|
|
607
|
+
profileRecall: (request) => {
|
|
608
|
+
try {
|
|
609
|
+
if (profileStatement === undefined)
|
|
610
|
+
return undefined;
|
|
611
|
+
const rows = profileStatement.all().map(row => ({
|
|
612
|
+
id: row.id,
|
|
613
|
+
content: row.content,
|
|
614
|
+
promotedAt: row.promoted_at ?? row.id,
|
|
615
|
+
updatedAt: row.updated_at ?? row.promoted_at ?? row.id,
|
|
616
|
+
}));
|
|
617
|
+
if (rows.length === 0)
|
|
618
|
+
return undefined;
|
|
619
|
+
const budget = request.budgetTokens ?? PROFILE_M0_BUDGET_TOKENS;
|
|
620
|
+
const watermark = request.maxId;
|
|
621
|
+
const pool = watermark === undefined ? rows : rows.filter(row => row.id <= watermark);
|
|
622
|
+
const additions = watermark === undefined ? [] : rows.filter(row => row.id > watermark);
|
|
623
|
+
const pick = (candidates, tokens) => {
|
|
624
|
+
const selected = [];
|
|
625
|
+
let used = 0;
|
|
626
|
+
for (const row of candidates) {
|
|
627
|
+
const cost = estimateTokens(renderProfileLine(row));
|
|
628
|
+
// `continue`, not `break`: one expensive entry must not hide the
|
|
629
|
+
// cheaper ones behind it (the same rule the memory picker keeps).
|
|
630
|
+
if (used + cost > tokens)
|
|
631
|
+
continue;
|
|
632
|
+
used += cost;
|
|
633
|
+
selected.push(row);
|
|
634
|
+
}
|
|
635
|
+
return selected;
|
|
636
|
+
};
|
|
637
|
+
const selectedPool = pick(pool, budget);
|
|
638
|
+
const selectedDelta = pick(additions, Math.max(1, Math.floor(budget * 0.25)));
|
|
639
|
+
if (selectedPool.length === 0 && selectedDelta.length === 0 && rows.length === 0)
|
|
640
|
+
return undefined;
|
|
641
|
+
const digestOf = (list) => createHash('sha1').update(list.map(row => `${String(row.id)}:${String(row.updatedAt)}`).join('|')).digest('hex');
|
|
642
|
+
return {
|
|
643
|
+
rows: selectedPool,
|
|
644
|
+
lines: selectedPool.map(renderProfileLine),
|
|
645
|
+
digest: digestOf(pool),
|
|
646
|
+
foldDigest: digestOf(rows),
|
|
647
|
+
newRows: selectedDelta,
|
|
648
|
+
newLines: selectedDelta.map(renderProfileLine),
|
|
649
|
+
currentMaxId: rows.reduce((max, row) => Math.max(max, row.id), 0),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
options.logger.warn(`magic-context profile read failed (continuing without it): ${errText(error)}`);
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
recall: (request) => {
|
|
658
|
+
try {
|
|
659
|
+
// B3 (MC single_store_reads): one deferred read transaction so the
|
|
660
|
+
// pool query, the max and the watermark observe ONE snapshot — a
|
|
661
|
+
// concurrent writer between statements must not tear the digest.
|
|
662
|
+
db.exec('BEGIN');
|
|
663
|
+
try {
|
|
664
|
+
const identities = identityCandidates(request.project);
|
|
665
|
+
const auto = peersFor(identities);
|
|
666
|
+
const rows = [];
|
|
667
|
+
const seen = new Set();
|
|
668
|
+
for (const identity of identities) {
|
|
669
|
+
for (const raw of selfStatement.all(identity, request.now)) {
|
|
670
|
+
const row = toRow(raw);
|
|
671
|
+
if (!seen.has(row.id)) {
|
|
672
|
+
seen.add(row.id);
|
|
673
|
+
rows.push(row);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const shared = new Set(request.shareCategories ?? auto.shareCategories ?? DEFAULT_SHARE_CATEGORIES);
|
|
678
|
+
const peers = request.domainProjects ?? auto.members;
|
|
679
|
+
const peerIdentities = peers.flatMap(candidate => identityCandidates(candidate));
|
|
680
|
+
for (const identity of peerIdentities) {
|
|
681
|
+
for (const raw of foreignStatement.all(identity, request.now)) {
|
|
682
|
+
const row = toRow(raw);
|
|
683
|
+
if (seen.has(row.id))
|
|
684
|
+
continue;
|
|
685
|
+
if (identities.includes(row.projectPath))
|
|
686
|
+
continue;
|
|
687
|
+
if (!shared.has(row.category))
|
|
688
|
+
continue;
|
|
689
|
+
seen.add(row.id);
|
|
690
|
+
rows.push(row);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const context = {
|
|
694
|
+
...identities[0] === undefined ? {} : { identity: identities[0] },
|
|
695
|
+
...auto.domains === undefined ? {} : { domains: auto.domains },
|
|
696
|
+
members: peers,
|
|
697
|
+
};
|
|
698
|
+
if (rows.length === 0)
|
|
699
|
+
return undefined;
|
|
700
|
+
const needle = request.query?.trim().toLowerCase();
|
|
701
|
+
const filtered = needle === undefined || needle.length === 0
|
|
702
|
+
? rows
|
|
703
|
+
: rows.filter(row => row.content.toLowerCase().includes(needle) || row.category.toLowerCase().includes(needle));
|
|
704
|
+
if (filtered.length === 0)
|
|
705
|
+
return undefined;
|
|
706
|
+
// S(t) decay scoring (schema v7): retired rows drop out of the
|
|
707
|
+
// injection pool but stay in eligibleIds — searchable, and
|
|
708
|
+
// `<memory-updates>` pointers for them stay resolvable. The picker
|
|
709
|
+
// prefers hotter rows (score desc, MC priority as the tiebreak);
|
|
710
|
+
// the RENDER order below stays MC's category order, so the injected
|
|
711
|
+
// block still reads like the OpenCode/Pi block.
|
|
712
|
+
const scoring = request.scoring;
|
|
713
|
+
const scoringActive = scoring !== undefined && scoring.config.enabled;
|
|
714
|
+
const retiredIds = new Set();
|
|
715
|
+
const scoreOf = (row) => {
|
|
716
|
+
if (scoring === undefined)
|
|
717
|
+
return 0;
|
|
718
|
+
const stat = scoring.statsById.get(row.id);
|
|
719
|
+
return scoreMemory({
|
|
720
|
+
importance: row.importance,
|
|
721
|
+
category: row.category,
|
|
722
|
+
hits: stat?.hits ?? 0,
|
|
723
|
+
...stat?.lastHitAt === undefined ? {} : { lastHitAt: stat.lastHitAt },
|
|
724
|
+
}, scoring.config, request.now);
|
|
725
|
+
};
|
|
726
|
+
if (scoringActive && scoring !== undefined) {
|
|
727
|
+
for (const row of filtered) {
|
|
728
|
+
const stat = scoring.statsById.get(row.id);
|
|
729
|
+
if (stat?.localArchived === true || scoreOf(row) < scoring.config.archiveThreshold) {
|
|
730
|
+
retiredIds.add(row.id);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// The additive lane: rows above the stored watermark are in-session
|
|
735
|
+
// additions and render in m[1], so they never disturb the m[0] pool
|
|
736
|
+
// digest below (Magic Context's maxMemoryId contract).
|
|
737
|
+
const watermark = request.maxMemoryId;
|
|
738
|
+
const pool = watermark === undefined ? filtered : filtered.filter(row => row.id <= watermark);
|
|
739
|
+
const additions = watermark === undefined ? [] : filtered.filter(row => row.id > watermark);
|
|
740
|
+
const pick = (candidates, budgetTokens) => {
|
|
741
|
+
// Priority order picks the budget (S(t) score desc first when the
|
|
742
|
+
// decay scoring is on, MC priority as the tiebreak); the render
|
|
743
|
+
// order is MC's category order, so the injected block reads like
|
|
744
|
+
// the OpenCode/Pi block.
|
|
745
|
+
const usable = scoringActive
|
|
746
|
+
? candidates.filter(row => !retiredIds.has(row.id))
|
|
747
|
+
: candidates;
|
|
748
|
+
const selected = [];
|
|
749
|
+
let used = 0;
|
|
750
|
+
const ranked = scoringActive
|
|
751
|
+
? [...usable].sort((left, right) => scoreOf(right) - scoreOf(left) || comparePriority(left, right))
|
|
752
|
+
: [...usable].sort(comparePriority);
|
|
753
|
+
for (const row of ranked) {
|
|
754
|
+
if (selected.length >= request.limit)
|
|
755
|
+
break;
|
|
756
|
+
const cost = estimateTokens(renderMemoryLine(row));
|
|
757
|
+
if (used + cost > budgetTokens)
|
|
758
|
+
continue;
|
|
759
|
+
used += cost;
|
|
760
|
+
selected.push(row);
|
|
761
|
+
}
|
|
762
|
+
return selected;
|
|
763
|
+
};
|
|
764
|
+
const poolSelected = pick(pool, request.budgetTokens);
|
|
765
|
+
// MC budgets the additions at 25% of the memory injection budget.
|
|
766
|
+
const deltaSelected = pick(additions, Math.max(1, Math.floor(request.budgetTokens * 0.25)));
|
|
767
|
+
if (poolSelected.length === 0 && deltaSelected.length === 0)
|
|
768
|
+
return undefined;
|
|
769
|
+
const order = (selected) => [...selected].sort((left, right) => categoryRank(left.category) - categoryRank(right.category) || right.updatedAt - left.updatedAt);
|
|
770
|
+
const orderedPool = order(poolSelected);
|
|
771
|
+
const orderedAdditions = order(deltaSelected);
|
|
772
|
+
// Digest covers the m[0] pool ONLY: a new memory may not bust the cache.
|
|
773
|
+
const digest = createHash('sha1')
|
|
774
|
+
.update(orderedPool.map(row => `${String(row.id)}:${String(row.updatedAt)}`).join('|'))
|
|
775
|
+
.digest('hex');
|
|
776
|
+
// B1: visibility-qualified max (own eligible + peers eligible AND shared).
|
|
777
|
+
const currentMaxMemoryId = maxVisibleId(identities, peerIdentities, [...shared], request.now);
|
|
778
|
+
return {
|
|
779
|
+
rows: orderedPool,
|
|
780
|
+
lines: orderedPool.map(renderMemoryLine),
|
|
781
|
+
digest,
|
|
782
|
+
newRows: orderedAdditions,
|
|
783
|
+
newLines: orderedAdditions.map(renderMemoryLine),
|
|
784
|
+
currentMaxMemoryId,
|
|
785
|
+
eligibleIds: rows.map(row => row.id),
|
|
786
|
+
eligibleRows: rows,
|
|
787
|
+
...context,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
// COMMIT ends the read snapshot; a failed body leaves the tx
|
|
792
|
+
// aborted and the END is a no-op (readonly — nothing to roll back).
|
|
793
|
+
try {
|
|
794
|
+
db.exec('COMMIT');
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
/* aborted transaction */
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
options.logger.warn(`magic-context memory recall failed (continuing without memories): ${errText(error)}`);
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
close: () => db.close(),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
try {
|
|
811
|
+
db.close();
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
/* best effort */
|
|
815
|
+
}
|
|
816
|
+
return unavailable(`probe failed (${errText(error)})`);
|
|
817
|
+
}
|
|
818
|
+
}
|