@elvisw/dsh-magic-context 0.0.0-bootstrap.0 → 0.0.0-stage
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/README.md +2 -452
- package/package.json +4 -108
- package/LICENSE +0 -23
- package/NOTICE +0 -93
- package/README.zh-CN.md +0 -368
- package/cordis.patch.yml +0 -20
- package/lib/client.js +0 -2270
- package/lib/core/compartments.js +0 -424
- package/lib/core/corpus-store.js +0 -176
- package/lib/core/directive-detect.js +0 -147
- package/lib/core/domain-reconcile.js +0 -394
- package/lib/core/domains.js +0 -127
- package/lib/core/embedding-local.js +0 -247
- package/lib/core/global-autostage.js +0 -223
- package/lib/core/global-domains.js +0 -302
- package/lib/core/journal.js +0 -248
- package/lib/core/llm-deadline.js +0 -53
- package/lib/core/machine-domain.js +0 -106
- package/lib/core/maintenance.js +0 -380
- package/lib/core/memory-bridge.js +0 -818
- package/lib/core/memory-categories.js +0 -84
- package/lib/core/memory-scoring.js +0 -65
- package/lib/core/memory-search.js +0 -414
- package/lib/core/memory-write.js +0 -1176
- package/lib/core/policy.js +0 -111
- package/lib/core/profile-confirmation.js +0 -55
- package/lib/core/profile-review.js +0 -375
- package/lib/core/profile-write.js +0 -283
- package/lib/core/reference-seeds.js +0 -63
- package/lib/core/route-health.js +0 -106
- package/lib/core/stores.js +0 -1021
- package/lib/core/tagger.js +0 -7
- package/lib/core/types.js +0 -15
- package/lib/core/vector-backfill.js +0 -140
- package/lib/core/vector-lane.js +0 -184
- package/lib/dsh/command-surface.js +0 -376
- package/lib/dsh/compaction-engine.js +0 -231
- package/lib/dsh/config-switches.js +0 -56
- package/lib/dsh/config.js +0 -13
- package/lib/dsh/context-pressure.js +0 -36
- package/lib/dsh/drop-landing.js +0 -42
- package/lib/dsh/emergency.js +0 -89
- package/lib/dsh/flush-command.js +0 -148
- package/lib/dsh/global-review.js +0 -412
- package/lib/dsh/historian.js +0 -907
- package/lib/dsh/live-journal.js +0 -81
- package/lib/dsh/maintenance-wiring.js +0 -229
- package/lib/dsh/management-pass.js +0 -564
- package/lib/dsh/memory-lane.js +0 -535
- package/lib/dsh/pre-step.js +0 -675
- package/lib/dsh/project-drift.js +0 -116
- package/lib/dsh/recomp.js +0 -192
- package/lib/dsh/reduce-guidance.js +0 -188
- package/lib/dsh/seams.js +0 -588
- package/lib/dsh/search-hint.js +0 -186
- package/lib/dsh/session-notice.js +0 -100
- package/lib/dsh/status-command.js +0 -75
- package/lib/dsh/telemetry.js +0 -349
- package/lib/dsh/tool-bridge.js +0 -1009
- package/lib/dsh/volatile.js +0 -89
- package/lib/index.js +0 -931
- package/tools/migrate-profile-package-name.mjs +0 -317
- package/vendor/onnx/LICENSE +0 -27
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +0 -59
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnx/ort.node.min.mjs +0 -8
package/lib/core/memory-write.js
DELETED
|
@@ -1,1176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The memory WRITE path — the other half of Magic Context's memory feature.
|
|
3
|
-
*
|
|
4
|
-
* This module writes into Magic Context's shared `context.db`, so every
|
|
5
|
-
* statement and default here is a replication of MC's storage contract, not an
|
|
6
|
-
* invention. The contract (verified against `packages/plugin/src/features/
|
|
7
|
-
* magic-context/memory/promotion.ts`, `storage-memory.ts` and the live v94
|
|
8
|
-
* schema) is:
|
|
9
|
-
*
|
|
10
|
-
* - identity: `git:<root-commit-sha>` / `dir:<md5(path.resolve(dir))[:12]>`;
|
|
11
|
-
* - hash: `md5(content.toLowerCase().replace(/\s+/g,' ').trim())`, the
|
|
12
|
-
* dedup key is `UNIQUE(project_path, category, normalized_hash)`;
|
|
13
|
-
* - a duplicate fact only bumps `seen_count` + `last_seen_at`/`updated_at`
|
|
14
|
-
* (never resurrects an archived row, because the UNIQUE covers all statuses);
|
|
15
|
-
* - a new row is `importance = 50`, `scope = 'project'`, `shareable = 0`,
|
|
16
|
-
* `source_type = 'historian'`, `status = 'active'`, `verification_status =
|
|
17
|
-
* 'unverified'`, `seen_count = 1`, `retrieval_count = 0`, all four
|
|
18
|
-
* timestamps equal to `now`, and `expires_at` only for the TTL categories;
|
|
19
|
-
* - promotion is a PURE APPEND: it writes neither `memory_mutation_log` nor
|
|
20
|
-
* `project_memory_epoch`. New rows become visible through the
|
|
21
|
-
* `maxMemoryId` watermark in m[1], never by rebuilding m[0].
|
|
22
|
-
*
|
|
23
|
-
* Deliberate divergence, recorded for the review: MC promotes inside the
|
|
24
|
-
* compartment publish transaction, so a failed promotion rolls the publish
|
|
25
|
-
* back. Our compartments are already durable when promotion runs, so we
|
|
26
|
-
* fail-open, journal the error, and let a later pass retry from the stored
|
|
27
|
-
* facts — reclaim must not stall on a memory bug, and no fact is lost.
|
|
28
|
-
*
|
|
29
|
-
* The agent write path below (`writeMemory` / `updateMemory` / `archiveMemory`
|
|
30
|
-
* / `mergeMemory` / `getMemory` / `listMemories`) replicates §11 of the same
|
|
31
|
-
* contract and adds the non-additive machinery promotion deliberately omits:
|
|
32
|
-
* `memory_mutation_log` deltas for `update`/`archive`/`superseded` and the
|
|
33
|
-
* §4.5 clear-on-content-change invariants. It never bumps
|
|
34
|
-
* `project_memory_epoch` (contract §5.2: in-session mutations route through
|
|
35
|
-
* m[1]) and never touches FTS or the embedding tables beyond invalidation.
|
|
36
|
-
* (The §8.4 module-authority guard was retired with the 2026-10-13 corpus
|
|
37
|
-
* cutover — no authority tables exist in the own corpus.)
|
|
38
|
-
*/
|
|
39
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
40
|
-
import { createHash } from 'node:crypto';
|
|
41
|
-
import { existsSync } from 'node:fs';
|
|
42
|
-
import { MEMORY_SCHEMA_MAX_VERSION, normalizeMemoryContent } from "./memory-bridge.js";
|
|
43
|
-
import { V2_MEMORY_CATEGORIES, isPromotableCategory, resolveExpiresAt, resolveHistorianExpiresAt } from "./memory-categories.js";
|
|
44
|
-
import { errText } from "./types.js";
|
|
45
|
-
/** MC's `normalize-hash.ts`: md5 of the whitespace/case-normalized content. */
|
|
46
|
-
export function normalizedMemoryHash(content) {
|
|
47
|
-
return createHash('md5').update(normalizeMemoryContent(content), 'utf8').digest('hex');
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Content identity for remembered verify SKIPS (`skip` = "cannot judge from the
|
|
51
|
-
* text alone"). Deliberately sha256 over the EXACT content rather than the dedup
|
|
52
|
-
* md5: a skip must expire the moment the text changes in any way (a `stale`
|
|
53
|
-
* rewrite, a human edit, a re-promotion), and the dedup key normalizes
|
|
54
|
-
* whitespace/case, which would let a cosmetic edit look like the same row. The
|
|
55
|
-
* digest is truncated to 32 hex chars — this is an equality token, not a
|
|
56
|
-
* security boundary.
|
|
57
|
-
*/
|
|
58
|
-
export function memoryContentHash(content) {
|
|
59
|
-
return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 32);
|
|
60
|
-
}
|
|
61
|
-
/** The v2 taxonomy is the only category set the agent write path accepts (§3.1). */
|
|
62
|
-
export function isWritableMemoryCategory(category) {
|
|
63
|
-
return typeof category === 'string' && V2_MEMORY_CATEGORIES.includes(category);
|
|
64
|
-
}
|
|
65
|
-
const INSERT_MEMORY = `
|
|
66
|
-
INSERT INTO memories(
|
|
67
|
-
project_path, category, content, normalized_hash, importance, scope, shareable,
|
|
68
|
-
source_session_id, source_type, seen_count, retrieval_count,
|
|
69
|
-
first_seen_at, created_at, updated_at, last_seen_at,
|
|
70
|
-
status, expires_at, verification_status
|
|
71
|
-
) VALUES(?, ?, ?, ?, 50, 'project', 0, ?, 'historian', 1, 0, ?, ?, ?, ?, 'active', ?, 'unverified')
|
|
72
|
-
`;
|
|
73
|
-
/**
|
|
74
|
-
* Rolling window for {@link HISTORIAN_DAILY_CAP}: 24 hours of wall clock, not a
|
|
75
|
-
* calendar day — no timezone, no reset hour, nothing to get wrong.
|
|
76
|
-
*/
|
|
77
|
-
const HISTORIAN_DAILY_CAP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
78
|
-
/**
|
|
79
|
-
* Soft ceiling on NEW historian rows per rolling 24h (user decision
|
|
80
|
-
* 2026-10-16). Measured inflow was ~120/day with a 166 peak, so this is a
|
|
81
|
-
* guard against runaway extraction, not a throttle on normal operation: the
|
|
82
|
-
* excess is skipped for THIS pass with `reason: 'daily-cap'` in the promotion
|
|
83
|
-
* journal row (the facts are still in the compartment, and the next pass
|
|
84
|
-
* re-promotes idempotently). Re-encounters of an existing hash are free — they
|
|
85
|
-
* insert nothing.
|
|
86
|
-
*/
|
|
87
|
-
const HISTORIAN_DAILY_CAP = 150;
|
|
88
|
-
/**
|
|
89
|
-
* WP9/WP10 — a DOMAIN memory: owned by a synthetic domain identity and readable
|
|
90
|
-
* by every peer project.
|
|
91
|
-
*
|
|
92
|
-
* The two columns that make it visible are written explicitly:
|
|
93
|
-
* `scope = 'universe'` and `shareable = 1`. Every other insert path here leaves
|
|
94
|
-
* them to the schema (`'project'` / `0`), which is correct for a project fact
|
|
95
|
-
* and would make a domain row invisible to the very projects it exists for —
|
|
96
|
-
* published, and seen by nobody.
|
|
97
|
-
*
|
|
98
|
-
* `expires_at` is NULL on purpose. The contract's TTL categories are the two
|
|
99
|
-
* legacy ones; organisation knowledge does not rot, and a domain row that
|
|
100
|
-
* silently expired would take its visibility with it.
|
|
101
|
-
*
|
|
102
|
-
* `source_type = 'user'` records that a HUMAN published it (the approval gate),
|
|
103
|
-
* not the historian: MC reserves the distinction and it is the only way to tell
|
|
104
|
-
* afterwards which rows were machine-derived.
|
|
105
|
-
*/
|
|
106
|
-
const INSERT_DOMAIN_MEMORY = `
|
|
107
|
-
INSERT INTO memories(
|
|
108
|
-
project_path, category, content, normalized_hash, importance, scope, shareable,
|
|
109
|
-
source_session_id, source_type, seen_count, retrieval_count,
|
|
110
|
-
first_seen_at, created_at, updated_at, last_seen_at,
|
|
111
|
-
status, expires_at, verification_status
|
|
112
|
-
) VALUES(?, ?, ?, ?, ?, 'universe', 1, ?, 'user', 1, 0, ?, ?, ?, ?, 'active', NULL, 'unverified')
|
|
113
|
-
`;
|
|
114
|
-
/**
|
|
115
|
-
* MC's `getMemorySelectColumns` (§11.1), verbatim column list and aliases.
|
|
116
|
-
* Every reader/writer below projects through this so a row always comes back
|
|
117
|
-
* in the same shape the storage layer would return.
|
|
118
|
-
*/
|
|
119
|
-
const MEMORY_SELECT_COLUMNS = `
|
|
120
|
-
memories.id AS id,
|
|
121
|
-
memories.project_path AS projectPath,
|
|
122
|
-
memories.category AS category,
|
|
123
|
-
memories.content AS content,
|
|
124
|
-
memories.normalized_hash AS normalizedHash,
|
|
125
|
-
COALESCE(memories.importance, 50) AS importance,
|
|
126
|
-
COALESCE(memories.scope, 'project') AS scope,
|
|
127
|
-
COALESCE(memories.shareable, 0) AS shareable,
|
|
128
|
-
memories.source_session_id AS sourceSessionId,
|
|
129
|
-
memories.source_type AS sourceType,
|
|
130
|
-
memories.seen_count AS seenCount,
|
|
131
|
-
memories.retrieval_count AS retrievalCount,
|
|
132
|
-
memories.first_seen_at AS firstSeenAt,
|
|
133
|
-
memories.created_at AS createdAt,
|
|
134
|
-
memories.updated_at AS updatedAt,
|
|
135
|
-
memories.last_seen_at AS lastSeenAt,
|
|
136
|
-
memories.last_retrieved_at AS lastRetrievedAt,
|
|
137
|
-
memories.status AS status,
|
|
138
|
-
memories.expires_at AS expiresAt,
|
|
139
|
-
memories.verification_status AS verificationStatus,
|
|
140
|
-
memories.verified_at AS verifiedAt,
|
|
141
|
-
memories.superseded_by_memory_id AS supersededByMemoryId,
|
|
142
|
-
memories.merged_from AS mergedFrom,
|
|
143
|
-
memories.metadata_json AS metadataJson
|
|
144
|
-
`;
|
|
145
|
-
/** The 21-value agent INSERT (§11.1): 13 bound parameters, the rest literals. */
|
|
146
|
-
const INSERT_AGENT_MEMORY = `
|
|
147
|
-
INSERT INTO memories
|
|
148
|
-
(project_path, category, content, normalized_hash, importance,
|
|
149
|
-
source_session_id, source_type, seen_count, retrieval_count,
|
|
150
|
-
first_seen_at, created_at, updated_at, last_seen_at, last_retrieved_at,
|
|
151
|
-
status, expires_at, verification_status, verified_at,
|
|
152
|
-
superseded_by_memory_id, merged_from, metadata_json)
|
|
153
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?, NULL,
|
|
154
|
-
'active', ?, 'unverified', NULL, NULL, NULL, ?)
|
|
155
|
-
`;
|
|
156
|
-
/**
|
|
157
|
-
* `seen_count` bump for content duplicates. `COALESCE` matches the Rust
|
|
158
|
-
* facade's NULL-tolerant form (contract §9.2); the promotion statement above
|
|
159
|
-
* keeps MC's literal `seen_count + 1` untouched.
|
|
160
|
-
*/
|
|
161
|
-
const BUMP_SEEN_COUNT = `
|
|
162
|
-
UPDATE memories SET seen_count = COALESCE(seen_count, 0) + 1, last_seen_at = ?, updated_at = ? WHERE id = ?
|
|
163
|
-
`;
|
|
164
|
-
/**
|
|
165
|
-
* Explicit-retrieval telemetry (contract §9.1).
|
|
166
|
-
*
|
|
167
|
-
* DELIBERATE DIVERGENCE from MC's `updateMemoryRetrievalCount` statement,
|
|
168
|
-
* which also writes `updated_at = ?`. Here `updated_at` is load-bearing:
|
|
169
|
-
* `memory-bridge.ts` composes the m[0] pool digest from `id:updated_at` and
|
|
170
|
-
* renders the pool in `updated_at DESC` order, so bumping it on every read
|
|
171
|
-
* would rebuild the cached prefix and reorder the injected block — precisely
|
|
172
|
-
* the cache break the m[0]/m[1] split exists to prevent. MC's own comment
|
|
173
|
-
* calls this counter "telemetry, not correctness", so the counter and
|
|
174
|
-
* `last_retrieved_at` move while `updated_at` deliberately does not.
|
|
175
|
-
*/
|
|
176
|
-
const BUMP_RETRIEVAL_COUNT = `
|
|
177
|
-
UPDATE memories SET retrieval_count = COALESCE(retrieval_count, 0) + 1, last_retrieved_at = ? WHERE id = ?
|
|
178
|
-
`;
|
|
179
|
-
/** Ownership probe for id-addressed writes (§8.1 OWNED). */
|
|
180
|
-
const SELECT_OWNER = 'SELECT project_path AS projectPath FROM memories WHERE id = ?';
|
|
181
|
-
/**
|
|
182
|
-
* `update` (§11.3 tool-layer content/category, Rust facade's connected
|
|
183
|
-
* clears) plus `importance` so the tool layer can re-rank a memory. The
|
|
184
|
-
* `shareable = 0` reset is MC's fail-closed invariant (§4.3): the classify
|
|
185
|
-
* judgement was scored against the old content.
|
|
186
|
-
*/
|
|
187
|
-
const UPDATE_MEMORY_CONTENT = `
|
|
188
|
-
UPDATE memories
|
|
189
|
-
SET content = ?, category = ?, normalized_hash = ?, updated_at = ?,
|
|
190
|
-
importance = COALESCE(?, importance),
|
|
191
|
-
shareable = 0, classified_at = NULL,
|
|
192
|
-
mural_cue = NULL, mural_cue_hash = NULL, mural_cue_at = NULL,
|
|
193
|
-
mural_cue_rejection_count = 0
|
|
194
|
-
WHERE id = ?
|
|
195
|
-
`;
|
|
196
|
-
const ARCHIVE_MEMORY = `
|
|
197
|
-
UPDATE memories SET status = 'archived', updated_at = ? WHERE id = ?
|
|
198
|
-
`;
|
|
199
|
-
const ARCHIVE_MEMORY_WITH_REASON = `
|
|
200
|
-
UPDATE memories SET status = 'archived', metadata_json = ?, updated_at = ? WHERE id = ?
|
|
201
|
-
`;
|
|
202
|
-
/** Merge/archive source that is being replaced by another memory (§11.4). */
|
|
203
|
-
const SUPERSEDE_MEMORY = `
|
|
204
|
-
UPDATE memories
|
|
205
|
-
SET status = 'archived', superseded_by_memory_id = ?,
|
|
206
|
-
metadata_json = COALESCE(?, metadata_json), updated_at = ?
|
|
207
|
-
WHERE id = ?
|
|
208
|
-
`;
|
|
209
|
-
/** Classify scoring (metadata only — see the interface note): the importance
|
|
210
|
-
* score plus the `classified_at` stamp, ownership- and status-scoped. */
|
|
211
|
-
const CLASSIFY_MEMORY = `
|
|
212
|
-
UPDATE memories SET importance = ?, classified_at = ?
|
|
213
|
-
WHERE project_path = ? AND id = ? AND status IN ('active','permanent')
|
|
214
|
-
`;
|
|
215
|
-
/** Merge target (canonical) aggregation (§11.4). */
|
|
216
|
-
const MERGE_MEMORY_TARGET = `
|
|
217
|
-
UPDATE memories
|
|
218
|
-
SET content = ?, normalized_hash = ?, seen_count = ?, retrieval_count = ?,
|
|
219
|
-
merged_from = ?, status = ?, updated_at = ?, shareable = 0, classified_at = NULL
|
|
220
|
-
WHERE id = ?
|
|
221
|
-
`;
|
|
222
|
-
/** The mutation-log primitive (§6.2): `update` / `archive` / `superseded`. */
|
|
223
|
-
const APPEND_MUTATION = `
|
|
224
|
-
INSERT INTO memory_mutation_log
|
|
225
|
-
(project_path, mutation_type, target_memory_id, superseded_by_id, category, new_content, queued_at)
|
|
226
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
227
|
-
`;
|
|
228
|
-
/**
|
|
229
|
-
* The restore half of §4.3 (WP4 — this adapter becomes the owner of sharing).
|
|
230
|
-
*
|
|
231
|
-
* MC clears `shareable` on every content change, because its classify judgement
|
|
232
|
-
* was scored against the old text. That is coherent while MC's classify task
|
|
233
|
-
* owns the flag and will re-score the row. Once this adapter owns sharing,
|
|
234
|
-
* nothing re-scores it: a rewritten org memory would drop out of every other
|
|
235
|
-
* project's view, and a rewritten shared fact would quietly stop crossing
|
|
236
|
-
* projects — a withdrawal nobody asked for.
|
|
237
|
-
*
|
|
238
|
-
* `scope` needs no restore: neither reset statement touches it. The
|
|
239
|
-
* `AND shareable = 0` guard only keeps the write a no-op when something already
|
|
240
|
-
* put the value back; the DECISION to restore is made before the mutation runs
|
|
241
|
-
* (see {@link sharingToRestore}), never by the guard.
|
|
242
|
-
*/
|
|
243
|
-
const RESTORE_SHARING = 'UPDATE memories SET shareable = ? WHERE id = ? AND shareable = 0';
|
|
244
|
-
/**
|
|
245
|
-
* Whether a content mutation must put a row's sharing back, and to what value.
|
|
246
|
-
*
|
|
247
|
-
* One-directional on purpose: a row that WAS shared (`shareable = 1`) keeps its
|
|
248
|
-
* sharing; a row that was not is never promoted. The reverse direction is the
|
|
249
|
-
* one that leaks — an edit must never be able to widen visibility.
|
|
250
|
-
*
|
|
251
|
-
* `shareable` is read through the §11.1 projection, which COALESCEs a legacy
|
|
252
|
-
* NULL to 0, so a NULL row is treated as private rather than unknown.
|
|
253
|
-
*/
|
|
254
|
-
export function sharingToRestore(row) {
|
|
255
|
-
if (row === null || row === undefined)
|
|
256
|
-
return undefined;
|
|
257
|
-
return row.shareable === 1 ? 1 : undefined;
|
|
258
|
-
}
|
|
259
|
-
/** `list` (§11.6): injectable rows of one project, in MC's render order. */
|
|
260
|
-
const listMemoriesSql = (unverifiedOnly, recurrenceFirst = false) => `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories
|
|
261
|
-
WHERE project_path = ? AND status IN ('active','permanent') AND (expires_at IS NULL OR expires_at > ?)`
|
|
262
|
-
+ (unverifiedOnly ? ` AND verification_status = 'unverified'` : '')
|
|
263
|
-
+ (recurrenceFirst
|
|
264
|
-
? // Sweep-internal order, maintenance only — the §11.6 render order above is
|
|
265
|
-
// untouched. A fact re-derived across sessions (seen_count ≥ 2) is the
|
|
266
|
-
// strongest keep-evidence short of the verdict itself, so the sweep
|
|
267
|
-
// adjudicates it first.
|
|
268
|
-
' ORDER BY COALESCE(seen_count, 0) >= 2 DESC, category ASC, updated_at DESC, id ASC'
|
|
269
|
-
: ' ORDER BY category ASC, updated_at DESC, id ASC');
|
|
270
|
-
const LIST_MEMORIES = listMemoriesSql(false);
|
|
271
|
-
/** Candidates for the maintenance sweep: the ORDER BY window stays the same,
|
|
272
|
-
* but the unverified filter is applied BEFORE the limit, so verification
|
|
273
|
-
* progress always advances through the whole project — and recurrence jumps
|
|
274
|
-
* the queue (see listMemoriesSql). */
|
|
275
|
-
const LIST_MEMORIES_UNVERIFIED = listMemoriesSql(true, true);
|
|
276
|
-
// (The 2026-10-13 corpus cutover removed the module-managed authority guard:
|
|
277
|
-
// the own corpus carries no authority tables and no Rust module exists —
|
|
278
|
-
// the writer's §8.4 yield/abort translation went with it.)
|
|
279
|
-
/**
|
|
280
|
-
* Merge an `archive_reason` into `metadata_json` exactly like MC's
|
|
281
|
-
* `mergeMemoryMetadata` (§11.5): a malformed object is replaced by a fresh one.
|
|
282
|
-
*/
|
|
283
|
-
function mergeArchiveMetadata(raw, reason) {
|
|
284
|
-
let base = {};
|
|
285
|
-
if (raw !== null) {
|
|
286
|
-
try {
|
|
287
|
-
const parsed = JSON.parse(raw);
|
|
288
|
-
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
289
|
-
base = parsed;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
// A malformed object is discarded, not propagated.
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
return JSON.stringify({ ...base, archive_reason: reason });
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Merge a remembered verify skip into `metadata_json` (`{verify_skip:{hash,at}}`),
|
|
300
|
-
* exactly like {@link mergeArchiveMetadata}: a malformed object is replaced by a
|
|
301
|
-
* fresh one rather than propagated. A skip is bookkeeping, so it must never
|
|
302
|
-
* break the row it annotates.
|
|
303
|
-
*/
|
|
304
|
-
function mergeVerifySkipMetadata(raw, contentHash, at) {
|
|
305
|
-
let base = {};
|
|
306
|
-
if (raw !== null) {
|
|
307
|
-
try {
|
|
308
|
-
const parsed = JSON.parse(raw);
|
|
309
|
-
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
310
|
-
base = parsed;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// A malformed object is discarded, not propagated.
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return JSON.stringify({ ...base, verify_skip: { hash: contentHash, at } });
|
|
318
|
-
}
|
|
319
|
-
/** The remembered skip hash in `metadata_json`, when one is well-formed. */
|
|
320
|
-
function parseVerifySkipHash(raw) {
|
|
321
|
-
if (raw === null)
|
|
322
|
-
return undefined;
|
|
323
|
-
try {
|
|
324
|
-
const parsed = JSON.parse(raw);
|
|
325
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
326
|
-
return undefined;
|
|
327
|
-
const skip = parsed.verify_skip;
|
|
328
|
-
if (typeof skip !== 'object' || skip === null || Array.isArray(skip))
|
|
329
|
-
return undefined;
|
|
330
|
-
const hash = skip.hash;
|
|
331
|
-
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
|
|
332
|
-
}
|
|
333
|
-
catch {
|
|
334
|
-
return undefined;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
/** Parse a `merged_from` JSON array of ids, tolerating NULL/malformed values. */ function parseMergedFrom(raw) {
|
|
338
|
-
if (raw === null)
|
|
339
|
-
return [];
|
|
340
|
-
try {
|
|
341
|
-
const parsed = JSON.parse(raw);
|
|
342
|
-
if (!Array.isArray(parsed))
|
|
343
|
-
return [];
|
|
344
|
-
return parsed.filter((value) => typeof value === 'number' && Number.isInteger(value));
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
return [];
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
/**
|
|
351
|
-
* `StatementSync` hands back raw SQL rows (`Record<string, SQLOutputValue>`);
|
|
352
|
-
* `MEMORY_SELECT_COLUMNS` pins the shape, which TypeScript cannot see through
|
|
353
|
-
* that record type, so every read funnels through these two casts.
|
|
354
|
-
*/
|
|
355
|
-
function asMemoryRecord(row) {
|
|
356
|
-
return row;
|
|
357
|
-
}
|
|
358
|
-
function asMemoryRecords(rows) {
|
|
359
|
-
return rows;
|
|
360
|
-
}
|
|
361
|
-
/**
|
|
362
|
-
* Open the memory corpus read-write. Missing file / newer schema disable
|
|
363
|
-
* writing with a warning instead of throwing: the adapter must never break a
|
|
364
|
-
* turn because memory is unavailable.
|
|
365
|
-
*/
|
|
366
|
-
export function openMemoryWriter(options) {
|
|
367
|
-
const now = options.now ?? Date.now;
|
|
368
|
-
const unavailable = (detail) => {
|
|
369
|
-
options.logger.warn(`magic-context memory writer disabled: ${detail} (${options.dbPath})`);
|
|
370
|
-
const refused = { ok: false, reason: 'unavailable', detail };
|
|
371
|
-
const noTelemetry = {
|
|
372
|
-
ok: false,
|
|
373
|
-
recorded: [],
|
|
374
|
-
skipped: [],
|
|
375
|
-
reason: 'unavailable',
|
|
376
|
-
};
|
|
377
|
-
return {
|
|
378
|
-
dbPath: options.dbPath,
|
|
379
|
-
status: { ok: false, detail },
|
|
380
|
-
promoteFacts: input => ({
|
|
381
|
-
identity: input.identity,
|
|
382
|
-
inserted: [],
|
|
383
|
-
seenAgain: [],
|
|
384
|
-
skipped: [],
|
|
385
|
-
maxMemoryId: 0,
|
|
386
|
-
}),
|
|
387
|
-
stats: () => ({ rows: 0, mutationLog: 0, epoch: 0, verified: 0, unverified: 0 }),
|
|
388
|
-
writeMemory: () => refused,
|
|
389
|
-
publishDomainMemory: () => refused,
|
|
390
|
-
countDomainMemories: () => 0,
|
|
391
|
-
readContents: () => [],
|
|
392
|
-
storeEmbedding: () => refused,
|
|
393
|
-
updateMemory: () => refused,
|
|
394
|
-
archiveMemory: () => refused,
|
|
395
|
-
mergeMemory: () => refused,
|
|
396
|
-
getMemory: () => undefined,
|
|
397
|
-
listMemories: () => [],
|
|
398
|
-
listExpired: () => [],
|
|
399
|
-
markVerified: () => 0,
|
|
400
|
-
classifyMemories: () => 0,
|
|
401
|
-
recordVerifySkip: () => undefined,
|
|
402
|
-
verifySkipHashes: () => new Map(),
|
|
403
|
-
recordRetrieval: () => noTelemetry,
|
|
404
|
-
close: () => undefined,
|
|
405
|
-
};
|
|
406
|
-
};
|
|
407
|
-
if (!existsSync(options.dbPath))
|
|
408
|
-
return unavailable('database not found');
|
|
409
|
-
let db;
|
|
410
|
-
try {
|
|
411
|
-
db = new DatabaseSync(options.dbPath);
|
|
412
|
-
// Connection order is load-bearing (contract §10.1): the busy timeout must
|
|
413
|
-
// precede WAL (acquiring the mode itself can need the lock), and
|
|
414
|
-
// foreign_keys must be ON before the first read/write or the
|
|
415
|
-
// `memory_embeddings` cascade is a no-op. WAL is a database-level,
|
|
416
|
-
// persistent setting, so re-applying it is a harmless no-op.
|
|
417
|
-
db.exec(`PRAGMA busy_timeout = ${String(options.busyTimeoutMs ?? 5_000)}`);
|
|
418
|
-
db.exec('PRAGMA foreign_keys = ON');
|
|
419
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
420
|
-
}
|
|
421
|
-
catch (error) {
|
|
422
|
-
return unavailable(`cannot open (${errText(error)})`);
|
|
423
|
-
}
|
|
424
|
-
try {
|
|
425
|
-
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()
|
|
426
|
-
.map(row => row.name));
|
|
427
|
-
if (!tables.has('memories')) {
|
|
428
|
-
db.close();
|
|
429
|
-
return unavailable('memories table missing');
|
|
430
|
-
}
|
|
431
|
-
// Contract fence: MC's storage version, read exactly like the bridge reads
|
|
432
|
-
// it (main lane `version < 10000`). A NEWER database downgrades this writer
|
|
433
|
-
// to read-only — the adapter must not write rows a storage layer it does
|
|
434
|
-
// not understand may interpret differently. An absent table means a legacy
|
|
435
|
-
// or trimmed database, which keeps today's (unfenced) behaviour.
|
|
436
|
-
const corpusVersion = (() => {
|
|
437
|
-
if (!tables.has('schema_migrations'))
|
|
438
|
-
return undefined;
|
|
439
|
-
try {
|
|
440
|
-
const row = db.prepare('SELECT MAX(version) AS version FROM schema_migrations WHERE version < 10000')
|
|
441
|
-
.get();
|
|
442
|
-
return row?.version ?? undefined;
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
return undefined;
|
|
446
|
-
}
|
|
447
|
-
})();
|
|
448
|
-
const corpusFenced = corpusVersion !== undefined && corpusVersion > MEMORY_SCHEMA_MAX_VERSION;
|
|
449
|
-
if (corpusFenced) {
|
|
450
|
-
options.logger.warn(`magic-context memory: corpus v${String(corpusVersion)} is newer than the adapter understands `
|
|
451
|
-
+ `(v${String(MEMORY_SCHEMA_MAX_VERSION)}); the writer is READ-ONLY and every write is refused`);
|
|
452
|
-
}
|
|
453
|
-
const byHash = db.prepare('SELECT id, status FROM memories WHERE project_path = ? AND category = ? AND normalized_hash = ?');
|
|
454
|
-
const bumpSeen = db.prepare('UPDATE memories SET seen_count = seen_count + 1, last_seen_at = ?, updated_at = ? WHERE id = ?');
|
|
455
|
-
// Reviving a withdrawn copy of the SAME text (same range key). Publish is a
|
|
456
|
-
// human decision and UNIQUE(project_path, category, normalized_hash) leaves
|
|
457
|
-
// no other way to honour it: measured 2026-10-16, approving candidate #1
|
|
458
|
-
// while row #1420 stayed archived marked the candidate approved and put
|
|
459
|
-
// NOTHING live.
|
|
460
|
-
const reviveArchived = db.prepare("UPDATE memories SET status = 'active', seen_count = seen_count + 1, last_seen_at = ?, updated_at = ? WHERE id = ?");
|
|
461
|
-
// T90 (2026-10-16): MAX() keeps the window monotonic — a refresh may extend
|
|
462
|
-
// a row's life but never shorten one, so two lanes can never race a fact to
|
|
463
|
-
// an earlier expiry than one of them already granted.
|
|
464
|
-
const refreshHistorianExpiry = db.prepare('UPDATE memories SET expires_at = MAX(COALESCE(expires_at, 0), ?) WHERE id = ?');
|
|
465
|
-
// L150: only NEW rows count against the rolling window (a re-encounter
|
|
466
|
-
// inserts nothing, so it must never consume budget).
|
|
467
|
-
const countPromotedSince = db.prepare("SELECT COUNT(*) AS c FROM memories WHERE project_path = ? AND source_type = 'historian' AND created_at > ?");
|
|
468
|
-
const insert = db.prepare(INSERT_MEMORY);
|
|
469
|
-
const insertDomain = db.prepare(INSERT_DOMAIN_MEMORY);
|
|
470
|
-
const maxId = db.prepare('SELECT MAX(id) AS id FROM memories WHERE project_path = ?');
|
|
471
|
-
// The telemetry panel's "total" is the live pool, not every historical row:
|
|
472
|
-
// verified/unverified below already use the same status scope. Counting
|
|
473
|
-
// archived rows here produced "total 1 / verified 0 / unverified 0".
|
|
474
|
-
const statsRows = db.prepare("SELECT COUNT(*) AS c FROM memories WHERE project_path = ? AND status IN ('active','permanent')");
|
|
475
|
-
const statsMutations = db.prepare('SELECT COUNT(*) AS c FROM memory_mutation_log WHERE project_path = ?');
|
|
476
|
-
const statsEpoch = db.prepare('SELECT project_memory_epoch AS e FROM project_state WHERE project_path = ?');
|
|
477
|
-
// Verification coverage for the telemetry panel: the dreamer slice's own KPI.
|
|
478
|
-
// Admission cap input: the live rows of ONE domain identity. `permanent`
|
|
479
|
-
// counts too — a permanent row occupies the domain exactly like an active one.
|
|
480
|
-
const countDomainRows = db.prepare("SELECT COUNT(*) AS c FROM memories WHERE project_path = ? AND status IN ('active','permanent')");
|
|
481
|
-
const statsVerification = db.prepare(`SELECT
|
|
482
|
-
COALESCE(SUM(CASE WHEN verified_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS verified,
|
|
483
|
-
COALESCE(SUM(CASE WHEN verified_at IS NULL THEN 1 ELSE 0 END), 0) AS unverified
|
|
484
|
-
FROM memories WHERE project_path = ? AND status IN ('active','permanent')`);
|
|
485
|
-
// Agent write path (contract §11). Prepared once; the whole projection is
|
|
486
|
-
// required, so a pre-v94 database disables the writer at probe time rather
|
|
487
|
-
// than failing mid-write.
|
|
488
|
-
const selectByHash = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE project_path = ? AND category = ? AND normalized_hash = ?`);
|
|
489
|
-
const selectById = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`);
|
|
490
|
-
const insertAgentMemory = db.prepare(INSERT_AGENT_MEMORY);
|
|
491
|
-
const bumpSeenCount = db.prepare(BUMP_SEEN_COUNT);
|
|
492
|
-
const bumpRetrieval = db.prepare(BUMP_RETRIEVAL_COUNT);
|
|
493
|
-
const selectOwner = db.prepare(SELECT_OWNER);
|
|
494
|
-
const updateMemoryContent = db.prepare(UPDATE_MEMORY_CONTENT);
|
|
495
|
-
const archiveMemory = db.prepare(ARCHIVE_MEMORY);
|
|
496
|
-
const archiveMemoryWithReason = db.prepare(ARCHIVE_MEMORY_WITH_REASON);
|
|
497
|
-
const supersedeMemory = db.prepare(SUPERSEDE_MEMORY);
|
|
498
|
-
const mergeMemoryTarget = db.prepare(MERGE_MEMORY_TARGET);
|
|
499
|
-
const restoreSharing = db.prepare(RESTORE_SHARING);
|
|
500
|
-
const appendMutation = db.prepare(APPEND_MUTATION);
|
|
501
|
-
const listMemoriesAll = db.prepare(LIST_MEMORIES);
|
|
502
|
-
const listMemoriesLimited = db.prepare(`${LIST_MEMORIES} LIMIT ?`);
|
|
503
|
-
const listUnverifiedAll = db.prepare(LIST_MEMORIES_UNVERIFIED);
|
|
504
|
-
const listUnverifiedLimited = db.prepare(`${LIST_MEMORIES_UNVERIFIED} LIMIT ?`);
|
|
505
|
-
// Content changes invalidate the classify/verify artefacts (§4.5). Both
|
|
506
|
-
// tables are absent on trimmed databases, hence the table guard.
|
|
507
|
-
const deleteEmbeddings = tables.has('memory_embeddings')
|
|
508
|
-
? db.prepare('DELETE FROM memory_embeddings WHERE memory_id = ?')
|
|
509
|
-
: undefined;
|
|
510
|
-
// WP11: the vector backfill is the only writer of this table. Both
|
|
511
|
-
// statements are prepared together so "can I read the table" and "can I
|
|
512
|
-
// write it" can never disagree.
|
|
513
|
-
const selectEmbedding = tables.has('memory_embeddings')
|
|
514
|
-
? db.prepare('SELECT 1 AS one FROM memory_embeddings WHERE memory_id = ? AND model_id = ?')
|
|
515
|
-
: undefined;
|
|
516
|
-
const insertEmbeddingStatement = tables.has('memory_embeddings')
|
|
517
|
-
? db.prepare('INSERT INTO memory_embeddings(memory_id, embedding, model_id) VALUES(?, ?, ?)')
|
|
518
|
-
: undefined;
|
|
519
|
-
const deleteVerifications = tables.has('memory_verifications')
|
|
520
|
-
? db.prepare('DELETE FROM memory_verifications WHERE memory_id = ?')
|
|
521
|
-
: undefined;
|
|
522
|
-
const maxFor = (identity) => {
|
|
523
|
-
const row = maxId.get(identity);
|
|
524
|
-
return row?.id ?? 0;
|
|
525
|
-
};
|
|
526
|
-
/** A refused write: logged, never thrown (the caller owns the turn). */
|
|
527
|
-
const refusal = (operation, reason, detail) => {
|
|
528
|
-
options.logger.info(`magic-context memory: ${operation} refused (${reason}): ${detail}`);
|
|
529
|
-
return { ok: false, reason, detail };
|
|
530
|
-
};
|
|
531
|
-
const databaseFailure = (operation, error) => {
|
|
532
|
-
const detail = errText(error);
|
|
533
|
-
options.logger.warn(`magic-context memory: ${operation} failed: ${detail}`);
|
|
534
|
-
return { ok: false, reason: 'database-error', detail };
|
|
535
|
-
};
|
|
536
|
-
/**
|
|
537
|
-
* The contract fence: a database newer than the adapter understands stays
|
|
538
|
-
* READ-ONLY. Reads (`getMemory`/`listMemories`/`stats`) keep working; every
|
|
539
|
-
* mutation — including promotion — is refused with a diagnosable reason.
|
|
540
|
-
*/
|
|
541
|
-
const fenceRefusal = (operation) => refusal(operation, 'corpus-version-unsupported', `corpus schema v${String(corpusVersion)} is newer than the adapter understands `
|
|
542
|
-
+ `(v${String(MEMORY_SCHEMA_MAX_VERSION)}); the memory write path is READ-ONLY. `
|
|
543
|
-
+ 'Update the adapter before writing.');
|
|
544
|
-
/**
|
|
545
|
-
* `BEGIN IMMEDIATE` (contract §10.2): a read-then-write must take the write
|
|
546
|
-
* lock up front, and SQLite does not honour `busy_timeout` while upgrading.
|
|
547
|
-
*/
|
|
548
|
-
const runImmediate = (body) => {
|
|
549
|
-
db.exec('BEGIN IMMEDIATE');
|
|
550
|
-
try {
|
|
551
|
-
const value = body();
|
|
552
|
-
db.exec('COMMIT');
|
|
553
|
-
return value;
|
|
554
|
-
}
|
|
555
|
-
catch (error) {
|
|
556
|
-
try {
|
|
557
|
-
db.exec('ROLLBACK');
|
|
558
|
-
}
|
|
559
|
-
catch {
|
|
560
|
-
/* the transaction may already be gone */
|
|
561
|
-
}
|
|
562
|
-
throw error;
|
|
563
|
-
}
|
|
564
|
-
};
|
|
565
|
-
const writerStatus = corpusFenced
|
|
566
|
-
? {
|
|
567
|
-
ok: true,
|
|
568
|
-
readOnly: true,
|
|
569
|
-
detail: `corpus schema v${String(corpusVersion)} is newer than the adapter understands `
|
|
570
|
-
+ `(v${String(MEMORY_SCHEMA_MAX_VERSION)}); the memory write path is READ-ONLY.`,
|
|
571
|
-
...(corpusVersion === undefined ? {} : { corpusVersion }),
|
|
572
|
-
}
|
|
573
|
-
: { ok: true, ...(corpusVersion === undefined ? {} : { corpusVersion }) };
|
|
574
|
-
return {
|
|
575
|
-
dbPath: options.dbPath,
|
|
576
|
-
status: writerStatus,
|
|
577
|
-
stats: identity => {
|
|
578
|
-
try {
|
|
579
|
-
const coverage = statsVerification.get(identity);
|
|
580
|
-
return {
|
|
581
|
-
rows: statsRows.get(identity).c,
|
|
582
|
-
mutationLog: statsMutations.get(identity).c,
|
|
583
|
-
epoch: (statsEpoch.get(identity)?.e) ?? 0,
|
|
584
|
-
verified: Number(coverage.verified),
|
|
585
|
-
unverified: Number(coverage.unverified),
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
return { rows: 0, mutationLog: 0, epoch: 0, verified: 0, unverified: 0 };
|
|
590
|
-
}
|
|
591
|
-
},
|
|
592
|
-
promoteFacts: (input) => {
|
|
593
|
-
const outcomeBase = {
|
|
594
|
-
identity: input.identity,
|
|
595
|
-
inserted: [],
|
|
596
|
-
seenAgain: [],
|
|
597
|
-
skipped: [],
|
|
598
|
-
};
|
|
599
|
-
if (corpusFenced) {
|
|
600
|
-
options.logger.warn(`magic-context memory: promotion refused, schema v${String(corpusVersion)} is newer than `
|
|
601
|
-
+ `the adapter understands (v${String(MEMORY_SCHEMA_MAX_VERSION)})`);
|
|
602
|
-
return { ...outcomeBase, maxMemoryId: maxFor(input.identity) };
|
|
603
|
-
}
|
|
604
|
-
// Validated first, so an invalid fact neither aborts the batch nor
|
|
605
|
-
// leaves a half-written row behind.
|
|
606
|
-
const accepted = [];
|
|
607
|
-
for (const fact of input.facts) {
|
|
608
|
-
const category = typeof fact.category === 'string' ? fact.category : '';
|
|
609
|
-
const content = typeof fact.content === 'string' ? fact.content : '';
|
|
610
|
-
if (!isPromotableCategory(category)) {
|
|
611
|
-
outcomeBase.skipped.push({ category, content, reason: 'category-not-promotable' });
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
if (content.trim().length === 0) {
|
|
615
|
-
outcomeBase.skipped.push({ category, content, reason: 'empty-content' });
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
accepted.push({ category, content, hash: normalizedMemoryHash(content) });
|
|
619
|
-
}
|
|
620
|
-
if (accepted.length === 0)
|
|
621
|
-
return { ...outcomeBase, maxMemoryId: maxFor(input.identity) };
|
|
622
|
-
const timestamp = now();
|
|
623
|
-
// L150 — see HISTORIAN_DAILY_CAP. Counted once per call, then spent:
|
|
624
|
-
// nothing inside the transaction can raise the count.
|
|
625
|
-
const promotedLast24h = Number(countPromotedSince.get(input.identity, timestamp - HISTORIAN_DAILY_CAP_WINDOW_MS).c);
|
|
626
|
-
let budget = Math.max(0, HISTORIAN_DAILY_CAP - promotedLast24h);
|
|
627
|
-
db.exec('BEGIN IMMEDIATE');
|
|
628
|
-
try {
|
|
629
|
-
for (const fact of accepted) {
|
|
630
|
-
const existing = byHash.get(input.identity, fact.category, fact.hash);
|
|
631
|
-
if (existing !== undefined) {
|
|
632
|
-
bumpSeen.run(timestamp, timestamp, existing.id);
|
|
633
|
-
// T90: re-observed means alive. Without this refresh a fact the
|
|
634
|
-
// historian keeps re-deriving would still expire on its first
|
|
635
|
-
// window, which is precisely the row we must NOT lose.
|
|
636
|
-
refreshHistorianExpiry.run(resolveHistorianExpiresAt(fact.category, timestamp), existing.id);
|
|
637
|
-
outcomeBase.seenAgain.push({ id: existing.id, category: fact.category });
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
if (budget <= 0) {
|
|
641
|
-
// Soft, per-pass, and visible: the fact stays in the compartment
|
|
642
|
-
// and the next pass re-proposes it (the dedup makes that free).
|
|
643
|
-
outcomeBase.skipped.push({ category: fact.category, content: fact.content, reason: 'daily-cap' });
|
|
644
|
-
continue;
|
|
645
|
-
}
|
|
646
|
-
budget -= 1;
|
|
647
|
-
const expiresAt = resolveHistorianExpiresAt(fact.category, timestamp);
|
|
648
|
-
const result = insert.run(input.identity, fact.category, fact.content, fact.hash, input.sessionId ?? null, timestamp, timestamp, timestamp, timestamp, expiresAt ?? null);
|
|
649
|
-
outcomeBase.inserted.push({
|
|
650
|
-
id: Number(result.lastInsertRowid),
|
|
651
|
-
category: fact.category,
|
|
652
|
-
content: fact.content,
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
db.exec('COMMIT');
|
|
656
|
-
}
|
|
657
|
-
catch (error) {
|
|
658
|
-
try {
|
|
659
|
-
db.exec('ROLLBACK');
|
|
660
|
-
}
|
|
661
|
-
catch {
|
|
662
|
-
/* the transaction may already be gone */
|
|
663
|
-
}
|
|
664
|
-
throw error;
|
|
665
|
-
}
|
|
666
|
-
return { ...outcomeBase, maxMemoryId: maxFor(input.identity) };
|
|
667
|
-
},
|
|
668
|
-
writeMemory: (input) => {
|
|
669
|
-
if (typeof input.identity !== 'string' || input.identity.length === 0) {
|
|
670
|
-
return refusal('write', 'invalid-argument', 'a resolved project identity is required');
|
|
671
|
-
}
|
|
672
|
-
if (!isWritableMemoryCategory(input.category)) {
|
|
673
|
-
return refusal('write', 'invalid-category', `Unknown memory category '${String(input.category)}'. The agent write path accepts only: ${V2_MEMORY_CATEGORIES.join(', ')}.`);
|
|
674
|
-
}
|
|
675
|
-
if (typeof input.content !== 'string' || input.content.trim().length === 0) {
|
|
676
|
-
return refusal('write', 'empty-content', "Error: 'content' is required when action is 'write'.");
|
|
677
|
-
}
|
|
678
|
-
const sourceType = input.sourceType ?? 'agent';
|
|
679
|
-
if (sourceType === 'user') {
|
|
680
|
-
return refusal('write', 'source-type-reserved', 'sourceType "user" is reserved for future dashboard manual entry and cannot be written by agent paths');
|
|
681
|
-
}
|
|
682
|
-
if (corpusFenced)
|
|
683
|
-
return fenceRefusal('write');
|
|
684
|
-
const category = input.category;
|
|
685
|
-
const hash = normalizedMemoryHash(input.content);
|
|
686
|
-
const timestamp = now();
|
|
687
|
-
try {
|
|
688
|
-
return runImmediate(() => {
|
|
689
|
-
const existing = asMemoryRecord(selectByHash.get(input.identity, category, hash));
|
|
690
|
-
if (existing !== undefined) {
|
|
691
|
-
bumpSeenCount.run(timestamp, timestamp, existing.id);
|
|
692
|
-
const after = asMemoryRecord(selectById.get(existing.id));
|
|
693
|
-
return { ok: true, id: existing.id, created: false, seenCount: after?.seenCount ?? 1 };
|
|
694
|
-
}
|
|
695
|
-
const expiresAt = input.expiresAt ?? resolveExpiresAt(category, timestamp) ?? null;
|
|
696
|
-
// Clamp to MC's 1..100 band (storage-memory.ts:1132-1135) so an
|
|
697
|
-
// out-of-range tool argument can't skew ranking on either side.
|
|
698
|
-
const importance = Math.min(100, Math.max(1, input.importance ?? 50));
|
|
699
|
-
const result = insertAgentMemory.run(input.identity, category, input.content, hash, importance, input.sessionId ?? null, sourceType, timestamp, timestamp, timestamp, timestamp, expiresAt, input.metadataJson ?? null);
|
|
700
|
-
return { ok: true, id: Number(result.lastInsertRowid), created: true, seenCount: 1 };
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
catch (error) {
|
|
704
|
-
return databaseFailure('write', error);
|
|
705
|
-
}
|
|
706
|
-
},
|
|
707
|
-
publishDomainMemory: (input) => {
|
|
708
|
-
const content = input.content.trim();
|
|
709
|
-
if (content.length === 0) {
|
|
710
|
-
return refusal('publish-domain', 'invalid-argument', 'Error: a domain memory needs content.');
|
|
711
|
-
}
|
|
712
|
-
if (!isWritableMemoryCategory(input.category)) {
|
|
713
|
-
return refusal('publish-domain', 'invalid-argument', `Error: '${input.category}' is not a writable memory category.`);
|
|
714
|
-
}
|
|
715
|
-
if (input.identity.trim().length === 0) {
|
|
716
|
-
return refusal('publish-domain', 'invalid-argument', 'Error: a domain memory needs an identity.');
|
|
717
|
-
}
|
|
718
|
-
if (corpusFenced)
|
|
719
|
-
return fenceRefusal('publish-domain');
|
|
720
|
-
const hash = normalizedMemoryHash(content);
|
|
721
|
-
try {
|
|
722
|
-
const outcome = runImmediate(() => {
|
|
723
|
-
// The contract's UNIQUE(project_path, category, normalized_hash)
|
|
724
|
-
// makes republishing idempotent: the row is already visible, so the
|
|
725
|
-
// only honest change is the recurrence counter. When it is NOT
|
|
726
|
-
// visible (withdrawn since), the same key means this publication is
|
|
727
|
-
// that exact text coming back — revive the tombstone instead of
|
|
728
|
-
// reporting success for a row nobody can read.
|
|
729
|
-
const existing = byHash.get(input.identity, input.category, hash);
|
|
730
|
-
if (existing !== undefined) {
|
|
731
|
-
const timestamp = now();
|
|
732
|
-
if (existing.status !== 'active' && existing.status !== 'permanent') {
|
|
733
|
-
reviveArchived.run(timestamp, timestamp, existing.id);
|
|
734
|
-
return { ok: true, id: existing.id, created: false, revived: true };
|
|
735
|
-
}
|
|
736
|
-
bumpSeen.run(timestamp, timestamp, existing.id);
|
|
737
|
-
return { ok: true, id: existing.id, created: false };
|
|
738
|
-
}
|
|
739
|
-
const timestamp = now();
|
|
740
|
-
const result = insertDomain.run(input.identity, input.category, content, hash, Math.max(1, Math.min(100, Math.round(input.importance ?? 50))), `domain:${input.identity}`, timestamp, timestamp, timestamp, timestamp);
|
|
741
|
-
return { ok: true, id: Number(result.lastInsertRowid), created: true };
|
|
742
|
-
});
|
|
743
|
-
return outcome;
|
|
744
|
-
}
|
|
745
|
-
catch (error) {
|
|
746
|
-
return databaseFailure('publish-domain', error);
|
|
747
|
-
}
|
|
748
|
-
},
|
|
749
|
-
countDomainMemories: (identity) => {
|
|
750
|
-
try {
|
|
751
|
-
return Number(countDomainRows.get(identity).c);
|
|
752
|
-
}
|
|
753
|
-
catch {
|
|
754
|
-
// Never a gate the publisher cannot evaluate: an unreadable count is
|
|
755
|
-
// reported as an empty domain, and the write below fails on its own
|
|
756
|
-
// terms if it cannot proceed.
|
|
757
|
-
return 0;
|
|
758
|
-
}
|
|
759
|
-
},
|
|
760
|
-
readContents: (ids) => {
|
|
761
|
-
const out = [];
|
|
762
|
-
for (const id of ids) {
|
|
763
|
-
try {
|
|
764
|
-
const row = selectById.get(id);
|
|
765
|
-
if (row !== undefined)
|
|
766
|
-
out.push({ id: Number(row.id), content: String(row.content) });
|
|
767
|
-
}
|
|
768
|
-
catch {
|
|
769
|
-
// A row that cannot be read is simply not a backfill candidate.
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
return out;
|
|
773
|
-
},
|
|
774
|
-
storeEmbedding: (input) => {
|
|
775
|
-
if (input.modelId.trim() === '') {
|
|
776
|
-
return refusal('store-embedding', 'invalid-argument', 'Error: an embedding needs a model id.');
|
|
777
|
-
}
|
|
778
|
-
if (input.vector.length === 0) {
|
|
779
|
-
return refusal('store-embedding', 'invalid-argument', 'Error: an embedding needs at least one dimension.');
|
|
780
|
-
}
|
|
781
|
-
if (corpusFenced)
|
|
782
|
-
return fenceRefusal('store-embedding');
|
|
783
|
-
if (selectEmbedding === undefined || insertEmbeddingStatement === undefined) {
|
|
784
|
-
return refusal('store-embedding', 'unsupported', 'Error: this contract has no memory_embeddings table, so the vector lane cannot be filled.');
|
|
785
|
-
}
|
|
786
|
-
try {
|
|
787
|
-
return runImmediate(() => {
|
|
788
|
-
// The contract keys this table `(memory_id, model_id)`; checking
|
|
789
|
-
// first makes the outcome deterministic and gives an honest
|
|
790
|
-
// `created`, and the key makes a concurrent second writer harmless.
|
|
791
|
-
if (selectEmbedding.get(input.memoryId, input.modelId) !== undefined) {
|
|
792
|
-
return { ok: true, id: input.memoryId, created: false };
|
|
793
|
-
}
|
|
794
|
-
insertEmbeddingStatement.run(input.memoryId, Buffer.from(input.vector.buffer, input.vector.byteOffset, input.vector.byteLength), input.modelId);
|
|
795
|
-
return { ok: true, id: input.memoryId, created: true };
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
catch (error) {
|
|
799
|
-
return databaseFailure('store-embedding', error);
|
|
800
|
-
}
|
|
801
|
-
},
|
|
802
|
-
updateMemory: (input) => {
|
|
803
|
-
if (!Number.isInteger(input.id) || input.id <= 0) {
|
|
804
|
-
return refusal('update', 'invalid-argument', "Error: 'ids' must contain exactly one integer memory ID when action is 'update'.");
|
|
805
|
-
}
|
|
806
|
-
if (typeof input.content !== 'string' || input.content.trim().length === 0) {
|
|
807
|
-
return refusal('update', 'empty-content', "Error: 'content' is required when action is 'update'.");
|
|
808
|
-
}
|
|
809
|
-
if (input.category !== undefined && !isWritableMemoryCategory(input.category)) {
|
|
810
|
-
return refusal('update', 'invalid-category', `Unknown memory category '${String(input.category)}'.`);
|
|
811
|
-
}
|
|
812
|
-
if (corpusFenced)
|
|
813
|
-
return fenceRefusal('update');
|
|
814
|
-
const hash = normalizedMemoryHash(input.content);
|
|
815
|
-
try {
|
|
816
|
-
return runImmediate(() => {
|
|
817
|
-
const row = asMemoryRecord(selectById.get(input.id));
|
|
818
|
-
if (row === undefined) {
|
|
819
|
-
return refusal('update', 'not-found', `Error: Memory with ID ${input.id} was not found.`);
|
|
820
|
-
}
|
|
821
|
-
if (row.projectPath !== input.identity) {
|
|
822
|
-
// Not leaking existence: a foreign row reads as absent (§8.3).
|
|
823
|
-
return refusal('update', 'not-owned', `Error: Memory with ID ${input.id} was not found.`);
|
|
824
|
-
}
|
|
825
|
-
if (row.status === 'archived' || row.supersededByMemoryId !== null) {
|
|
826
|
-
return refusal('update', 'archived-or-superseded', `Error: Memory with ID ${input.id} is archived or superseded; restore it before updating.`);
|
|
827
|
-
}
|
|
828
|
-
const category = input.category ?? row.category;
|
|
829
|
-
// Dedup inside the transaction, excluding the row itself (§11.3).
|
|
830
|
-
const duplicate = asMemoryRecord(selectByHash.get(input.identity, category, hash));
|
|
831
|
-
if (duplicate !== undefined && duplicate.id !== row.id) {
|
|
832
|
-
return refusal('update', 'duplicate', `Error: Memory content already exists as ID ${duplicate.id}; merge or archive duplicates instead.`);
|
|
833
|
-
}
|
|
834
|
-
const timestamp = now();
|
|
835
|
-
updateMemoryContent.run(input.content, category, hash, timestamp, input.importance ?? null, row.id);
|
|
836
|
-
// §4.3 cleared `shareable`; put back what this edit did not decide to
|
|
837
|
-
// change, decided from the row as it was read inside this transaction.
|
|
838
|
-
const restore = sharingToRestore(row);
|
|
839
|
-
if (restore !== undefined)
|
|
840
|
-
restoreSharing.run(restore, row.id);
|
|
841
|
-
deleteEmbeddings?.run(row.id);
|
|
842
|
-
deleteVerifications?.run(row.id);
|
|
843
|
-
appendMutation.run(input.identity, 'update', row.id, null, category, input.content, timestamp);
|
|
844
|
-
return { ok: true, id: row.id };
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
catch (error) {
|
|
848
|
-
return databaseFailure('update', error);
|
|
849
|
-
}
|
|
850
|
-
},
|
|
851
|
-
archiveMemory: (input) => {
|
|
852
|
-
if (!Number.isInteger(input.id) || input.id <= 0) {
|
|
853
|
-
return refusal('archive', 'invalid-argument', "Error: 'ids' must contain at least one integer memory ID when action is 'archive'.");
|
|
854
|
-
}
|
|
855
|
-
if (input.supersededBy !== undefined && (!Number.isInteger(input.supersededBy) || input.supersededBy <= 0)) {
|
|
856
|
-
return refusal('archive', 'invalid-argument', "Error: 'supersededBy' must be an integer memory ID.");
|
|
857
|
-
}
|
|
858
|
-
if (corpusFenced)
|
|
859
|
-
return fenceRefusal('archive');
|
|
860
|
-
const reason = typeof input.reason === 'string' && input.reason.trim().length > 0
|
|
861
|
-
? input.reason.trim()
|
|
862
|
-
: undefined;
|
|
863
|
-
try {
|
|
864
|
-
return runImmediate(() => {
|
|
865
|
-
const row = asMemoryRecord(selectById.get(input.id));
|
|
866
|
-
if (row === undefined) {
|
|
867
|
-
return refusal('archive', 'not-found', `Error: Memory with ID ${input.id} was not found.`);
|
|
868
|
-
}
|
|
869
|
-
if (row.projectPath !== input.identity) {
|
|
870
|
-
return refusal('archive', 'not-owned', `Error: Memory with ID ${input.id} was not found.`);
|
|
871
|
-
}
|
|
872
|
-
if (row.status === 'archived' || row.supersededByMemoryId !== null) {
|
|
873
|
-
return refusal('archive', 'archived-or-superseded', `Error: Memory with ID ${input.id} is archived or superseded; restore it before archiving.`);
|
|
874
|
-
}
|
|
875
|
-
const timestamp = now();
|
|
876
|
-
const metadata = reason === undefined ? null : mergeArchiveMetadata(row.metadataJson, reason);
|
|
877
|
-
if (input.supersededBy !== undefined) {
|
|
878
|
-
supersedeMemory.run(input.supersededBy, metadata, timestamp, row.id);
|
|
879
|
-
}
|
|
880
|
-
else if (metadata !== null) {
|
|
881
|
-
archiveMemoryWithReason.run(metadata, timestamp, row.id);
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
archiveMemory.run(timestamp, row.id);
|
|
885
|
-
}
|
|
886
|
-
appendMutation.run(input.identity, input.supersededBy === undefined ? 'archive' : 'superseded', row.id, input.supersededBy ?? null, null, null, timestamp);
|
|
887
|
-
return { ok: true, id: row.id };
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
catch (error) {
|
|
891
|
-
return databaseFailure('archive', error);
|
|
892
|
-
}
|
|
893
|
-
},
|
|
894
|
-
mergeMemory: (input) => {
|
|
895
|
-
if (!Number.isInteger(input.targetId) || input.targetId <= 0) {
|
|
896
|
-
return refusal('merge', 'invalid-argument', "Error: 'ids' must include at least two integer memory IDs when action is 'merge'.");
|
|
897
|
-
}
|
|
898
|
-
if (input.sourceIds.length === 0
|
|
899
|
-
|| input.sourceIds.some(id => !Number.isInteger(id) || id <= 0)) {
|
|
900
|
-
return refusal('merge', 'invalid-argument', "Error: 'ids' must include at least two integer memory IDs when action is 'merge'.");
|
|
901
|
-
}
|
|
902
|
-
const sourceIds = [...new Set(input.sourceIds)].filter(id => id !== input.targetId);
|
|
903
|
-
if (sourceIds.length === 0) {
|
|
904
|
-
return refusal('merge', 'invalid-argument', "Error: 'ids' must include at least two distinct memory IDs when action is 'merge'.");
|
|
905
|
-
}
|
|
906
|
-
if (typeof input.content !== 'string' || input.content.trim().length === 0) {
|
|
907
|
-
return refusal('merge', 'empty-content', "Error: 'content' is required when action is 'merge'.");
|
|
908
|
-
}
|
|
909
|
-
if (input.category !== undefined && !isWritableMemoryCategory(input.category)) {
|
|
910
|
-
return refusal('merge', 'invalid-category', `Unknown memory category '${String(input.category)}'.`);
|
|
911
|
-
}
|
|
912
|
-
if (corpusFenced)
|
|
913
|
-
return fenceRefusal('merge');
|
|
914
|
-
const hash = normalizedMemoryHash(input.content);
|
|
915
|
-
try {
|
|
916
|
-
return runImmediate(() => {
|
|
917
|
-
const target = asMemoryRecord(selectById.get(input.targetId));
|
|
918
|
-
if (target === undefined) {
|
|
919
|
-
return refusal('merge', 'not-found', `Error: Memory with ID ${input.targetId} was not found.`);
|
|
920
|
-
}
|
|
921
|
-
const sources = [];
|
|
922
|
-
for (const id of sourceIds) {
|
|
923
|
-
const row = asMemoryRecord(selectById.get(id));
|
|
924
|
-
if (row === undefined) {
|
|
925
|
-
return refusal('merge', 'not-found', `Error: Memory with ID ${id} was not found.`);
|
|
926
|
-
}
|
|
927
|
-
sources.push(row);
|
|
928
|
-
}
|
|
929
|
-
// Every affected row must be OWNED and primary-mutable (§8.1, §8.2).
|
|
930
|
-
const foreign = [target, ...sources].find(row => row.projectPath !== input.identity);
|
|
931
|
-
if (foreign !== undefined) {
|
|
932
|
-
return refusal('merge', 'not-owned', `Error: Memory with ID ${foreign.id} was not found.`);
|
|
933
|
-
}
|
|
934
|
-
const stale = [target, ...sources].find(row => row.status === 'archived' || row.supersededByMemoryId !== null);
|
|
935
|
-
if (stale !== undefined) {
|
|
936
|
-
return refusal('merge', 'archived-or-superseded', `Error: Memory with ID ${stale.id} is archived or superseded; restore it before merging.`);
|
|
937
|
-
}
|
|
938
|
-
// Cross-category merge is structurally refused (§3.3).
|
|
939
|
-
const categories = [...new Set([target, ...sources].map(row => row.category))];
|
|
940
|
-
if (categories.length > 1) {
|
|
941
|
-
return refusal('merge', 'category-mismatch', `Error: Cannot merge memories from different categories (${categories.join(', ')}). If they are genuine duplicates, one is miscategorized — archive the redundant one instead of merging across categories.`);
|
|
942
|
-
}
|
|
943
|
-
const category = input.category ?? target.category;
|
|
944
|
-
if (category !== target.category) {
|
|
945
|
-
return refusal('merge', 'category-mismatch', `Error: Cannot merge memories from different categories (${target.category}, ${category}). If they are genuine duplicates, one is miscategorized — archive the redundant one instead of merging across categories.`);
|
|
946
|
-
}
|
|
947
|
-
const duplicate = asMemoryRecord(selectByHash.get(input.identity, category, hash));
|
|
948
|
-
if (duplicate !== undefined && ![target, ...sources].some(row => row.id === duplicate.id)) {
|
|
949
|
-
return refusal('merge', 'duplicate', `Error: Memory content already exists as ID ${duplicate.id}; merge or archive duplicates instead.`);
|
|
950
|
-
}
|
|
951
|
-
const timestamp = now();
|
|
952
|
-
// Aggregation rules (§11.4): sums of the non-negative counters, the
|
|
953
|
-
// permanent flag OR-ed, and the id set unioned with each source's
|
|
954
|
-
// own `merged_from` chain.
|
|
955
|
-
const counters = [target, ...sources];
|
|
956
|
-
const seenCount = counters.reduce((sum, row) => sum + Math.max(row.seenCount ?? 0, 0), 0);
|
|
957
|
-
const retrievalCount = counters.reduce((sum, row) => sum + Math.max(row.retrievalCount ?? 0, 0), 0);
|
|
958
|
-
const status = counters.some(row => row.status === 'permanent') ? 'permanent' : 'active';
|
|
959
|
-
const mergedFrom = [...new Set([
|
|
960
|
-
...counters.map(row => row.id),
|
|
961
|
-
...sources.flatMap(row => parseMergedFrom(row.mergedFrom)),
|
|
962
|
-
])].sort((left, right) => left - right);
|
|
963
|
-
mergeMemoryTarget.run(input.content, hash, seenCount, retrievalCount, JSON.stringify(mergedFrom), status, timestamp, target.id);
|
|
964
|
-
// Same §4.3 restore as `update`: the merge target keeps whatever
|
|
965
|
-
// sharing it had, and a private target is never promoted by a merge.
|
|
966
|
-
const restoreTarget = sharingToRestore(target);
|
|
967
|
-
if (restoreTarget !== undefined)
|
|
968
|
-
restoreSharing.run(restoreTarget, target.id);
|
|
969
|
-
for (const source of sources) {
|
|
970
|
-
supersedeMemory.run(target.id, null, timestamp, source.id);
|
|
971
|
-
appendMutation.run(input.identity, 'superseded', source.id, target.id, null, null, timestamp);
|
|
972
|
-
}
|
|
973
|
-
if (input.content !== target.content || category !== target.category) {
|
|
974
|
-
appendMutation.run(input.identity, 'update', target.id, null, category, input.content, timestamp);
|
|
975
|
-
}
|
|
976
|
-
return { ok: true, id: target.id, superseded: sources.map(row => row.id), mergedFrom };
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
catch (error) {
|
|
980
|
-
return databaseFailure('merge', error);
|
|
981
|
-
}
|
|
982
|
-
},
|
|
983
|
-
getMemory: (identity, id) => {
|
|
984
|
-
if (!Number.isInteger(id) || id <= 0)
|
|
985
|
-
return undefined;
|
|
986
|
-
try {
|
|
987
|
-
const row = asMemoryRecord(selectById.get(id));
|
|
988
|
-
if (row === undefined || row.projectPath !== identity)
|
|
989
|
-
return undefined;
|
|
990
|
-
return row;
|
|
991
|
-
}
|
|
992
|
-
catch {
|
|
993
|
-
return undefined;
|
|
994
|
-
}
|
|
995
|
-
},
|
|
996
|
-
listMemories: (identity, listOptions) => {
|
|
997
|
-
try {
|
|
998
|
-
const cutoff = listOptions?.now ?? now();
|
|
999
|
-
const limit = listOptions?.limit;
|
|
1000
|
-
const all = listOptions?.unverifiedOnly === true ? listUnverifiedAll : listMemoriesAll;
|
|
1001
|
-
const limited = listOptions?.unverifiedOnly === true ? listUnverifiedLimited : listMemoriesLimited;
|
|
1002
|
-
if (limit !== undefined) {
|
|
1003
|
-
if (!Number.isInteger(limit) || limit <= 0)
|
|
1004
|
-
return [];
|
|
1005
|
-
return asMemoryRecords(limited.all(identity, cutoff, limit));
|
|
1006
|
-
}
|
|
1007
|
-
return asMemoryRecords(all.all(identity, cutoff));
|
|
1008
|
-
}
|
|
1009
|
-
catch {
|
|
1010
|
-
return [];
|
|
1011
|
-
}
|
|
1012
|
-
},
|
|
1013
|
-
/**
|
|
1014
|
-
* Explicit-retrieval telemetry (§9.1). Never throws and never fails the
|
|
1015
|
-
* caller: telemetry is not correctness, so a fenced or module-managed
|
|
1016
|
-
* database reports `{ok:false, reason}` instead of raising, and rows the
|
|
1017
|
-
* caller does not own are skipped rather than counted.
|
|
1018
|
-
*/
|
|
1019
|
-
recordRetrieval: (input) => {
|
|
1020
|
-
// `skipped` is always derived as "requested minus recorded", so it keeps
|
|
1021
|
-
// the caller's order and cannot disagree with `recorded`.
|
|
1022
|
-
const requested = [...new Set(input.ids)];
|
|
1023
|
-
const countable = requested.filter(id => Number.isInteger(id) && id > 0);
|
|
1024
|
-
const recorded = [];
|
|
1025
|
-
if (typeof input.identity !== 'string' || input.identity.length === 0) {
|
|
1026
|
-
return { ok: false, recorded, skipped: requested, reason: 'invalid-argument' };
|
|
1027
|
-
}
|
|
1028
|
-
if (countable.length === 0)
|
|
1029
|
-
return { ok: true, recorded, skipped: requested };
|
|
1030
|
-
if (corpusFenced) {
|
|
1031
|
-
options.logger.warn(`magic-context memory: retrieval telemetry refused, schema v${String(corpusVersion)} is newer `
|
|
1032
|
-
+ `than the adapter understands (v${String(MEMORY_SCHEMA_MAX_VERSION)})`);
|
|
1033
|
-
return { ok: false, recorded, skipped: requested, reason: 'corpus-version-unsupported' };
|
|
1034
|
-
}
|
|
1035
|
-
const skipped = () => requested.filter(id => !recorded.includes(id));
|
|
1036
|
-
try {
|
|
1037
|
-
const outcome = runImmediate(() => {
|
|
1038
|
-
const timestamp = now();
|
|
1039
|
-
for (const id of countable) {
|
|
1040
|
-
const owner = selectOwner.get(id);
|
|
1041
|
-
if (owner === undefined || owner.projectPath !== input.identity)
|
|
1042
|
-
continue;
|
|
1043
|
-
bumpRetrieval.run(timestamp, id);
|
|
1044
|
-
recorded.push(id);
|
|
1045
|
-
}
|
|
1046
|
-
return { ok: true };
|
|
1047
|
-
});
|
|
1048
|
-
if (!outcome.ok)
|
|
1049
|
-
return { ok: false, recorded, skipped: skipped(), reason: outcome.reason };
|
|
1050
|
-
return { ok: true, recorded, skipped: skipped() };
|
|
1051
|
-
}
|
|
1052
|
-
catch (error) {
|
|
1053
|
-
const detail = errText(error);
|
|
1054
|
-
options.logger.warn(`magic-context memory: retrieval telemetry failed: ${detail}`);
|
|
1055
|
-
return { ok: false, recorded, skipped: skipped(), reason: 'database-error' };
|
|
1056
|
-
}
|
|
1057
|
-
},
|
|
1058
|
-
listExpired: (identity, nowAt, limit = 200) => {
|
|
1059
|
-
try {
|
|
1060
|
-
if (corpusFenced)
|
|
1061
|
-
return [];
|
|
1062
|
-
const rows = db.prepare(`SELECT id FROM memories`
|
|
1063
|
-
+ ` WHERE project_path = ? AND status IN ('active','permanent')`
|
|
1064
|
-
+ ` AND expires_at IS NOT NULL AND expires_at <= ?`
|
|
1065
|
-
+ ` ORDER BY id ASC LIMIT ?`).all(identity, nowAt, Math.max(1, Math.min(500, limit)));
|
|
1066
|
-
return rows.map(row => row.id);
|
|
1067
|
-
}
|
|
1068
|
-
catch (error) {
|
|
1069
|
-
options.logger.warn(`magic-context memory: listExpired failed: ${errText(error)}`);
|
|
1070
|
-
return [];
|
|
1071
|
-
}
|
|
1072
|
-
},
|
|
1073
|
-
markVerified: (identity, ids, verifiedAt) => {
|
|
1074
|
-
if (ids.length === 0)
|
|
1075
|
-
return 0;
|
|
1076
|
-
try {
|
|
1077
|
-
if (corpusFenced)
|
|
1078
|
-
return 0;
|
|
1079
|
-
const stamp = runImmediate(() => {
|
|
1080
|
-
// MC writes verification_status and verified_at as a pair
|
|
1081
|
-
// (storage-memory.ts:464) — writing only verified_at left rows
|
|
1082
|
-
// that MC still read as unverified (audit R1).
|
|
1083
|
-
const statement = db.prepare(`UPDATE memories SET verification_status = 'verified', verified_at = ?`
|
|
1084
|
-
+ ` WHERE project_path = ? AND id = ? AND status IN ('active','permanent')`);
|
|
1085
|
-
let changed = 0;
|
|
1086
|
-
for (const id of ids) {
|
|
1087
|
-
// node:sqlite reports `changes` as `number | bigint`.
|
|
1088
|
-
changed += Number(statement.run(verifiedAt, identity, id).changes);
|
|
1089
|
-
}
|
|
1090
|
-
return { ok: true, changed };
|
|
1091
|
-
});
|
|
1092
|
-
return stamp.ok ? stamp.changed : 0;
|
|
1093
|
-
}
|
|
1094
|
-
catch (error) {
|
|
1095
|
-
options.logger.warn(`magic-context memory: markVerified failed: ${errText(error)}`);
|
|
1096
|
-
return 0;
|
|
1097
|
-
}
|
|
1098
|
-
},
|
|
1099
|
-
classifyMemories: (identity, entries, classifiedAt) => {
|
|
1100
|
-
if (entries.length === 0)
|
|
1101
|
-
return 0;
|
|
1102
|
-
try {
|
|
1103
|
-
if (corpusFenced)
|
|
1104
|
-
return 0;
|
|
1105
|
-
const scored = runImmediate(() => {
|
|
1106
|
-
const statement = db.prepare(CLASSIFY_MEMORY);
|
|
1107
|
-
let changed = 0;
|
|
1108
|
-
for (const entry of entries) {
|
|
1109
|
-
if (!Number.isInteger(entry.id) || entry.id <= 0)
|
|
1110
|
-
continue;
|
|
1111
|
-
// Same 1..100 clamp as writeMemory (storage-memory.ts:1132-1135),
|
|
1112
|
-
// so an out-of-range verdict can't skew m[0] ranking either side.
|
|
1113
|
-
const importance = Math.min(100, Math.max(1, Math.round(entry.importance)));
|
|
1114
|
-
changed += Number(statement.run(importance, classifiedAt, identity, entry.id).changes);
|
|
1115
|
-
}
|
|
1116
|
-
return { ok: true, changed };
|
|
1117
|
-
});
|
|
1118
|
-
return scored.ok ? scored.changed : 0;
|
|
1119
|
-
}
|
|
1120
|
-
catch (error) {
|
|
1121
|
-
options.logger.warn(`magic-context memory: classifyMemories failed: ${errText(error)}`);
|
|
1122
|
-
return 0;
|
|
1123
|
-
}
|
|
1124
|
-
},
|
|
1125
|
-
recordVerifySkip: ({ identity, id, contentHash, at }) => {
|
|
1126
|
-
try {
|
|
1127
|
-
if (corpusFenced)
|
|
1128
|
-
return;
|
|
1129
|
-
if (!Number.isInteger(id) || id <= 0 || contentHash.length === 0)
|
|
1130
|
-
return;
|
|
1131
|
-
runImmediate(() => {
|
|
1132
|
-
const row = db.prepare('SELECT metadata_json FROM memories WHERE project_path = ? AND id = ?').get(identity, id);
|
|
1133
|
-
// Foreign/vanished row: nothing to annotate, and no error worth a log.
|
|
1134
|
-
if (row === undefined)
|
|
1135
|
-
return { ok: true };
|
|
1136
|
-
db.prepare('UPDATE memories SET metadata_json = ? WHERE project_path = ? AND id = ?')
|
|
1137
|
-
.run(mergeVerifySkipMetadata(row.metadata_json ?? null, contentHash, at), identity, id);
|
|
1138
|
-
return { ok: true };
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
catch (error) {
|
|
1142
|
-
options.logger.warn(`magic-context memory: recordVerifySkip failed: ${errText(error)}`);
|
|
1143
|
-
}
|
|
1144
|
-
},
|
|
1145
|
-
verifySkipHashes: (identity, ids) => {
|
|
1146
|
-
const remembered = new Map();
|
|
1147
|
-
if (ids.length === 0)
|
|
1148
|
-
return remembered;
|
|
1149
|
-
try {
|
|
1150
|
-
const placeholders = ids.map(() => '?').join(',');
|
|
1151
|
-
const rows = db.prepare(`SELECT id, metadata_json FROM memories WHERE project_path = ? AND id IN (${placeholders})`).all(identity, ...ids);
|
|
1152
|
-
for (const row of rows) {
|
|
1153
|
-
const hash = parseVerifySkipHash(row.metadata_json ?? null);
|
|
1154
|
-
if (hash !== undefined)
|
|
1155
|
-
remembered.set(Number(row.id), hash);
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
catch (error) {
|
|
1159
|
-
// Fail-open: an unreadable annotation must not stop the sweep.
|
|
1160
|
-
options.logger.warn(`magic-context memory: verifySkipHashes failed: ${errText(error)}`);
|
|
1161
|
-
}
|
|
1162
|
-
return remembered;
|
|
1163
|
-
},
|
|
1164
|
-
close: () => db.close(),
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1167
|
-
catch (error) {
|
|
1168
|
-
try {
|
|
1169
|
-
db.close();
|
|
1170
|
-
}
|
|
1171
|
-
catch {
|
|
1172
|
-
/* best effort */
|
|
1173
|
-
}
|
|
1174
|
-
return unavailable(`probe failed (${errText(error)})`);
|
|
1175
|
-
}
|
|
1176
|
-
}
|