@gmickel/gno 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/assets/skill/SKILL.md +17 -0
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +24 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.40.0.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +194 -0
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +127 -0
- package/src/config/types.ts +7 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/tools/index.ts +43 -0
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +80 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/routes/api.ts +167 -0
- package/src/serve/server.ts +26 -0
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +0 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remember()`: candidate matching, the add path, and supersession.
|
|
3
|
+
*
|
|
4
|
+
* Owns the shared write lease for every write (single acquisition point, no
|
|
5
|
+
* nesting — a caller that already holds the lease deadlocks/fails fast).
|
|
6
|
+
*
|
|
7
|
+
* @module src/core/memory-remember
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// node:fs/promises for mkdir (no Bun equivalent for recursive dir creation)
|
|
11
|
+
import { mkdir } from "node:fs/promises";
|
|
12
|
+
// node:path has no Bun path utilities
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import type { DocumentRow, FtsResult } from "../store/types";
|
|
16
|
+
import type {
|
|
17
|
+
MemoryCandidate,
|
|
18
|
+
MemoryCandidateMatch,
|
|
19
|
+
MemoryFact,
|
|
20
|
+
MemoryMatchDiagnostics,
|
|
21
|
+
MemoryServiceDeps,
|
|
22
|
+
MemorySyncState,
|
|
23
|
+
RememberInput,
|
|
24
|
+
RememberResult,
|
|
25
|
+
} from "./memory-types";
|
|
26
|
+
|
|
27
|
+
import { defaultSyncService, withContentTypeRules } from "../ingestion";
|
|
28
|
+
import { withWriteLock } from "./file-lock";
|
|
29
|
+
import { atomicCreate } from "./file-ops";
|
|
30
|
+
import {
|
|
31
|
+
applyFence,
|
|
32
|
+
compareCodeUnits,
|
|
33
|
+
memoryLockWaitMs,
|
|
34
|
+
memoryNow,
|
|
35
|
+
readFact,
|
|
36
|
+
requireDecision,
|
|
37
|
+
requireFactText,
|
|
38
|
+
requireIdentity,
|
|
39
|
+
requireManagedCollection,
|
|
40
|
+
requireScopes,
|
|
41
|
+
} from "./memory-fence";
|
|
42
|
+
import {
|
|
43
|
+
buildMemoryRecordId,
|
|
44
|
+
buildMemoryRecordRelPath,
|
|
45
|
+
hashMemoryText,
|
|
46
|
+
MEMORY_SUPERSEDES_EDGE,
|
|
47
|
+
memoryCosine,
|
|
48
|
+
memoryJaccard,
|
|
49
|
+
normalizeMemoryText,
|
|
50
|
+
serializeMemoryRecord,
|
|
51
|
+
type MemoryRecordFrontmatter,
|
|
52
|
+
} from "./memory-record";
|
|
53
|
+
import {
|
|
54
|
+
MEMORY_CANDIDATE_POOL,
|
|
55
|
+
MEMORY_LEXICAL_LIKELY_THRESHOLD,
|
|
56
|
+
MEMORY_SEMANTIC_LIKELY_THRESHOLD,
|
|
57
|
+
MemoryError,
|
|
58
|
+
} from "./memory-types";
|
|
59
|
+
|
|
60
|
+
export interface CandidateQuery {
|
|
61
|
+
text: string;
|
|
62
|
+
collection: string;
|
|
63
|
+
scopes: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Candidate matching
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Candidate pool: BM25 top-16 (any-term) within the scope intersection,
|
|
72
|
+
* current facts only, materialized as records.
|
|
73
|
+
*/
|
|
74
|
+
async function loadCandidatePool(
|
|
75
|
+
deps: MemoryServiceDeps,
|
|
76
|
+
input: CandidateQuery
|
|
77
|
+
): Promise<MemoryFact[]> {
|
|
78
|
+
const { store } = deps;
|
|
79
|
+
const ftsResult = await store.searchFts(normalizeMemoryText(input.text), {
|
|
80
|
+
limit: MEMORY_CANDIDATE_POOL,
|
|
81
|
+
collection: input.collection,
|
|
82
|
+
memoryScopesAny: input.scopes,
|
|
83
|
+
excludeSuperseded: true,
|
|
84
|
+
anyTerm: true,
|
|
85
|
+
snippet: false,
|
|
86
|
+
});
|
|
87
|
+
// INVALID_INPUT means the text has no searchable terms: an empty pool.
|
|
88
|
+
if (!ftsResult.ok && ftsResult.error.code !== "INVALID_INPUT") {
|
|
89
|
+
throw new MemoryError("MEMORY_QUERY_FAILED", ftsResult.error.message);
|
|
90
|
+
}
|
|
91
|
+
const rows: FtsResult[] = ftsResult.ok ? ftsResult.value : [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const facts: MemoryFact[] = [];
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
if (!row.uri || !row.docid || seen.has(row.uri)) continue;
|
|
96
|
+
seen.add(row.uri);
|
|
97
|
+
const fact = await readFact(store, {
|
|
98
|
+
uri: row.uri,
|
|
99
|
+
docid: row.docid,
|
|
100
|
+
mirrorHash: row.mirrorHash,
|
|
101
|
+
});
|
|
102
|
+
if (fact) facts.push(fact);
|
|
103
|
+
}
|
|
104
|
+
return facts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Exact-duplicate lookup (same normalized-text hash) in the candidate pool.
|
|
109
|
+
* Re-run under the write lease so two concurrent adds of the same text
|
|
110
|
+
* cannot both write.
|
|
111
|
+
*/
|
|
112
|
+
async function findExactCurrent(
|
|
113
|
+
deps: MemoryServiceDeps,
|
|
114
|
+
input: CandidateQuery & { contentHash: string }
|
|
115
|
+
): Promise<MemoryFact | null> {
|
|
116
|
+
const pool = await loadCandidatePool(deps, input);
|
|
117
|
+
return pool.find((fact) => fact.contentHash === input.contentHash) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Candidates with similarity: cosine when semantic is ready, else
|
|
122
|
+
* normalized-token Jaccard. Ordered by similarity desc, ties by recordId.
|
|
123
|
+
*/
|
|
124
|
+
export async function findMemoryCandidates(
|
|
125
|
+
deps: MemoryServiceDeps,
|
|
126
|
+
input: CandidateQuery
|
|
127
|
+
): Promise<{
|
|
128
|
+
candidates: MemoryCandidate[];
|
|
129
|
+
matching: MemoryMatchDiagnostics;
|
|
130
|
+
}> {
|
|
131
|
+
const normalizedText = normalizeMemoryText(input.text);
|
|
132
|
+
const facts = await loadCandidatePool(deps, input);
|
|
133
|
+
|
|
134
|
+
const incomingHash = hashMemoryText(input.text);
|
|
135
|
+
let matching: MemoryMatchDiagnostics = {
|
|
136
|
+
mode: "lexical",
|
|
137
|
+
threshold: MEMORY_LEXICAL_LIKELY_THRESHOLD,
|
|
138
|
+
};
|
|
139
|
+
let similarities: number[] | null = null;
|
|
140
|
+
const embedPort = deps.embedPort ?? null;
|
|
141
|
+
if (embedPort && facts.length > 0) {
|
|
142
|
+
const embedded = await embedPort.embedBatch([
|
|
143
|
+
normalizedText,
|
|
144
|
+
...facts.map((fact) => normalizeMemoryText(fact.text)),
|
|
145
|
+
]);
|
|
146
|
+
if (embedded.ok && embedded.value.length === facts.length + 1) {
|
|
147
|
+
const [query, ...vectors] = embedded.value;
|
|
148
|
+
similarities = vectors.map((vector) => memoryCosine(query ?? [], vector));
|
|
149
|
+
matching = {
|
|
150
|
+
mode: "semantic",
|
|
151
|
+
threshold: MEMORY_SEMANTIC_LIKELY_THRESHOLD,
|
|
152
|
+
};
|
|
153
|
+
} else {
|
|
154
|
+
matching.semanticUnavailable = embedded.ok
|
|
155
|
+
? "embedding batch returned an unexpected shape"
|
|
156
|
+
: embedded.error.message;
|
|
157
|
+
}
|
|
158
|
+
} else if (!embedPort) {
|
|
159
|
+
matching.semanticUnavailable = "no embedding model available";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const candidates: MemoryCandidate[] = facts.map((fact, index) => {
|
|
163
|
+
const exact = fact.contentHash === incomingHash;
|
|
164
|
+
const similarity = exact
|
|
165
|
+
? 1
|
|
166
|
+
: (similarities?.[index] ?? memoryJaccard(input.text, fact.text));
|
|
167
|
+
const match: MemoryCandidateMatch = exact
|
|
168
|
+
? "exact"
|
|
169
|
+
: similarity >= matching.threshold
|
|
170
|
+
? "likely"
|
|
171
|
+
: "weak";
|
|
172
|
+
return { ...fact, similarity, match };
|
|
173
|
+
});
|
|
174
|
+
candidates.sort(
|
|
175
|
+
(left, right) =>
|
|
176
|
+
right.similarity - left.similarity ||
|
|
177
|
+
compareCodeUnits(left.recordId, right.recordId)
|
|
178
|
+
);
|
|
179
|
+
return { candidates, matching };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// Supersession checks (under the lease)
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/** Under the lease: predecessor exists, hash matches, no successor yet. */
|
|
187
|
+
async function verifyPredecessor(
|
|
188
|
+
deps: MemoryServiceDeps,
|
|
189
|
+
collection: string,
|
|
190
|
+
predecessorUri: string,
|
|
191
|
+
predecessorHash: string
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
const { store } = deps;
|
|
194
|
+
const docResult = await store.getDocumentByUri(predecessorUri.trim());
|
|
195
|
+
if (!docResult.ok) {
|
|
196
|
+
throw new MemoryError("MEMORY_QUERY_FAILED", docResult.error.message);
|
|
197
|
+
}
|
|
198
|
+
const doc = docResult.value;
|
|
199
|
+
if (!doc || !doc.active || doc.collection !== collection) {
|
|
200
|
+
throw new MemoryError(
|
|
201
|
+
"MEMORY_PREDECESSOR_NOT_FOUND",
|
|
202
|
+
`Predecessor ${predecessorUri} is not a current record in collection "${collection}".`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const predecessor = await readFact(store, doc);
|
|
206
|
+
if (!predecessor) {
|
|
207
|
+
throw new MemoryError(
|
|
208
|
+
"MEMORY_PREDECESSOR_NOT_FOUND",
|
|
209
|
+
`Predecessor ${predecessorUri} is not a valid managed memory record.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (predecessor.contentHash !== predecessorHash) {
|
|
213
|
+
throw new MemoryError(
|
|
214
|
+
"MEMORY_PREDECESSOR_HASH_MISMATCH",
|
|
215
|
+
`Predecessor ${predecessorUri} has content hash ${predecessor.contentHash}, not ${predecessorHash}. Recall it again before superseding.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const successors = await store.getEdgeBacklinksForDoc(doc.id, {
|
|
219
|
+
edgeType: MEMORY_SUPERSEDES_EDGE,
|
|
220
|
+
});
|
|
221
|
+
if (!successors.ok) {
|
|
222
|
+
throw new MemoryError("MEMORY_QUERY_FAILED", successors.error.message);
|
|
223
|
+
}
|
|
224
|
+
if (successors.value.length > 0) {
|
|
225
|
+
const successor = successors.value[0];
|
|
226
|
+
throw new MemoryError(
|
|
227
|
+
"MEMORY_SUPERSEDE_CONFLICT",
|
|
228
|
+
`Predecessor ${predecessorUri} was already superseded by ${successor?.sourceUri ?? "another record"}. Recall the current fact and decide again.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return doc.uri;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Under the lease, after sync: every predecessor URI has a projected edge. */
|
|
235
|
+
async function supersedesEdgeProjected(
|
|
236
|
+
deps: MemoryServiceDeps,
|
|
237
|
+
successorDocId: number,
|
|
238
|
+
predecessorUris: readonly string[]
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
const edges = await deps.store.getEdgesForDoc(successorDocId, {
|
|
241
|
+
edgeType: MEMORY_SUPERSEDES_EDGE,
|
|
242
|
+
});
|
|
243
|
+
if (!edges.ok) return false;
|
|
244
|
+
const targets = new Set(edges.value.map((edge) => edge.targetUri));
|
|
245
|
+
return predecessorUris.every((uri) => targets.has(uri));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
249
|
+
// remember
|
|
250
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
export async function rememberFact(
|
|
253
|
+
deps: MemoryServiceDeps,
|
|
254
|
+
rawInput: RememberInput
|
|
255
|
+
): Promise<RememberResult> {
|
|
256
|
+
const { store, collections, config } = deps;
|
|
257
|
+
const identity = requireIdentity(rawInput);
|
|
258
|
+
const text = requireFactText(rawInput.text);
|
|
259
|
+
const collection = requireManagedCollection(collections, rawInput.collection);
|
|
260
|
+
const scopes = requireScopes(rawInput.scopes);
|
|
261
|
+
const decision = requireDecision(rawInput.decision);
|
|
262
|
+
const contentHash = hashMemoryText(text);
|
|
263
|
+
applyFence(rawInput, contentHash);
|
|
264
|
+
|
|
265
|
+
if (decision === "supersede") {
|
|
266
|
+
if (!rawInput.predecessorUri?.trim() || !rawInput.predecessorHash) {
|
|
267
|
+
throw new MemoryError(
|
|
268
|
+
"MEMORY_PREDECESSOR_REQUIRED",
|
|
269
|
+
"supersede requires predecessorUri and predecessorHash."
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { candidates, matching } = await findMemoryCandidates(deps, {
|
|
275
|
+
text,
|
|
276
|
+
collection: collection.name,
|
|
277
|
+
scopes,
|
|
278
|
+
});
|
|
279
|
+
const exact = candidates.find((candidate) => candidate.match === "exact");
|
|
280
|
+
if (exact && decision !== "supersede") {
|
|
281
|
+
const { similarity: _similarity, match: _match, ...record } = exact;
|
|
282
|
+
return { outcome: "existing", record, matching };
|
|
283
|
+
}
|
|
284
|
+
if (decision === undefined) {
|
|
285
|
+
return { outcome: "candidates", candidates, matching };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const createdAt = memoryNow(deps).toISOString();
|
|
289
|
+
const source = rawInput.source?.trim() || undefined;
|
|
290
|
+
const frontmatter: MemoryRecordFrontmatter = {
|
|
291
|
+
recordId: buildMemoryRecordId({
|
|
292
|
+
contentHash,
|
|
293
|
+
createdAt,
|
|
294
|
+
caller: identity.caller,
|
|
295
|
+
session: identity.session,
|
|
296
|
+
}),
|
|
297
|
+
scopes,
|
|
298
|
+
caller: identity.caller,
|
|
299
|
+
session: identity.session,
|
|
300
|
+
createdAt,
|
|
301
|
+
contentHash,
|
|
302
|
+
...(source ? { source } : {}),
|
|
303
|
+
};
|
|
304
|
+
const relPath = buildMemoryRecordRelPath(frontmatter);
|
|
305
|
+
const absPath = join(collection.path, relPath);
|
|
306
|
+
const lockWaitMs = memoryLockWaitMs(deps);
|
|
307
|
+
|
|
308
|
+
let leased: RememberResult;
|
|
309
|
+
try {
|
|
310
|
+
leased = await withWriteLock(
|
|
311
|
+
deps.lockPath,
|
|
312
|
+
async () => {
|
|
313
|
+
const supersedes: string[] = [];
|
|
314
|
+
if (decision === "supersede") {
|
|
315
|
+
supersedes.push(
|
|
316
|
+
await verifyPredecessor(
|
|
317
|
+
deps,
|
|
318
|
+
collection.name,
|
|
319
|
+
rawInput.predecessorUri as string,
|
|
320
|
+
rawInput.predecessorHash as string
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
} else {
|
|
324
|
+
// The pre-lease check raced with any concurrent writer; decide
|
|
325
|
+
// idempotency on the state visible under the lease.
|
|
326
|
+
const existing = await findExactCurrent(deps, {
|
|
327
|
+
text,
|
|
328
|
+
collection: collection.name,
|
|
329
|
+
scopes,
|
|
330
|
+
contentHash,
|
|
331
|
+
});
|
|
332
|
+
if (existing) {
|
|
333
|
+
return { outcome: "existing", record: existing, matching };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
337
|
+
await atomicCreate(
|
|
338
|
+
absPath,
|
|
339
|
+
serializeMemoryRecord({ frontmatter, supersedes, text })
|
|
340
|
+
);
|
|
341
|
+
// syncPaths (not syncFiles) so typed-edge projection errors surface.
|
|
342
|
+
const syncResult = await (
|
|
343
|
+
deps.syncService ?? defaultSyncService
|
|
344
|
+
).syncPaths(
|
|
345
|
+
collection,
|
|
346
|
+
store,
|
|
347
|
+
[relPath],
|
|
348
|
+
withContentTypeRules({ runUpdateCmd: false, gitPull: false }, config)
|
|
349
|
+
);
|
|
350
|
+
const fileResult = syncResult.files?.[0];
|
|
351
|
+
const doc = await store.getDocument(collection.name, relPath);
|
|
352
|
+
const sync: MemorySyncState =
|
|
353
|
+
fileResult?.status === "error" || !doc.ok || doc.value === null
|
|
354
|
+
? {
|
|
355
|
+
status: "failed",
|
|
356
|
+
error:
|
|
357
|
+
fileResult?.errorMessage ??
|
|
358
|
+
fileResult?.errorCode ??
|
|
359
|
+
"memory record was written but is not retrievable yet",
|
|
360
|
+
}
|
|
361
|
+
: { status: "completed" };
|
|
362
|
+
if (sync.status === "failed") {
|
|
363
|
+
throw new MemoryError(
|
|
364
|
+
"MEMORY_SYNC_FAILED",
|
|
365
|
+
`Memory record written to ${absPath} but lexical sync failed: ${sync.error}. Run gno update to retry indexing.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const written = (doc as { value: DocumentRow }).value;
|
|
369
|
+
const projectionErrors = syncResult.errors
|
|
370
|
+
.map((error) => `${error.relPath}: ${error.message}`)
|
|
371
|
+
.join("; ");
|
|
372
|
+
if (decision === "supersede") {
|
|
373
|
+
// The write is only a supersession once the edge is projected;
|
|
374
|
+
// until then the predecessor still reads as current.
|
|
375
|
+
const projected =
|
|
376
|
+
projectionErrors.length === 0 &&
|
|
377
|
+
(await supersedesEdgeProjected(deps, written.id, supersedes));
|
|
378
|
+
if (!projected) {
|
|
379
|
+
throw new MemoryError(
|
|
380
|
+
"MEMORY_SUPERSEDE_PROJECTION_FAILED",
|
|
381
|
+
`Successor written to ${absPath} but its supersedes edge did not project${projectionErrors ? ` (${projectionErrors})` : ""}; the predecessor still reads as current. Run gno update to retry the projection.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
} else if (projectionErrors.length > 0) {
|
|
385
|
+
throw new MemoryError(
|
|
386
|
+
"MEMORY_SYNC_FAILED",
|
|
387
|
+
`Memory record written to ${absPath} but typed-edge projection failed: ${projectionErrors}. Run gno update to retry indexing.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const record: MemoryFact = {
|
|
391
|
+
uri: written.uri,
|
|
392
|
+
docid: written.docid,
|
|
393
|
+
recordId: frontmatter.recordId,
|
|
394
|
+
text,
|
|
395
|
+
scopes,
|
|
396
|
+
caller: identity.caller,
|
|
397
|
+
session: identity.session,
|
|
398
|
+
createdAt,
|
|
399
|
+
contentHash,
|
|
400
|
+
supersedes,
|
|
401
|
+
...(source ? { source } : {}),
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
outcome: decision === "supersede" ? "superseded" : "added",
|
|
405
|
+
record,
|
|
406
|
+
absPath,
|
|
407
|
+
sync,
|
|
408
|
+
matching,
|
|
409
|
+
};
|
|
410
|
+
},
|
|
411
|
+
lockWaitMs
|
|
412
|
+
);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error instanceof MemoryError) throw error;
|
|
415
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
416
|
+
if (message.startsWith("LOCKED")) {
|
|
417
|
+
throw new MemoryError(
|
|
418
|
+
"MEMORY_WRITE_LEASE_BUSY",
|
|
419
|
+
`Could not acquire the shared write lease at ${deps.lockPath} within ${lockWaitMs}ms: another write holds it. The memory service takes the lease itself; callers must not pre-hold it.`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
return leased;
|
|
425
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory service contracts: binding defaults, the stable error code set, and
|
|
3
|
+
* the input/result shapes shared by every surface (CLI, MCP, REST, SDK).
|
|
4
|
+
*
|
|
5
|
+
* Import these through `src/core/memory` (the facade re-exports them); this
|
|
6
|
+
* module exists so the remember/recall implementations can share the types
|
|
7
|
+
* without importing the service class.
|
|
8
|
+
*
|
|
9
|
+
* @module src/core/memory-types
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Collection, Config } from "../config/types";
|
|
13
|
+
import type { defaultSyncService } from "../ingestion";
|
|
14
|
+
import type { EmbeddingPort } from "../llm/types";
|
|
15
|
+
import type { StorePort } from "../store/types";
|
|
16
|
+
import type { VectorIndexPort } from "../store/vector/types";
|
|
17
|
+
import type { EgressLineage } from "./egress-provenance";
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Binding defaults (tunable here, documented in docs/MEMORY.md)
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/** BM25 candidate pool size for remember() candidate matching. */
|
|
24
|
+
export const MEMORY_CANDIDATE_POOL = 16;
|
|
25
|
+
/** Cosine threshold for a semantic likely-match. */
|
|
26
|
+
export const MEMORY_SEMANTIC_LIKELY_THRESHOLD = 0.83;
|
|
27
|
+
/** Normalized-token Jaccard threshold for a lexical likely-match. */
|
|
28
|
+
export const MEMORY_LEXICAL_LIKELY_THRESHOLD = 0.5;
|
|
29
|
+
/** Default recall budget. */
|
|
30
|
+
export const MEMORY_RECALL_MAX_FACTS = 8;
|
|
31
|
+
export const MEMORY_RECALL_MAX_TOKENS = 512;
|
|
32
|
+
/** Retrieval depth per leg before fusion and budgeting. */
|
|
33
|
+
export const MEMORY_RECALL_RETRIEVAL_LIMIT = 32;
|
|
34
|
+
export const MEMORY_RRF_K = 60;
|
|
35
|
+
export const MEMORY_DEFAULT_LOCK_WAIT_MS = 120_000;
|
|
36
|
+
export const MEMORY_TOKEN_BYTES_ESTIMATE = 4;
|
|
37
|
+
|
|
38
|
+
export const MEMORY_EMPTY_RECALL_HINT =
|
|
39
|
+
'No memories in scope yet. Store one with: gno remember "<fact>" --scope <scope> --decision add';
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Errors
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export type MemoryErrorCode =
|
|
46
|
+
| "MEMORY_TEXT_REQUIRED"
|
|
47
|
+
| "MEMORY_TEXT_TOO_LARGE"
|
|
48
|
+
| "MEMORY_QUERY_REQUIRED"
|
|
49
|
+
| "MEMORY_BUDGET_INVALID"
|
|
50
|
+
| "MEMORY_COLLECTION_REQUIRED"
|
|
51
|
+
| "MEMORY_COLLECTION_NOT_FOUND"
|
|
52
|
+
| "MEMORY_COLLECTION_UNMANAGED"
|
|
53
|
+
| "MEMORY_SCOPES_REQUIRED"
|
|
54
|
+
| "MEMORY_SCOPES_INVALID"
|
|
55
|
+
| "MEMORY_IDENTITY_REQUIRED"
|
|
56
|
+
| "MEMORY_DECISION_INVALID"
|
|
57
|
+
| "MEMORY_PREDECESSOR_REQUIRED"
|
|
58
|
+
| "MEMORY_PREDECESSOR_NOT_FOUND"
|
|
59
|
+
| "MEMORY_PREDECESSOR_HASH_MISMATCH"
|
|
60
|
+
| "MEMORY_SUPERSEDE_CONFLICT"
|
|
61
|
+
| "MEMORY_SUPERSEDE_PROJECTION_FAILED"
|
|
62
|
+
| "MEMORY_FENCED_REPLAY"
|
|
63
|
+
| "MEMORY_FENCED_DERIVED"
|
|
64
|
+
| "MEMORY_WRITE_LEASE_BUSY"
|
|
65
|
+
| "MEMORY_SYNC_FAILED"
|
|
66
|
+
| "MEMORY_QUERY_FAILED";
|
|
67
|
+
|
|
68
|
+
export class MemoryError extends Error {
|
|
69
|
+
readonly code: MemoryErrorCode;
|
|
70
|
+
|
|
71
|
+
constructor(code: MemoryErrorCode, message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "MemoryError";
|
|
74
|
+
this.code = code;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
// Contracts
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export interface MemoryIdentity {
|
|
83
|
+
caller: string;
|
|
84
|
+
session: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Content-free receipt bound to caller + session; travels with recall. */
|
|
88
|
+
export interface MemoryRecallReceipt extends MemoryIdentity {
|
|
89
|
+
issuedAt: string;
|
|
90
|
+
memoryIds: string[];
|
|
91
|
+
spanHashes: string[];
|
|
92
|
+
digest: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type MemoryDecision = "add" | "supersede";
|
|
96
|
+
|
|
97
|
+
export interface RememberInput extends MemoryIdentity {
|
|
98
|
+
text: string;
|
|
99
|
+
collection: string;
|
|
100
|
+
scopes: string[];
|
|
101
|
+
/** Absent → candidate proposal only (writes nothing). */
|
|
102
|
+
decision?: MemoryDecision;
|
|
103
|
+
predecessorUri?: string;
|
|
104
|
+
predecessorHash?: string;
|
|
105
|
+
receipt?: MemoryRecallReceipt;
|
|
106
|
+
/** Declared origins; any gno:// origin is fenced. */
|
|
107
|
+
derivedFrom?: string[];
|
|
108
|
+
source?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface MemoryFact {
|
|
112
|
+
uri: string;
|
|
113
|
+
docid: string;
|
|
114
|
+
recordId: string;
|
|
115
|
+
text: string;
|
|
116
|
+
scopes: string[];
|
|
117
|
+
caller: string;
|
|
118
|
+
session: string;
|
|
119
|
+
createdAt: string;
|
|
120
|
+
contentHash: string;
|
|
121
|
+
supersedes: string[];
|
|
122
|
+
/** Free-text evidence recorded with the fact, when one was given. */
|
|
123
|
+
source?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type MemoryCandidateMatch = "exact" | "likely" | "weak";
|
|
127
|
+
|
|
128
|
+
export interface MemoryCandidate extends MemoryFact {
|
|
129
|
+
similarity: number;
|
|
130
|
+
match: MemoryCandidateMatch;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type MemoryMatchMode = "semantic" | "lexical";
|
|
134
|
+
|
|
135
|
+
export interface MemoryMatchDiagnostics {
|
|
136
|
+
mode: MemoryMatchMode;
|
|
137
|
+
/** Present when semantic matching was unavailable and lexical was used. */
|
|
138
|
+
semanticUnavailable?: string;
|
|
139
|
+
threshold: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface MemorySyncState {
|
|
143
|
+
status: "completed" | "failed";
|
|
144
|
+
error?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type RememberResult =
|
|
148
|
+
| {
|
|
149
|
+
outcome: "existing";
|
|
150
|
+
record: MemoryFact;
|
|
151
|
+
matching: MemoryMatchDiagnostics;
|
|
152
|
+
}
|
|
153
|
+
| {
|
|
154
|
+
outcome: "candidates";
|
|
155
|
+
candidates: MemoryCandidate[];
|
|
156
|
+
matching: MemoryMatchDiagnostics;
|
|
157
|
+
}
|
|
158
|
+
| {
|
|
159
|
+
outcome: "added" | "superseded";
|
|
160
|
+
record: MemoryFact;
|
|
161
|
+
absPath: string;
|
|
162
|
+
sync: MemorySyncState;
|
|
163
|
+
matching: MemoryMatchDiagnostics;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export interface RecallInput extends MemoryIdentity {
|
|
167
|
+
query: string;
|
|
168
|
+
collection: string;
|
|
169
|
+
scopes: string[];
|
|
170
|
+
maxFacts?: number;
|
|
171
|
+
maxTokens?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RecalledFact extends MemoryFact {
|
|
175
|
+
score: number;
|
|
176
|
+
spanHash: string;
|
|
177
|
+
egressLineage: EgressLineage;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface RecallResult {
|
|
181
|
+
facts: RecalledFact[];
|
|
182
|
+
receipt: MemoryRecallReceipt;
|
|
183
|
+
budget: {
|
|
184
|
+
maxFacts: number;
|
|
185
|
+
maxTokens: number;
|
|
186
|
+
usedTokens: number;
|
|
187
|
+
omitted: number;
|
|
188
|
+
};
|
|
189
|
+
retrieval: {
|
|
190
|
+
mode: "hybrid" | "lexical";
|
|
191
|
+
semanticUnavailable?: string;
|
|
192
|
+
};
|
|
193
|
+
/** Strictest source policy across every returned fact (absent when empty). */
|
|
194
|
+
egressLineage?: EgressLineage;
|
|
195
|
+
/** Self-teaching line, present only when no fact was returned. */
|
|
196
|
+
hint?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface MemoryServiceDeps {
|
|
200
|
+
store: StorePort;
|
|
201
|
+
config: Config;
|
|
202
|
+
collections: readonly Collection[];
|
|
203
|
+
/** Absolute `.mcp-write.lock` path (shared write lease namespace). */
|
|
204
|
+
lockPath: string;
|
|
205
|
+
lockWaitMs?: number;
|
|
206
|
+
embedPort?: EmbeddingPort | null;
|
|
207
|
+
vectorIndex?: VectorIndexPort | null;
|
|
208
|
+
/** Must surface typed-edge projection errors (`syncPaths`, not `syncFiles`). */
|
|
209
|
+
syncService?: Pick<typeof defaultSyncService, "syncPaths">;
|
|
210
|
+
now?: () => Date;
|
|
211
|
+
}
|