@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,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed memory record contract: frontmatter shape, scope normalization,
|
|
3
|
+
* text normalization, similarity primitives, and the malformed-file validator.
|
|
4
|
+
*
|
|
5
|
+
* One fact per markdown file. Files are canonical; every derived row (scopes,
|
|
6
|
+
* supersedes edges, FTS) is rebuilt from the file by ingestion.
|
|
7
|
+
*
|
|
8
|
+
* @module src/core/memory-record
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const MEMORY_MAX_SCOPES = 8;
|
|
12
|
+
export const MEMORY_MAX_SCOPE_CHARS = 64;
|
|
13
|
+
export const MEMORY_MAX_FACT_BYTES = 4096;
|
|
14
|
+
export const MEMORY_SUPERSEDES_EDGE = "supersedes";
|
|
15
|
+
export const MEMORY_FRONTMATTER_KEY = "memory";
|
|
16
|
+
export const MEMORY_RECORD_ID_PREFIX = "mem-";
|
|
17
|
+
|
|
18
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)(?:\r?\n)?---(?:\r?\n|$)/;
|
|
19
|
+
const SCOPE_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._:/@-]*$/u;
|
|
20
|
+
const RECORD_ID_PATTERN = /^mem-[0-9a-f]{16}$/;
|
|
21
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
22
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
23
|
+
const TOKEN_SPLIT = /[^\p{L}\p{N}]+/u;
|
|
24
|
+
const URI_PREFIX = "gno://";
|
|
25
|
+
|
|
26
|
+
export type MemoryDiagnosticCode =
|
|
27
|
+
| "MEMORY_FRONTMATTER_MISSING"
|
|
28
|
+
| "MEMORY_FRONTMATTER_INVALID"
|
|
29
|
+
| "MEMORY_RECORD_ID_INVALID"
|
|
30
|
+
| "MEMORY_SCOPES_INVALID"
|
|
31
|
+
| "MEMORY_SCOPES_EMPTY"
|
|
32
|
+
| "MEMORY_IDENTITY_MISSING"
|
|
33
|
+
| "MEMORY_CREATED_AT_INVALID"
|
|
34
|
+
| "MEMORY_CONTENT_HASH_INVALID"
|
|
35
|
+
| "MEMORY_CONTENT_HASH_MISMATCH"
|
|
36
|
+
| "MEMORY_BODY_EMPTY"
|
|
37
|
+
| "MEMORY_SUPERSEDES_INVALID";
|
|
38
|
+
|
|
39
|
+
export interface MemoryDiagnostic {
|
|
40
|
+
code: MemoryDiagnosticCode;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The frontmatter block every managed memory record must carry. */
|
|
45
|
+
export interface MemoryRecordFrontmatter {
|
|
46
|
+
recordId: string;
|
|
47
|
+
scopes: string[];
|
|
48
|
+
caller: string;
|
|
49
|
+
session: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
contentHash: string;
|
|
52
|
+
/** Free-text evidence for the fact (where it came from), when given. */
|
|
53
|
+
source?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ParsedMemoryRecord {
|
|
57
|
+
frontmatter: MemoryRecordFrontmatter;
|
|
58
|
+
/** gno:// URIs of predecessors this record supersedes (may be empty). */
|
|
59
|
+
supersedes: string[];
|
|
60
|
+
/** Fact text with frontmatter stripped and trailing whitespace trimmed. */
|
|
61
|
+
text: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type MemoryRecordValidation =
|
|
65
|
+
| { ok: true; record: ParsedMemoryRecord }
|
|
66
|
+
| { ok: false; diagnostics: MemoryDiagnostic[] };
|
|
67
|
+
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Normalization primitives
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** Trim, lowercase, NFC, dedupe; order-preserving on first occurrence. */
|
|
73
|
+
export function normalizeMemoryScopes(scopes: readonly string[]): string[] {
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const normalized: string[] = [];
|
|
76
|
+
for (const raw of scopes) {
|
|
77
|
+
const scope = raw.normalize("NFC").trim().toLowerCase();
|
|
78
|
+
if (scope.length === 0 || seen.has(scope)) continue;
|
|
79
|
+
seen.add(scope);
|
|
80
|
+
normalized.push(scope);
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Validation message for a normalized scope, or null when acceptable. */
|
|
86
|
+
export function invalidMemoryScopeReason(scope: string): string | null {
|
|
87
|
+
if (scope.length > MEMORY_MAX_SCOPE_CHARS) {
|
|
88
|
+
return `scope "${scope}" exceeds ${MEMORY_MAX_SCOPE_CHARS} characters`;
|
|
89
|
+
}
|
|
90
|
+
if (!SCOPE_PATTERN.test(scope)) {
|
|
91
|
+
return `scope "${scope}" must start with a letter or digit and contain only letters, digits, . _ : / @ -`;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Any-intersection visibility: a fact is visible when scopes overlap. */
|
|
97
|
+
export function memoryScopesIntersect(
|
|
98
|
+
factScopes: readonly string[],
|
|
99
|
+
requested: readonly string[]
|
|
100
|
+
): boolean {
|
|
101
|
+
const wanted = new Set(normalizeMemoryScopes(requested));
|
|
102
|
+
return normalizeMemoryScopes(factScopes).some((scope) => wanted.has(scope));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Trim, collapse whitespace runs, NFC. Exact-duplicate identity. */
|
|
106
|
+
export function normalizeMemoryText(text: string): string {
|
|
107
|
+
return text.normalize("NFC").replace(WHITESPACE_RUN, " ").trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function hashMemoryText(text: string): string {
|
|
111
|
+
return new Bun.CryptoHasher("sha256")
|
|
112
|
+
.update(normalizeMemoryText(text))
|
|
113
|
+
.digest("hex");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Lowercased letter/digit tokens, deduplicated. Corpus-independent. */
|
|
117
|
+
export function memoryTokenSet(text: string): Set<string> {
|
|
118
|
+
return new Set(
|
|
119
|
+
normalizeMemoryText(text)
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.split(TOKEN_SPLIT)
|
|
122
|
+
.filter((token) => token.length > 0)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Jaccard similarity over normalized token sets (lexical likely-match). */
|
|
127
|
+
export function memoryJaccard(left: string, right: string): number {
|
|
128
|
+
const a = memoryTokenSet(left);
|
|
129
|
+
const b = memoryTokenSet(right);
|
|
130
|
+
if (a.size === 0 && b.size === 0) return 1;
|
|
131
|
+
let intersection = 0;
|
|
132
|
+
for (const token of a) if (b.has(token)) intersection += 1;
|
|
133
|
+
const union = a.size + b.size - intersection;
|
|
134
|
+
return union === 0 ? 0 : intersection / union;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function memoryCosine(
|
|
138
|
+
left: readonly number[],
|
|
139
|
+
right: readonly number[]
|
|
140
|
+
): number {
|
|
141
|
+
const length = Math.min(left.length, right.length);
|
|
142
|
+
let dot = 0;
|
|
143
|
+
let normLeft = 0;
|
|
144
|
+
let normRight = 0;
|
|
145
|
+
for (let index = 0; index < length; index += 1) {
|
|
146
|
+
const a = left[index] ?? 0;
|
|
147
|
+
const b = right[index] ?? 0;
|
|
148
|
+
dot += a * b;
|
|
149
|
+
normLeft += a * a;
|
|
150
|
+
normRight += b * b;
|
|
151
|
+
}
|
|
152
|
+
if (normLeft === 0 || normRight === 0) return 0;
|
|
153
|
+
return dot / (Math.sqrt(normLeft) * Math.sqrt(normRight));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
// Record identity and serialization
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export function buildMemoryRecordId(input: {
|
|
161
|
+
contentHash: string;
|
|
162
|
+
createdAt: string;
|
|
163
|
+
caller: string;
|
|
164
|
+
session: string;
|
|
165
|
+
}): string {
|
|
166
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
167
|
+
.update(
|
|
168
|
+
[input.contentHash, input.createdAt, input.caller, input.session].join(
|
|
169
|
+
"\n"
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
.digest("hex");
|
|
173
|
+
return `${MEMORY_RECORD_ID_PREFIX}${digest.slice(0, 16)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Deterministic relative path: one fact file per record. */
|
|
177
|
+
export function buildMemoryRecordRelPath(
|
|
178
|
+
frontmatter: Pick<MemoryRecordFrontmatter, "recordId" | "createdAt">
|
|
179
|
+
): string {
|
|
180
|
+
return `facts/${frontmatter.createdAt.slice(0, 10)}/${frontmatter.recordId}.md`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function yamlString(value: string): string {
|
|
184
|
+
return JSON.stringify(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Serialize the canonical fact file content. */
|
|
188
|
+
export function serializeMemoryRecord(input: {
|
|
189
|
+
frontmatter: MemoryRecordFrontmatter;
|
|
190
|
+
supersedes: string[];
|
|
191
|
+
text: string;
|
|
192
|
+
}): string {
|
|
193
|
+
const { frontmatter } = input;
|
|
194
|
+
const text = input.text.trim();
|
|
195
|
+
const title = normalizeMemoryText(text).slice(0, 80);
|
|
196
|
+
const lines = [
|
|
197
|
+
"---",
|
|
198
|
+
`title: ${yamlString(title)}`,
|
|
199
|
+
`${MEMORY_FRONTMATTER_KEY}:`,
|
|
200
|
+
` recordId: ${yamlString(frontmatter.recordId)}`,
|
|
201
|
+
` scopes: [${frontmatter.scopes.map(yamlString).join(", ")}]`,
|
|
202
|
+
` caller: ${yamlString(frontmatter.caller)}`,
|
|
203
|
+
` session: ${yamlString(frontmatter.session)}`,
|
|
204
|
+
` createdAt: ${yamlString(frontmatter.createdAt)}`,
|
|
205
|
+
` contentHash: ${yamlString(frontmatter.contentHash)}`,
|
|
206
|
+
];
|
|
207
|
+
if (frontmatter.source) {
|
|
208
|
+
lines.push(` source: ${yamlString(frontmatter.source)}`);
|
|
209
|
+
}
|
|
210
|
+
if (input.supersedes.length > 0) {
|
|
211
|
+
lines.push("relations:", ` ${MEMORY_SUPERSEDES_EDGE}:`);
|
|
212
|
+
for (const uri of input.supersedes) {
|
|
213
|
+
lines.push(` - ${yamlString(uri)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
lines.push("---", "", text, "");
|
|
217
|
+
return lines.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
// Validator
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
225
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function nonEmptyString(value: unknown): value is string {
|
|
229
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readSupersedes(
|
|
233
|
+
relations: unknown,
|
|
234
|
+
diagnostics: MemoryDiagnostic[]
|
|
235
|
+
): string[] {
|
|
236
|
+
if (relations === undefined || relations === null) return [];
|
|
237
|
+
if (!isRecord(relations)) {
|
|
238
|
+
diagnostics.push({
|
|
239
|
+
code: "MEMORY_SUPERSEDES_INVALID",
|
|
240
|
+
message: "relations must be a mapping",
|
|
241
|
+
});
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const targets = relations[MEMORY_SUPERSEDES_EDGE];
|
|
245
|
+
if (targets === undefined || targets === null) return [];
|
|
246
|
+
const list = Array.isArray(targets) ? targets : [targets];
|
|
247
|
+
const uris: string[] = [];
|
|
248
|
+
for (const target of list) {
|
|
249
|
+
if (typeof target !== "string" || !target.startsWith(URI_PREFIX)) {
|
|
250
|
+
diagnostics.push({
|
|
251
|
+
code: "MEMORY_SUPERSEDES_INVALID",
|
|
252
|
+
message: `relations.${MEMORY_SUPERSEDES_EDGE} entries must be gno:// URIs`,
|
|
253
|
+
});
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
uris.push(target);
|
|
257
|
+
}
|
|
258
|
+
return uris;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Validate one file as a managed memory record. Returns every diagnostic
|
|
263
|
+
* found (never just the first) so status/audit can name them all.
|
|
264
|
+
*/
|
|
265
|
+
export function validateMemoryRecord(content: string): MemoryRecordValidation {
|
|
266
|
+
const diagnostics: MemoryDiagnostic[] = [];
|
|
267
|
+
const match = FRONTMATTER_REGEX.exec(content);
|
|
268
|
+
if (!match) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
diagnostics: [
|
|
272
|
+
{
|
|
273
|
+
code: "MEMORY_FRONTMATTER_MISSING",
|
|
274
|
+
message: "memory record has no frontmatter block",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
let parsed: unknown;
|
|
280
|
+
try {
|
|
281
|
+
parsed = Bun.YAML.parse(match[1] ?? "");
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
diagnostics: [
|
|
286
|
+
{
|
|
287
|
+
code: "MEMORY_FRONTMATTER_INVALID",
|
|
288
|
+
message: `frontmatter is not valid YAML: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (!isRecord(parsed)) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
diagnostics: [
|
|
297
|
+
{
|
|
298
|
+
code: "MEMORY_FRONTMATTER_INVALID",
|
|
299
|
+
message: "frontmatter must be a mapping",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const memory = parsed[MEMORY_FRONTMATTER_KEY];
|
|
305
|
+
if (!isRecord(memory)) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
diagnostics: [
|
|
309
|
+
{
|
|
310
|
+
code: "MEMORY_FRONTMATTER_MISSING",
|
|
311
|
+
message: `frontmatter has no "${MEMORY_FRONTMATTER_KEY}" mapping`,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const recordId = memory.recordId;
|
|
318
|
+
if (typeof recordId !== "string" || !RECORD_ID_PATTERN.test(recordId)) {
|
|
319
|
+
diagnostics.push({
|
|
320
|
+
code: "MEMORY_RECORD_ID_INVALID",
|
|
321
|
+
message: "memory.recordId must match mem-<16 hex chars>",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let scopes: string[] = [];
|
|
326
|
+
const rawScopes = memory.scopes;
|
|
327
|
+
if (!Array.isArray(rawScopes)) {
|
|
328
|
+
diagnostics.push({
|
|
329
|
+
code: "MEMORY_SCOPES_INVALID",
|
|
330
|
+
message: "memory.scopes must be a list of strings",
|
|
331
|
+
});
|
|
332
|
+
} else if (rawScopes.some((scope) => typeof scope !== "string")) {
|
|
333
|
+
diagnostics.push({
|
|
334
|
+
code: "MEMORY_SCOPES_INVALID",
|
|
335
|
+
message: "memory.scopes entries must be strings",
|
|
336
|
+
});
|
|
337
|
+
} else {
|
|
338
|
+
scopes = normalizeMemoryScopes(rawScopes as string[]);
|
|
339
|
+
const invalid = scopes.map(invalidMemoryScopeReason).find(Boolean);
|
|
340
|
+
if (invalid) {
|
|
341
|
+
diagnostics.push({ code: "MEMORY_SCOPES_INVALID", message: invalid });
|
|
342
|
+
} else if (scopes.length > MEMORY_MAX_SCOPES) {
|
|
343
|
+
diagnostics.push({
|
|
344
|
+
code: "MEMORY_SCOPES_INVALID",
|
|
345
|
+
message: `memory.scopes allows at most ${MEMORY_MAX_SCOPES} scopes`,
|
|
346
|
+
});
|
|
347
|
+
} else if (scopes.length === 0) {
|
|
348
|
+
diagnostics.push({
|
|
349
|
+
code: "MEMORY_SCOPES_EMPTY",
|
|
350
|
+
message: "memory.scopes must name at least one scope",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!nonEmptyString(memory.caller) || !nonEmptyString(memory.session)) {
|
|
356
|
+
diagnostics.push({
|
|
357
|
+
code: "MEMORY_IDENTITY_MISSING",
|
|
358
|
+
message: "memory.caller and memory.session are required",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const createdAt = memory.createdAt;
|
|
363
|
+
if (
|
|
364
|
+
typeof createdAt !== "string" ||
|
|
365
|
+
Number.isNaN(new Date(createdAt).getTime())
|
|
366
|
+
) {
|
|
367
|
+
diagnostics.push({
|
|
368
|
+
code: "MEMORY_CREATED_AT_INVALID",
|
|
369
|
+
message: "memory.createdAt must be an ISO-8601 timestamp",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const text = content.slice(match[0].length).trim();
|
|
374
|
+
if (text.length === 0) {
|
|
375
|
+
diagnostics.push({
|
|
376
|
+
code: "MEMORY_BODY_EMPTY",
|
|
377
|
+
message: "memory record body (the fact text) is empty",
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const contentHash = memory.contentHash;
|
|
382
|
+
if (typeof contentHash !== "string" || !SHA256_PATTERN.test(contentHash)) {
|
|
383
|
+
diagnostics.push({
|
|
384
|
+
code: "MEMORY_CONTENT_HASH_INVALID",
|
|
385
|
+
message: "memory.contentHash must be a lowercase sha256 hex digest",
|
|
386
|
+
});
|
|
387
|
+
} else if (text.length > 0 && hashMemoryText(text) !== contentHash) {
|
|
388
|
+
diagnostics.push({
|
|
389
|
+
code: "MEMORY_CONTENT_HASH_MISMATCH",
|
|
390
|
+
message: "memory.contentHash does not match the fact text",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const supersedes = readSupersedes(parsed.relations, diagnostics);
|
|
395
|
+
// Optional evidence: kept only when it is a non-empty string.
|
|
396
|
+
const source = nonEmptyString(memory.source) ? memory.source.trim() : null;
|
|
397
|
+
|
|
398
|
+
if (diagnostics.length > 0) {
|
|
399
|
+
return { ok: false, diagnostics };
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
ok: true,
|
|
403
|
+
record: {
|
|
404
|
+
frontmatter: {
|
|
405
|
+
recordId: recordId as string,
|
|
406
|
+
scopes,
|
|
407
|
+
caller: (memory.caller as string).trim(),
|
|
408
|
+
session: (memory.session as string).trim(),
|
|
409
|
+
createdAt: new Date(createdAt as string).toISOString(),
|
|
410
|
+
contentHash: contentHash as string,
|
|
411
|
+
...(source ? { source } : {}),
|
|
412
|
+
},
|
|
413
|
+
supersedes,
|
|
414
|
+
text,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Indexed scopes for a file: the validated set, or none when malformed. */
|
|
420
|
+
export function extractMemoryScopes(content: string): string[] {
|
|
421
|
+
const validation = validateMemoryRecord(content);
|
|
422
|
+
return validation.ok ? validation.record.frontmatter.scopes : [];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Whether the file declares the managed-memory contract at all. */
|
|
426
|
+
export function declaresMemoryRecord(content: string): boolean {
|
|
427
|
+
const match = FRONTMATTER_REGEX.exec(content);
|
|
428
|
+
if (!match) return false;
|
|
429
|
+
try {
|
|
430
|
+
const parsed = Bun.YAML.parse(match[1] ?? "");
|
|
431
|
+
return isRecord(parsed) && isRecord(parsed[MEMORY_FRONTMATTER_KEY]);
|
|
432
|
+
} catch {
|
|
433
|
+
return /^memory:\s*$/mu.test(match[1] ?? "");
|
|
434
|
+
}
|
|
435
|
+
}
|