superdev-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,810 @@
1
+ // Local project memory, brief section 13.
2
+ //
3
+ // Memory is recall, never authority. Every entry that comes out of here carries
4
+ // its epistemic status and a note saying it must be checked against the live
5
+ // records before it changes anything, and `verifyRecall` is that check. The
6
+ // point is that an agent resuming work reads the database, not a transcript.
7
+ //
8
+ // memory_entries.embedding stays null: this engine has no vector index, so
9
+ // vector recall would be a bounded full scan with vector32() and
10
+ // vector_distance_cos(), which is a later decision and is not implemented here.
11
+
12
+ import { createHash } from "node:crypto";
13
+ import {
14
+ query, mutate, create, patch, currentProject, recordActivity, json, DbError,
15
+ } from "../db/store.mjs";
16
+ import { tableFor } from "../model/ids.mjs";
17
+ import { sanitizeExternal, rewriteStyle, hasEmoji, assertRecordStorable } from "../model/screening.mjs";
18
+
19
+ const KINDS = new Set([
20
+ "outcome", "decision", "learned_fact", "unresolved_question",
21
+ "blocker", "handoff", "summary",
22
+ ]);
23
+
24
+ const STATUSES = new Set(["confirmed", "inferred", "assumed", "unknown", "contradicted"]);
25
+
26
+ const TERMINAL_WORK = new Set(["complete", "cancelled", "superseded"]);
27
+ const DEAD_DECISION = new Set(["superseded", "partially_superseded", "deprecated", "rejected"]);
28
+
29
+ const RECALL_NOTE =
30
+ "Recall, not authority. Verify with verifyRecall against current specifications, decisions and evidence before acting on it.";
31
+
32
+ // Progressive retrieval: a recall answers with the smallest set that is still
33
+ // useful, so an agent loads five relevant entries rather than a session log.
34
+ const DEFAULT_LIMIT = 5;
35
+ const MAX_LIMIT = 50;
36
+ const CANDIDATE_CAP = 500;
37
+ const HALF_LIFE_DAYS = 30;
38
+ // Anything scoring below this share of the best match is noise next to it.
39
+ const RELATIVE_FLOOR = 0.35;
40
+
41
+ // A decision or a handoff is worth more on recall than a passing summary of the
42
+ // same age, and a confirmed fact is worth more than an assumed one.
43
+ const KIND_WEIGHT = {
44
+ decision: 1.6, handoff: 1.5, blocker: 1.4, unresolved_question: 1.3,
45
+ learned_fact: 1.2, outcome: 1.0, summary: 0.7,
46
+ };
47
+ const STATUS_WEIGHT = {
48
+ confirmed: 1.3, inferred: 1.0, assumed: 0.8, unknown: 0.6, contradicted: 0.2,
49
+ };
50
+
51
+ const nowIso = () => new Date().toISOString();
52
+
53
+ /**
54
+ * Redact before storage. `sanitizeExternal` covers secret-shaped strings and the
55
+ * home paths that carry a person's account name; the loop exists because
56
+ * `rewriteStyle` strips one emoji per pass (its pattern is not global), and a
57
+ * second one would otherwise reach the storage screen and be refused rather
58
+ * than cleaned.
59
+ */
60
+ function clean(text) {
61
+ if (text === null || text === undefined) return null;
62
+ let out = sanitizeExternal(String(text)).trim();
63
+ for (let i = 0; i < 8 && hasEmoji(out); i++) out = rewriteStyle(out).trim();
64
+ return out;
65
+ }
66
+
67
+ const shape = (row, extra = {}) => ({
68
+ id: row.id,
69
+ kind: row.kind,
70
+ title: row.title,
71
+ content: row.content,
72
+ epistemicStatus: row.epistemic_status,
73
+ sourceRef: row.source_ref,
74
+ sessionId: row.session_id,
75
+ taskId: row.task_id,
76
+ featureId: row.feature_id,
77
+ supersededBy: row.superseded_by,
78
+ createdAt: row.created_at,
79
+ version: row.version,
80
+ authority: "recall",
81
+ note: RECALL_NOTE,
82
+ ...extra,
83
+ });
84
+
85
+ function normalizeLinks(links) {
86
+ if (!links) return [];
87
+ return (Array.isArray(links) ? links : [links])
88
+ .map((l) => ({
89
+ targetType: String(l.targetType ?? l.target_type ?? "").trim(),
90
+ targetId: String(l.targetId ?? l.target_id ?? "").trim(),
91
+ relationship: String(l.relationship ?? "about").trim() || "about",
92
+ }))
93
+ .filter((l) => l.targetType && l.targetId);
94
+ }
95
+
96
+ /**
97
+ * What the linked record looked like when the memory was written. Verification
98
+ * later compares against this: a record that still exists proves nothing on its
99
+ * own, because the fact may have been true about an earlier version of it.
100
+ */
101
+ async function targetState(db, targetType, targetId) {
102
+ const table = tableFor(targetType);
103
+ if (!table) return { exists: false, version: null, fingerprint: null };
104
+ const row = await db.get(`SELECT * FROM ${table} WHERE id = ?`, targetId);
105
+ if (!row) return { exists: false, version: null, fingerprint: null };
106
+ // Volatile bookkeeping is excluded so an unrelated touch does not read as a
107
+ // contradiction. Everything that carries meaning is hashed.
108
+ const meaningful = Object.keys(row)
109
+ .filter((k) => !["updated_at", "last_seen_at", "last_activity_at"].includes(k))
110
+ .sort();
111
+ const canonical = meaningful.map((k) => `${k}=${row[k] === null ? "" : String(row[k])}`).join("\u0001");
112
+ return {
113
+ exists: true,
114
+ version: row.version ?? null,
115
+ fingerprint: createHash("sha256").update(canonical).digest("hex"),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * memory_links has no id, version or status, so `create` does not cover it and a
121
+ * direct insert is the only route. The database also refuses a link to a record
122
+ * that does not exist, per migration 003; this check runs first so the caller
123
+ * gets a sentence rather than a trigger name.
124
+ */
125
+ async function linkRow(db, memoryId, link, at) {
126
+ assertRecordStorable({
127
+ target_type: link.targetType, target_id: link.targetId, relationship: link.relationship,
128
+ });
129
+ const state = await targetState(db, link.targetType, link.targetId);
130
+ if (!state.exists) {
131
+ throw new DbError(
132
+ "E_MEMORY_LINK_ORPHAN",
133
+ `${link.targetType} ${link.targetId} does not exist, so a memory cannot be linked to it.`,
134
+ );
135
+ }
136
+ await db.run(
137
+ `INSERT OR REPLACE INTO memory_links
138
+ (memory_id, target_type, target_id, relationship, target_version, target_fingerprint, created_at)
139
+ VALUES (?,?,?,?,?,?,?)`,
140
+ memoryId, link.targetType, link.targetId, link.relationship,
141
+ state.version, state.fingerprint, at ?? new Date().toISOString(),
142
+ );
143
+ }
144
+
145
+ // ------------------------------------------------------------- term indexing
146
+
147
+ /** Weighting by where a term appeared. A title match is a stronger signal. */
148
+ const FIELD_WEIGHT = { title: 3.0, content: 1.0, source_ref: 0.5 };
149
+
150
+ const STOP_WORDS = new Set([
151
+ "the", "a", "an", "and", "or", "but", "if", "then", "than", "that", "this",
152
+ "these", "those", "is", "are", "was", "were", "be", "been", "being", "to",
153
+ "of", "in", "on", "at", "for", "with", "by", "from", "as", "it", "its",
154
+ "we", "our", "you", "your", "they", "their", "not", "no", "so", "do", "does",
155
+ ]);
156
+
157
+ /** Lowercase word tokens of three characters or more, stop words removed. */
158
+ export function tokenize(text) {
159
+ return String(text ?? "")
160
+ .toLowerCase()
161
+ .split(/[^\p{Letter}\p{Number}]+/u)
162
+ .filter((t) => t.length >= 3 && !STOP_WORDS.has(t));
163
+ }
164
+
165
+ /**
166
+ * Rebuild the postings for one memory. Called inside the same transaction as the
167
+ * write, so the index can never describe a memory that was rolled back.
168
+ */
169
+ async function indexMemory(db, memoryId, { title, content, sourceRef }) {
170
+ await db.run("DELETE FROM memory_search_terms WHERE memory_id = ?", memoryId);
171
+ const counts = new Map();
172
+ for (const [field, text] of [["title", title], ["content", content], ["source_ref", sourceRef]]) {
173
+ for (const term of tokenize(text)) {
174
+ const key = `${term}\u0000${field}`;
175
+ counts.set(key, (counts.get(key) ?? 0) + 1);
176
+ }
177
+ }
178
+ for (const [key, frequency] of counts) {
179
+ const [term, field] = key.split("\u0000");
180
+ await db.run(
181
+ `INSERT INTO memory_search_terms (memory_id, term, field, frequency, field_weight)
182
+ VALUES (?,?,?,?,?)`,
183
+ memoryId, term, field, frequency, FIELD_WEIGHT[field] ?? 1.0,
184
+ );
185
+ }
186
+ return counts.size;
187
+ }
188
+
189
+ async function linksOf(db, memoryIds) {
190
+ if (!memoryIds.length) return new Map();
191
+ const rows = await db.all(
192
+ `SELECT * FROM memory_links WHERE memory_id IN (${memoryIds.map(() => "?").join(",")})`,
193
+ ...memoryIds,
194
+ );
195
+ const byMemory = new Map();
196
+ for (const row of rows) {
197
+ const list = byMemory.get(row.memory_id) ?? [];
198
+ list.push({ targetType: row.target_type, targetId: row.target_id, relationship: row.relationship });
199
+ byMemory.set(row.memory_id, list);
200
+ }
201
+ return byMemory;
202
+ }
203
+
204
+ // ------------------------------------------------------------------- writing
205
+
206
+ async function rememberLocal(root, entry = {}) {
207
+ // A key named `reasoning`, `thinking` or `scratchpad` never reaches storage.
208
+ // `create` would drop it silently as an unknown column, which would look like
209
+ // it worked, so it is refused here while the caller can still see why. Passing
210
+ // every key as allowed leaves only the private-reasoning rule armed; the text
211
+ // itself is redacted below rather than rejected.
212
+ assertRecordStorable(entry, { allow: Object.keys(entry) });
213
+
214
+ const kind = String(entry.kind ?? "").trim();
215
+ if (!KINDS.has(kind)) {
216
+ throw new DbError("E_MEMORY_KIND", `"${kind}" is not a memory kind. Use one of: ${[...KINDS].join(", ")}.`);
217
+ }
218
+ const epistemicStatus = String(entry.epistemicStatus ?? entry.epistemic_status ?? "inferred");
219
+ if (!STATUSES.has(epistemicStatus)) {
220
+ throw new DbError("E_MEMORY_STATUS", `"${epistemicStatus}" is not an epistemic status. Use one of: ${[...STATUSES].join(", ")}.`);
221
+ }
222
+
223
+ const title = clean(entry.title);
224
+ const content = clean(entry.content);
225
+ if (!title || !content) {
226
+ throw new DbError("E_MEMORY_EMPTY", "A memory needs a title and content. Store the observable outcome and the rationale for it.");
227
+ }
228
+
229
+ const links = normalizeLinks(entry.links);
230
+ const sourceRef = clean(entry.sourceRef ?? entry.source_ref);
231
+ const contentHash = createHash("sha256")
232
+ .update(`${kind}\n${title}\n${content}\n${sourceRef ?? ""}`)
233
+ .digest("hex");
234
+
235
+ // A hook, a compaction and a handoff all retry. The key is supplied by the
236
+ // caller and is deliberately NOT the content hash: two genuinely different
237
+ // observations can be worded alike, and collapsing those would lose real
238
+ // information. Only an explicit key claims "this is the same delivery".
239
+ const dedupeKey = entry.dedupeKey ?? entry.dedupe_key ?? null;
240
+ const sourceEventId = entry.sourceEventId ?? entry.source_event_id ?? null;
241
+
242
+ return mutate(root, async (db) => {
243
+ const project = await currentProject(db);
244
+ if (!project) throw new DbError("E_NOT_FOUND", "This directory has no Superdev project yet. Run superdev init first.");
245
+
246
+ if (dedupeKey) {
247
+ const existing = await db.get(
248
+ "SELECT * FROM memory_entries WHERE project_id = ? AND dedupe_key = ?",
249
+ project.id, dedupeKey,
250
+ );
251
+ if (existing) {
252
+ const known = await linksOf(db, [existing.id]);
253
+ return shape(existing, { links: known.get(existing.id) ?? [], deduplicated: true });
254
+ }
255
+ }
256
+
257
+ const row = await create(db, "memory_entry", {
258
+ project_id: project.id,
259
+ session_id: entry.sessionId ?? null,
260
+ task_id: entry.taskId ?? null,
261
+ feature_id: entry.featureId ?? null,
262
+ kind,
263
+ title,
264
+ content,
265
+ source_ref: sourceRef,
266
+ epistemic_status: epistemicStatus,
267
+ dedupe_key: dedupeKey,
268
+ source_event_id: sourceEventId,
269
+ content_hash: contentHash,
270
+ }, {
271
+ projectId: project.id,
272
+ actor: entry.actor,
273
+ sessionId: entry.sessionId ?? null,
274
+ taskId: entry.taskId ?? null,
275
+ featureId: entry.featureId ?? null,
276
+ activitySummary: `Remembered ${kind.replace(/_/g, " ")}: ${title.slice(0, 80)}`,
277
+ });
278
+
279
+ await indexMemory(db, row.id, { title, content, sourceRef });
280
+ for (const link of links) await linkRow(db, row.id, link, row.created_at);
281
+ return shape(row, { links, deduplicated: false });
282
+ });
283
+ }
284
+
285
+ async function linkLocal(root, memoryId, targetType, targetId, relationship = "about") {
286
+ const link = normalizeLinks([{ targetType, targetId, relationship }])[0];
287
+ if (!link) throw new DbError("E_MEMORY_LINK", "A memory link needs both a target type and a target id.");
288
+
289
+ return mutate(root, async (db) => {
290
+ const memory = await db.get("SELECT * FROM memory_entries WHERE id = ?", memoryId);
291
+ if (!memory) throw new DbError("E_NOT_FOUND", `Memory ${memoryId} does not exist.`);
292
+ await linkRow(db, memoryId, link);
293
+ await recordActivity(db, memory.project_id, {
294
+ type: "specification_changed",
295
+ summary: `Linked memory ${memoryId} to ${link.targetType} ${link.targetId}`,
296
+ sessionId: memory.session_id,
297
+ taskId: memory.task_id,
298
+ featureId: memory.feature_id,
299
+ metadata: { kind: "memory_link", id: memoryId, target: link },
300
+ });
301
+ return { memoryId, ...link };
302
+ });
303
+ }
304
+
305
+ async function supersedeLocal(root, memoryId, replacementId, { actor } = {}) {
306
+ if (memoryId === replacementId) {
307
+ throw new DbError("E_MEMORY_SUPERSEDE", "A memory cannot supersede itself.");
308
+ }
309
+ return mutate(root, async (db) => {
310
+ const memory = await db.get("SELECT * FROM memory_entries WHERE id = ?", memoryId);
311
+ if (!memory) throw new DbError("E_NOT_FOUND", `Memory ${memoryId} does not exist.`);
312
+ const replacement = replacementId
313
+ ? await db.get("SELECT * FROM memory_entries WHERE id = ?", replacementId)
314
+ : null;
315
+ if (replacementId && !replacement) {
316
+ throw new DbError("E_NOT_FOUND", `Memory ${replacementId} does not exist, so it cannot replace ${memoryId}.`);
317
+ }
318
+ if (replacement?.superseded_by === memoryId) {
319
+ throw new DbError("E_MEMORY_SUPERSEDE", `${replacementId} is already superseded by ${memoryId}; superseding it back would make a loop.`);
320
+ }
321
+
322
+ const row = await patch(db, "memory_entry", memoryId, memory.version, {
323
+ superseded_by: replacementId ?? null,
324
+ }, {
325
+ projectId: memory.project_id,
326
+ actor,
327
+ sessionId: memory.session_id,
328
+ activitySummary: replacementId
329
+ ? `Memory ${memoryId} superseded by ${replacementId}`
330
+ : `Memory ${memoryId} is no longer superseded`,
331
+ });
332
+ return shape(row);
333
+ });
334
+ }
335
+
336
+ // ------------------------------------------------------------------ recalling
337
+
338
+ /**
339
+ * Scoring runs in application code because there is no full-text-search table.
340
+ * It is four cheap signals combined:
341
+ *
342
+ * overlap how many query tokens appear, title hits counting double, scaled
343
+ * to 0..1 so a long query does not automatically outscore a short one
344
+ * phrase a flat bonus when the whole query string appears verbatim
345
+ * recency a 30 day half life, so this week's note beats last quarter's
346
+ * weight kind and epistemic status, so a confirmed decision outranks an
347
+ * assumed summary at equal relevance
348
+ *
349
+ * With no query text, overlap and phrase are zero and the ranking is recency
350
+ * times weight, which is the right answer for "what happened here lately".
351
+ */
352
+ function scoreRow(row, tokens, phrase, now) {
353
+ // Relevance arrives from the index as a weighted term score. Coverage (how
354
+ // many of the query's terms this memory matched at all) is kept separate,
355
+ // because matching three different terms once each is a better signal than
356
+ // matching one term three times.
357
+ const matched = Number(row.matched_terms ?? 0);
358
+ const coverage = tokens.length ? matched / tokens.length : 0;
359
+ const termScore = Number(row.term_score ?? 0);
360
+ const relevance = tokens.length ? coverage * 2 + Math.min(termScore / 10, 1) : 0;
361
+
362
+ // A verbatim phrase is checked directly, which is cheap here because the
363
+ // index has already reduced the field to a small candidate set.
364
+ const haystack = `${row.title} ${row.content}`.toLowerCase();
365
+ const phraseBonus = phrase.length > 3 && haystack.includes(phrase) ? 2 : 0;
366
+
367
+ const ageDays = Math.max(0, (now - Date.parse(row.created_at)) / 86_400_000);
368
+ const recency = 2 ** (-ageDays / HALF_LIFE_DAYS);
369
+ const weight = (KIND_WEIGHT[row.kind] ?? 1) * (STATUS_WEIGHT[row.epistemic_status] ?? 1);
370
+
371
+ // Recency is additive rather than multiplicative so that age can never drive
372
+ // a strongly relevant old memory to zero. That was the failure the newest-N
373
+ // window made structural.
374
+ return { matched, score: (relevance + phraseBonus + recency) * weight };
375
+ }
376
+
377
+ /** The read half of recall, on an open connection so callers can reuse it. */
378
+ async function recallOn(db, projectId, options = {}) {
379
+ const { text = "", kinds, taskId, featureId, sessionId, includeSuperseded = false } = options;
380
+ const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_LIMIT, MAX_LIMIT));
381
+
382
+ const kindList = (Array.isArray(kinds) ? kinds : kinds ? [kinds] : []).filter((k) => KINDS.has(k));
383
+ const phrase = String(text ?? "").trim().toLowerCase();
384
+ const tokens = [...new Set(tokenize(phrase))];
385
+ const { about } = options;
386
+
387
+ const where = ["m.project_id = ?"];
388
+ const params = [projectId];
389
+ if (!includeSuperseded) where.push("m.superseded_by IS NULL");
390
+ if (kindList.length) {
391
+ where.push(`m.kind IN (${kindList.map(() => "?").join(",")})`);
392
+ params.push(...kindList);
393
+ }
394
+ if (taskId) { where.push("m.task_id = ?"); params.push(taskId); }
395
+ if (featureId) { where.push("m.feature_id = ?"); params.push(featureId); }
396
+ if (sessionId) { where.push("m.session_id = ?"); params.push(sessionId); }
397
+
398
+ // Structured recall also reaches through the links, not only the direct
399
+ // columns, so "what do we remember about this decision" actually works.
400
+ if (about && about.targetType && about.targetId) {
401
+ where.push(`EXISTS (SELECT 1 FROM memory_links l
402
+ WHERE l.memory_id = m.id AND l.target_type = ? AND l.target_id = ?)`);
403
+ params.push(about.targetType, about.targetId);
404
+ }
405
+
406
+ let rows;
407
+ if (tokens.length) {
408
+ // Candidates come through the inverted index, so a relevant memory stays
409
+ // reachable however old it is and however many newer ones exist. The
410
+ // previous newest-N window silently hid exactly the long-lived facts that
411
+ // memory is for.
412
+ rows = await db.all(
413
+ `SELECT m.*, SUM(t.frequency * t.field_weight) AS term_score,
414
+ COUNT(DISTINCT t.term) AS matched_terms
415
+ FROM memory_entries m
416
+ JOIN memory_search_terms t ON t.memory_id = m.id
417
+ WHERE ${where.join(" AND ")}
418
+ AND t.term IN (${tokens.map(() => "?").join(",")})
419
+ GROUP BY m.id
420
+ ORDER BY matched_terms DESC, term_score DESC
421
+ LIMIT ?`,
422
+ ...params, ...tokens, CANDIDATE_CAP,
423
+ );
424
+ } else {
425
+ // With no query text the honest ranking is recency times authority, which
426
+ // is what "what happened here lately" means.
427
+ rows = await db.all(
428
+ `SELECT m.*, 0 AS term_score, 0 AS matched_terms FROM memory_entries m
429
+ WHERE ${where.join(" AND ")}
430
+ ORDER BY m.created_at DESC LIMIT ?`,
431
+ ...params, CANDIDATE_CAP,
432
+ );
433
+ }
434
+
435
+ const now = Date.now();
436
+ const scored = rows
437
+ .map((row) => ({ row, ...scoreRow(row, tokens, phrase, now) }))
438
+ .filter((entry) => !tokens.length || entry.matched > 0)
439
+ .sort((a, b) => b.score - a.score || b.row.created_at.localeCompare(a.row.created_at));
440
+
441
+ const best = scored[0]?.score ?? 0;
442
+ const kept = scored.filter((entry) => entry.score >= best * RELATIVE_FLOOR).slice(0, limit);
443
+ const links = await linksOf(db, kept.map((entry) => entry.row.id));
444
+
445
+ return kept.map((entry) => shape(entry.row, {
446
+ score: Math.round(entry.score * 1000) / 1000,
447
+ links: links.get(entry.row.id) ?? [],
448
+ }));
449
+ }
450
+
451
+ async function recallLocal(root, options = {}) {
452
+ return query(root, async (db) => {
453
+ const project = await currentProject(db);
454
+ if (!project) return [];
455
+ return recallOn(db, project.id, options);
456
+ });
457
+ }
458
+
459
+ // ---------------------------------------------------------------- verifying
460
+
461
+ /**
462
+ * A recalled fact is checked against what the records say now: the linked
463
+ * specifications, the decisions that govern them, and the evidence recorded
464
+ * since the memory was written. `stillTrue` is null when there was nothing to
465
+ * check it against, because reporting true for an unverifiable claim is the
466
+ * failure this function exists to prevent.
467
+ */
468
+ async function verifyLocal(root, memoryId) {
469
+ return query(root, async (db) => {
470
+ const memory = await db.get("SELECT * FROM memory_entries WHERE id = ?", memoryId);
471
+ if (!memory) throw new DbError("E_NOT_FOUND", `Memory ${memoryId} does not exist.`);
472
+
473
+ const checkedAgainst = [];
474
+ const contradictions = [];
475
+
476
+ if (memory.superseded_by) {
477
+ contradictions.push({
478
+ targetType: "memory_entry",
479
+ targetId: memory.superseded_by,
480
+ reason: `This memory was superseded by ${memory.superseded_by}. Read that one instead.`,
481
+ });
482
+ }
483
+ if (memory.epistemic_status === "contradicted") {
484
+ contradictions.push({
485
+ targetType: "memory_entry",
486
+ targetId: memory.id,
487
+ reason: "This memory is already marked contradicted.",
488
+ });
489
+ }
490
+
491
+ const linkRows = await db.all("SELECT * FROM memory_links WHERE memory_id = ?", memoryId);
492
+ const targets = [];
493
+ const seen = new Set();
494
+ const push = (targetType, targetId) => {
495
+ const key = `${targetType}:${targetId}`;
496
+ if (!targetId || seen.has(key)) return;
497
+ seen.add(key);
498
+ targets.push({ targetType, targetId });
499
+ };
500
+ // The recorded state at link time is what makes verification honest. A link
501
+ // row carries the target's version and fingerprint as they were when the
502
+ // memory was written; without that, existence is the only available signal
503
+ // and existence proves nothing.
504
+ const recorded = new Map();
505
+ for (const row of linkRows) {
506
+ push(row.target_type, row.target_id);
507
+ recorded.set(`${row.target_type}:${row.target_id}`, {
508
+ version: row.target_version,
509
+ fingerprint: row.target_fingerprint,
510
+ });
511
+ }
512
+ push("task", memory.task_id);
513
+ push("feature", memory.feature_id);
514
+
515
+ let anyChanged = false;
516
+ let anyConfirmed = false;
517
+
518
+ for (const target of targets) {
519
+ const table = tableFor(target.targetType);
520
+ if (!table) {
521
+ checkedAgainst.push({ ...target, found: false, outcome: "unverifiable", note: "unknown record type, nothing to check against" });
522
+ continue;
523
+ }
524
+ const row = await db.get(`SELECT * FROM ${table} WHERE id = ?`, target.targetId);
525
+ if (!row) {
526
+ checkedAgainst.push({ ...target, found: false, outcome: "contradicted" });
527
+ contradictions.push({ ...target, reason: `${target.targetType} ${target.targetId} no longer exists.` });
528
+ continue;
529
+ }
530
+
531
+ const was = recorded.get(`${target.targetType}:${target.targetId}`);
532
+ const now = await targetState(db, target.targetType, target.targetId);
533
+ const movedAt = row.updated_at ?? row.accepted_at ?? null;
534
+
535
+ let outcome;
536
+ if (!was || (was.fingerprint === null && was.version === null)) {
537
+ // A link made before state capture existed, or to a record that carries
538
+ // neither. Nothing can be compared, so nothing is claimed.
539
+ outcome = "unverifiable";
540
+ } else if (was.fingerprint && now.fingerprint && was.fingerprint === now.fingerprint) {
541
+ outcome = "verified";
542
+ anyConfirmed = true;
543
+ } else {
544
+ // The record moved after the memory was written. That does not make the
545
+ // memory wrong, and it certainly does not make it right, so it is
546
+ // referred for review rather than silently passed.
547
+ outcome = "needs_review";
548
+ anyChanged = true;
549
+ }
550
+
551
+ checkedAgainst.push({
552
+ ...target,
553
+ found: true,
554
+ outcome,
555
+ status: row.status ?? null,
556
+ updatedAt: movedAt,
557
+ recordedVersion: was?.version ?? null,
558
+ currentVersion: now.version,
559
+ changedSince: outcome === "needs_review",
560
+ });
561
+
562
+ if (target.targetType === "decision" && DEAD_DECISION.has(row.status)) {
563
+ contradictions.push({ ...target, reason: `Decision ${row.id} is now ${row.status.replace(/_/g, " ")}, so it no longer governs.` });
564
+ }
565
+ if ((memory.kind === "blocker" || memory.kind === "unresolved_question") && TERMINAL_WORK.has(row.status)) {
566
+ contradictions.push({ ...target, reason: `This memory records open work, but ${target.targetType} ${row.id} is ${row.status}.` });
567
+ }
568
+ }
569
+
570
+ // Evidence recorded after the memory is the strongest live signal there is:
571
+ // a failing run after a remembered success means the memory is out of date.
572
+ const taskIds = targets.filter((t) => t.targetType === "task").map((t) => t.targetId);
573
+ if (memory.task_id && !taskIds.includes(memory.task_id)) taskIds.push(memory.task_id);
574
+ for (const taskId of taskIds) {
575
+ const evidence = await db.all(
576
+ `SELECT * FROM verification_evidence
577
+ WHERE task_id = ? AND recorded_at > ? AND status <> 'superseded'
578
+ ORDER BY recorded_at DESC LIMIT 5`,
579
+ taskId, memory.created_at,
580
+ );
581
+ for (const row of evidence) {
582
+ checkedAgainst.push({
583
+ targetType: "verification_evidence",
584
+ targetId: row.id,
585
+ found: true,
586
+ status: row.status,
587
+ result: row.result,
588
+ updatedAt: row.recorded_at,
589
+ changedSince: true,
590
+ });
591
+ if (row.result === "fail" && (memory.kind === "outcome" || memory.kind === "learned_fact")) {
592
+ contradictions.push({
593
+ targetType: "verification_evidence",
594
+ targetId: row.id,
595
+ reason: `Evidence recorded after this memory failed: ${row.summary}`,
596
+ });
597
+ }
598
+ }
599
+ }
600
+
601
+ // Four states, in precedence order. "verified" is reachable only when every
602
+ // record this memory names is byte-identical to what it was when the memory
603
+ // was written. Existence alone never gets there.
604
+ let state;
605
+ if (contradictions.length) state = "contradicted";
606
+ else if (anyChanged) state = "needs_review";
607
+ else if (anyConfirmed) state = "verified";
608
+ else state = "unverifiable";
609
+
610
+ const NOTE = {
611
+ verified: "Every record this memory names is unchanged since it was written. Confirm anything consequential in the record itself.",
612
+ contradicted: "The records disagree with this memory. Trust the records.",
613
+ needs_review: "A record this memory names has changed since it was written, and exact equivalence could not be established. Re-read the record before acting on this.",
614
+ unverifiable: "There was nothing current to check this against. Treat it as unverified and confirm by hand before acting on it.",
615
+ };
616
+
617
+ return {
618
+ memory: shape(memory, { links: linkRows.map((r) => ({ targetType: r.target_type, targetId: r.target_id, relationship: r.relationship })) }),
619
+ state,
620
+ // Kept for callers that want a tri-state, and deliberately null for both
621
+ // needs_review and unverifiable: neither is a claim of truth.
622
+ stillTrue: state === "verified" ? true : state === "contradicted" ? false : null,
623
+ checkedAgainst,
624
+ contradictions,
625
+ checkedAt: nowIso(),
626
+ note: NOTE[state],
627
+ };
628
+ });
629
+ }
630
+
631
+ // ------------------------------------------------------------------- handoff
632
+
633
+ /** Everything the next agent needs to resume, without any conversation history. */
634
+ async function handoffLocal(root, sessionId) {
635
+ return query(root, async (db) => {
636
+ const session = await db.get("SELECT * FROM work_sessions WHERE id = ?", sessionId);
637
+ if (!session) throw new DbError("E_NOT_FOUND", `Session ${sessionId} does not exist.`);
638
+
639
+ const developer = session.developer_id
640
+ ? await db.get("SELECT * FROM developers WHERE id = ?", session.developer_id) : null;
641
+ const agent = session.agent_id
642
+ ? await db.get("SELECT * FROM agents WHERE id = ?", session.agent_id) : null;
643
+ const branch = session.branch_id
644
+ ? await db.get("SELECT * FROM branches WHERE id = ?", session.branch_id) : null;
645
+ const task = session.active_task_id
646
+ ? await db.get("SELECT * FROM tasks WHERE id = ?", session.active_task_id) : null;
647
+ const feature = task?.feature_id
648
+ ? await db.get("SELECT * FROM features WHERE id = ?", task.feature_id) : null;
649
+
650
+ const contracts = task
651
+ ? await db.all(
652
+ "SELECT target_type, target_id, relationship FROM task_contract_links WHERE task_id = ?", task.id)
653
+ : [];
654
+ const dependencies = task
655
+ ? await db.all(
656
+ `SELECT t.id, t.name, t.status, d.dependency_type
657
+ FROM task_dependencies d JOIN tasks t ON t.id = d.depends_on_task_id
658
+ WHERE d.task_id = ? AND t.status NOT IN ('complete','cancelled','superseded')`, task.id)
659
+ : [];
660
+ const openSubtasks = task
661
+ ? await db.all(
662
+ `SELECT id, name, status FROM tasks
663
+ WHERE parent_task_id = ? AND status NOT IN ('complete','cancelled','superseded')
664
+ ORDER BY sequence, id`, task.id)
665
+ : [];
666
+ const evidence = task
667
+ ? await db.all(
668
+ `SELECT id, evidence_type, summary, reference, result, status, recorded_at
669
+ FROM verification_evidence WHERE task_id = ?
670
+ ORDER BY recorded_at DESC LIMIT 5`, task.id)
671
+ : [];
672
+
673
+ const acceptance = feature
674
+ ? await db.all(
675
+ `SELECT id, criterion, verification_method, status FROM feature_acceptance_criteria
676
+ WHERE feature_id = ? AND status = 'unmet' ORDER BY sequence LIMIT 20`, feature.id)
677
+ : [];
678
+
679
+ // Accepted and time-boxed decisions constrain the next agent. Project-wide
680
+ // ones always apply; scoped ones only when they name this feature.
681
+ const decisions = await db.all(
682
+ `SELECT id, title, status, decision, observable_rationale, scope_type, scope_id, expires_at
683
+ FROM decisions
684
+ WHERE project_id = ? AND status IN ('accepted','time_boxed')
685
+ AND (scope_id IS NULL OR scope_id = ?)
686
+ ORDER BY updated_at DESC LIMIT 20`,
687
+ session.project_id, feature?.id ?? "",
688
+ );
689
+
690
+ const questions = await db.all(
691
+ `SELECT id, question, why_it_matters, recommendation, scope_type, scope_id
692
+ FROM questions WHERE project_id = ? AND status = 'open'
693
+ ORDER BY CASE WHEN scope_id = ? THEN 0 ELSE 1 END, created_at DESC LIMIT 10`,
694
+ session.project_id, feature?.id ?? "",
695
+ );
696
+
697
+ const pendingDocumentation = await db.all(
698
+ `SELECT id, kind, path, sync_status FROM documents
699
+ WHERE project_id = ? AND sync_status IN ('manual_edit_pending','missing')
700
+ ORDER BY path LIMIT 20`,
701
+ session.project_id,
702
+ );
703
+
704
+ const recentActivity = await db.all(
705
+ `SELECT id, event_type, summary, actor_label, created_at, sequence
706
+ FROM activity_events
707
+ WHERE project_id = ? AND (session_id = ? OR (task_id IS NOT NULL AND task_id = ?))
708
+ ORDER BY sequence DESC LIMIT 15`,
709
+ session.project_id, session.id, task?.id ?? null,
710
+ );
711
+
712
+ const memory = await recallOn(db, session.project_id, { sessionId: session.id, limit: 12 });
713
+ const blockers = await recallOn(db, session.project_id, {
714
+ kinds: ["blocker", "unresolved_question"],
715
+ taskId: task?.id,
716
+ limit: 8,
717
+ });
718
+
719
+ return {
720
+ generatedAt: nowIso(),
721
+ session: {
722
+ id: session.id,
723
+ projectId: session.project_id,
724
+ objective: session.objective,
725
+ status: session.status,
726
+ startedAt: session.started_at,
727
+ lastActivityAt: session.last_activity_at,
728
+ endedAt: session.ended_at,
729
+ outcome: session.outcome,
730
+ handoff: session.handoff,
731
+ },
732
+ developer: developer && { id: developer.id, displayName: developer.display_name, status: developer.status },
733
+ agent: agent && { id: agent.id, harness: agent.harness, modelLabel: agent.model_label, status: agent.status, lastSeenAt: agent.last_seen_at },
734
+ branch: branch && { id: branch.id, name: branch.name, headRevision: branch.head_revision, dirty: Boolean(branch.dirty), status: branch.status },
735
+ activeTask: task && {
736
+ id: task.id,
737
+ name: task.name,
738
+ status: task.status,
739
+ priority: task.priority,
740
+ description: task.description,
741
+ expectedOutcome: task.expected_outcome,
742
+ whyNeeded: task.why_needed,
743
+ completionCriteria: json(task.completion_criteria_json),
744
+ verificationRequirements: json(task.verification_requirements_json),
745
+ affectedBoundaries: json(task.affected_boundaries_json),
746
+ blockReason: task.block_reason,
747
+ enabling: Boolean(task.enabling),
748
+ enabledFeatureId: task.enabled_feature_id,
749
+ contracts,
750
+ dependencies,
751
+ openSubtasks,
752
+ },
753
+ feature: feature && {
754
+ id: feature.id,
755
+ name: feature.name,
756
+ purpose: feature.purpose,
757
+ status: feature.status,
758
+ specDepth: feature.spec_depth,
759
+ riskLevel: feature.risk_level,
760
+ whatWorksNow: feature.what_works_now,
761
+ whatRemains: feature.what_remains,
762
+ nextAction: feature.next_action,
763
+ unmetAcceptanceCriteria: acceptance,
764
+ },
765
+ governingDecisions: decisions,
766
+ openQuestions: questions,
767
+ blockers,
768
+ memory,
769
+ evidence,
770
+ pendingDocumentation,
771
+ recentActivity,
772
+ nextAction: session.next_action ?? feature?.next_action ?? null,
773
+ note: `Memory in this packet is ${RECALL_NOTE.charAt(0).toLowerCase()}${RECALL_NOTE.slice(1)}`,
774
+ };
775
+ });
776
+ }
777
+
778
+ // ---------------------------------------------------------------------- seam
779
+
780
+ /**
781
+ * The seam a remote memory service plugs into later. Every function takes and
782
+ * returns ids that already exist elsewhere (SES-, TASK-, FEAT-, MEM-), so
783
+ * swapping the store cannot change a task or a project identity, and a remote
784
+ * store only has to satisfy these six calls.
785
+ */
786
+ export const localMemory = {
787
+ remember: rememberLocal,
788
+ recall: recallLocal,
789
+ link: linkLocal,
790
+ supersede: supersedeLocal,
791
+ handoff: handoffLocal,
792
+ verify: verifyLocal,
793
+ };
794
+
795
+ let backend = localMemory;
796
+
797
+ /** Swap the memory store. Passing nothing restores the local one. */
798
+ export function useMemoryStore(store) {
799
+ backend = store ?? localMemory;
800
+ return backend;
801
+ }
802
+
803
+ export const remember = (root, entry) => backend.remember(root, entry);
804
+ export const recall = (root, options) => backend.recall(root, options);
805
+ export const linkMemory = (root, memoryId, targetType, targetId, relationship) =>
806
+ backend.link(root, memoryId, targetType, targetId, relationship);
807
+ export const supersede = (root, memoryId, replacementId, opts) =>
808
+ backend.supersede(root, memoryId, replacementId, opts);
809
+ export const handoffPacket = (root, sessionId) => backend.handoff(root, sessionId);
810
+ export const verifyRecall = (root, memoryId) => backend.verify(root, memoryId);