agentlas 0.7.0 → 0.9.2

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 (49) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +161 -18
  3. package/bin/agentlas.cjs +8 -8
  4. package/engine/agentlas-core-harness.cjs +212 -0
  5. package/engine/agentlas-desktop-loadout.cjs +527 -0
  6. package/engine/agentlas-doctor.cjs +1 -1
  7. package/engine/agentlas-experience-exchange.cjs +835 -85
  8. package/engine/agentlas-experience-intake.cjs +444 -0
  9. package/engine/agentlas-experience-mcp.cjs +580 -18
  10. package/engine/agentlas-i18n.cjs +10 -10
  11. package/engine/agentlas-input.cjs +5 -4
  12. package/engine/agentlas-mcp-env.cjs +219 -0
  13. package/engine/agentlas-mcp-wrapper.cjs +51 -0
  14. package/engine/agentlas-memory-governance.cjs +1029 -0
  15. package/engine/agentlas-native-host.cjs +129 -39
  16. package/engine/agentlas-parity.cjs +339 -154
  17. package/engine/agentlas-repl.cjs +306 -31
  18. package/engine/agentlas-workforce.cjs +2991 -0
  19. package/engine/agentlas-workload-routing.cjs +523 -0
  20. package/engine/agentlas.cjs +1619 -234
  21. package/engine/bootstrap-schema.sql +1 -1
  22. package/engine/experience-taxonomy-v1.json +49 -0
  23. package/package.json +8 -4
  24. package/scripts/gen-bootstrap-schema.sh +0 -23
  25. package/test/bootstrap-race.cjs +0 -47
  26. package/test/capture-runtime-guard.cjs +0 -122
  27. package/test/cloud-asset-restore.cjs +0 -423
  28. package/test/cloud-cas-client.cjs +0 -333
  29. package/test/cloud-owner-restore.cjs +0 -183
  30. package/test/cloud-runtime-paths.cjs +0 -40
  31. package/test/cloud-save-publish.cjs +0 -487
  32. package/test/credential-env-regression.cjs +0 -52
  33. package/test/engine-hardening-regression.cjs +0 -74
  34. package/test/experience-exchange-contract.cjs +0 -569
  35. package/test/experience-mcp-contract.cjs +0 -391
  36. package/test/fixtures/portable-experience-bundle-v1-golden.json +0 -124
  37. package/test/login-loopback-security.cjs +0 -115
  38. package/test/mcp-config-isolation.cjs +0 -36
  39. package/test/permission-mapping.cjs +0 -180
  40. package/test/route-regression.cjs +0 -357
  41. package/test/run-api-regression.cjs +0 -322
  42. package/test/runtime-env-protection.cjs +0 -89
  43. package/test/semver-precedence.cjs +0 -39
  44. package/test/smoke.sh +0 -93
  45. package/test/sqlite-driver-probe.cjs +0 -22
  46. package/test/terminal-ui-regression.cjs +0 -477
  47. package/test/timeout-regression.cjs +0 -218
  48. package/test/tool-workspace-boundary.cjs +0 -165
  49. package/test/update-safety.cjs +0 -376
@@ -0,0 +1,1029 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+
7
+ const GOVERNANCE_SCHEMA_VERSION = "agentlas.memory-governance.v1";
8
+ const RECEIPT_SCHEMA_VERSION = "agentlas.memory-episode-receipt.v1";
9
+ const DECISION_SCHEMA_VERSION = "agentlas.memory-curator-decision.v1";
10
+ const DEFAULT_EVENTS_HEADING = "## Memory Events";
11
+ const MAX_CANDIDATES = 32;
12
+ const MAX_CANDIDATE_CHARS = 900;
13
+ const MAX_OBSERVATION_CHARS = 320;
14
+ const PENDING_REUSE_MS = 24 * 60 * 60 * 1000;
15
+ const MAX_MIRROR_BYTES = 16 * 1024 * 1024;
16
+ const LOG_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
17
+
18
+ const MEMORY_KINDS = new Set([
19
+ "fact",
20
+ "decision",
21
+ "preference",
22
+ "risk",
23
+ "procedure",
24
+ "hypothesis",
25
+ "evidence",
26
+ "deprecation",
27
+ "conflict",
28
+ ]);
29
+ const FINAL_SCOPES = new Set(["user_global", "team", "agent", "project", "session", "discard"]);
30
+ const SEMANTIC_DISPOSITIONS = new Set(["retain", "session", "discard", "review"]);
31
+ const CONFIDENCE_LEVELS = new Set(["high", "medium", "low"]);
32
+
33
+ const SECRET_PATTERNS = [
34
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/i,
35
+ /\b(?:sk|rk|pk)-(?:ant|proj|live|test)?-?[A-Za-z0-9_-]{16,}\b/i,
36
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/i,
37
+ /\bxox[baprs]-[A-Za-z0-9-]{16,}\b/i,
38
+ /\bAIza[A-Za-z0-9_-]{30,}\b/,
39
+ /\bAKIA[A-Z0-9]{16}\b/,
40
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
41
+ /\b(?:password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*[^\s,;]{6,}/i,
42
+ /\bauthorization\s*:\s*bearer\s+[^\s,;]{8,}/i,
43
+ ];
44
+ const ABSOLUTE_PATH_PATTERNS = [
45
+ /(?:^|[\s("'`])~\/[A-Za-z0-9._-]/,
46
+ /(?:^|[\s("'`])\/(?:Users|home|private|var|tmp|opt|etc|Volumes|Applications|System|Library)\//,
47
+ /(?:^|[\s("'`])[A-Za-z]:[\\/][^\s]/,
48
+ /(?:^|[\s("'`])\\\\[^\\\s]+\\[^\s]/,
49
+ ];
50
+ const TRANSCRIPT_BODY_PATTERNS = [
51
+ /(?:^|\n)\s*(?:system|user|assistant|developer)\s*:\s*\S/i,
52
+ /<\/?(?:system|user|assistant|developer)(?:>|\s)/i,
53
+ /\b(?:raw prompt|full prompt|transcript body|verbatim transcript)\b/i,
54
+ ];
55
+
56
+ const CURATOR_SYSTEM_PROMPT = [
57
+ "You are the Agentlas Memory Curator for one completed turn.",
58
+ "You have no tools and must return one JSON object only.",
59
+ "For every eligible candidate, choose disposition retain|session|discard|review and scope user_global|team|agent|project|session|discard.",
60
+ "Be conservative: retain only durable facts, decisions, preferences, risks, or reusable procedures; never retain transient narration or unsupported claims.",
61
+ "Candidates marked eligible=false must be discarded. Do not quote or rewrite candidate content in the response.",
62
+ "Return: {\"schema_version\":\"agentlas.memory-curator.v1\",\"decisions\":[{\"candidate_id\":\"...\",\"disposition\":\"...\",\"scope\":\"...\",\"reason_code\":\"short_machine_code\"}]}",
63
+ "Your decision is advisory; deterministic privacy, secret, permission, and owner gates have final authority.",
64
+ ].join("\n");
65
+
66
+ function sha256(value) {
67
+ return crypto.createHash("sha256").update(String(value == null ? "" : value), "utf8").digest("hex");
68
+ }
69
+
70
+ function stableJson(value) {
71
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
72
+ if (value && typeof value === "object") {
73
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
74
+ }
75
+ return JSON.stringify(value);
76
+ }
77
+
78
+ function normalizePermission(value) {
79
+ return ["read", "write", "full"].includes(value) ? value : "write";
80
+ }
81
+
82
+ function projectKey(projectPath) {
83
+ if (!projectPath) return null;
84
+ try {
85
+ return `project:${sha256(path.resolve(String(projectPath))).slice(0, 24)}`;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function ownerKey(agentId) {
92
+ const value = String(agentId || "").trim();
93
+ return value ? `owner:${sha256(value).slice(0, 24)}` : null;
94
+ }
95
+
96
+ function hasSecret(value) {
97
+ const text = String(value || "");
98
+ return SECRET_PATTERNS.some((pattern) => pattern.test(text));
99
+ }
100
+
101
+ function hasAbsolutePath(value) {
102
+ const text = String(value || "");
103
+ return ABSOLUTE_PATH_PATTERNS.some((pattern) => pattern.test(text));
104
+ }
105
+
106
+ function hasTranscriptBody(value) {
107
+ const text = String(value || "");
108
+ return TRANSCRIPT_BODY_PATTERNS.some((pattern) => pattern.test(text));
109
+ }
110
+
111
+ function contentGateReasons(value, options = {}) {
112
+ const text = typeof value === "string" ? value.trim() : "";
113
+ const reasons = [];
114
+ if (!text && !options.allowEmpty) reasons.push("empty_content");
115
+ if (text.length > (options.maxChars || MAX_CANDIDATE_CHARS)) reasons.push("content_too_long");
116
+ if (text.split(/\r?\n/).length > 8) reasons.push("too_many_lines");
117
+ if (hasSecret(text)) reasons.push("secret_detected");
118
+ if (hasAbsolutePath(text)) reasons.push("absolute_path_detected");
119
+ if (hasTranscriptBody(text)) reasons.push("transcript_body_detected");
120
+ return [...new Set(reasons)];
121
+ }
122
+
123
+ function normalizeScope(value) {
124
+ const scope = String(value || "").trim().toLowerCase().replace(/-/g, "_");
125
+ const aliases = {
126
+ user: "user_global",
127
+ global: "user_global",
128
+ user_identity: "user_global",
129
+ user_global: "user_global",
130
+ team_memory: "team",
131
+ agent_team: "team",
132
+ team: "team",
133
+ agent_repo: "agent",
134
+ agent: "agent",
135
+ project: "project",
136
+ session: "session",
137
+ discard: "discard",
138
+ };
139
+ return aliases[scope] || "session";
140
+ }
141
+
142
+ function normalizeKind(value) {
143
+ const kind = String(value || "").trim().toLowerCase();
144
+ return MEMORY_KINDS.has(kind) ? kind : "fact";
145
+ }
146
+
147
+ function normalizeConfidence(value) {
148
+ const confidence = String(value || "").trim().toLowerCase();
149
+ return CONFIDENCE_LEVELS.has(confidence) ? confidence : "medium";
150
+ }
151
+
152
+ function normalizeOutcome(value) {
153
+ const outcome = String(value || "").trim().toLowerCase();
154
+ if (["completed", "succeeded", "failed", "cancelled"].includes(outcome)) return outcome;
155
+ return "completed";
156
+ }
157
+
158
+ function ownerPolicyFromPrompt(prompt) {
159
+ const text = String(prompt || "");
160
+ const explicitMemory = /\b(?:remember|memorize|save|record|store|keep)\b|기억|메모리|저장|기록|남겨/i.test(text);
161
+ const explicitGlobal = /\b(?:global(?:ly)?|all projects|every project|across projects|user profile|account-wide)\b|전역|모든\s*프로젝트|프로젝트\s*전체|사용자\s*프로필/i.test(text);
162
+ return {
163
+ globalWriteAuthorized: explicitMemory && explicitGlobal,
164
+ };
165
+ }
166
+
167
+ function tableExists(db, name) {
168
+ try {
169
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ function ensureGovernanceSchema(db) {
176
+ db.exec(`
177
+ CREATE TABLE IF NOT EXISTS terminal_memory_turn_intents (
178
+ turn_id TEXT PRIMARY KEY,
179
+ request_fingerprint TEXT NOT NULL,
180
+ context_key TEXT NOT NULL,
181
+ permission TEXT NOT NULL,
182
+ project_key TEXT,
183
+ owner_key TEXT,
184
+ owner_policy_json TEXT NOT NULL DEFAULT '{}',
185
+ status TEXT NOT NULL DEFAULT 'pending',
186
+ started_at TEXT NOT NULL,
187
+ completed_at TEXT
188
+ );
189
+ CREATE INDEX IF NOT EXISTS idx_terminal_memory_intent_pending
190
+ ON terminal_memory_turn_intents(request_fingerprint, status, started_at);
191
+ UPDATE terminal_memory_turn_intents
192
+ SET status='stale'
193
+ WHERE status IN ('pending','curating') AND rowid NOT IN (
194
+ SELECT MAX(rowid) FROM terminal_memory_turn_intents
195
+ WHERE status IN ('pending','curating') GROUP BY request_fingerprint
196
+ );
197
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_terminal_memory_one_pending_request
198
+ ON terminal_memory_turn_intents(request_fingerprint)
199
+ WHERE status IN ('pending','curating');
200
+ CREATE TABLE IF NOT EXISTS terminal_memory_episode_receipts (
201
+ turn_id TEXT PRIMARY KEY,
202
+ ticket_json TEXT NOT NULL,
203
+ project_key TEXT,
204
+ created_at TEXT NOT NULL
205
+ );
206
+ CREATE TABLE IF NOT EXISTS terminal_memory_curator_decisions (
207
+ decision_id TEXT PRIMARY KEY,
208
+ turn_id TEXT NOT NULL,
209
+ candidate_id TEXT NOT NULL,
210
+ content_digest TEXT NOT NULL,
211
+ semantic_disposition TEXT NOT NULL,
212
+ semantic_scope TEXT NOT NULL,
213
+ final_disposition TEXT NOT NULL,
214
+ final_scope TEXT NOT NULL,
215
+ reason_codes_json TEXT NOT NULL DEFAULT '[]',
216
+ created_at TEXT NOT NULL
217
+ );
218
+ CREATE INDEX IF NOT EXISTS idx_terminal_memory_decisions_turn
219
+ ON terminal_memory_curator_decisions(turn_id);
220
+ CREATE TABLE IF NOT EXISTS terminal_memory_scope_timeline (
221
+ event_id TEXT PRIMARY KEY,
222
+ turn_id TEXT NOT NULL,
223
+ memory_id TEXT NOT NULL,
224
+ lane TEXT NOT NULL,
225
+ project_key TEXT,
226
+ owner_key TEXT,
227
+ created_at TEXT NOT NULL
228
+ );
229
+ CREATE INDEX IF NOT EXISTS idx_terminal_memory_timeline_project
230
+ ON terminal_memory_scope_timeline(project_key, lane, created_at);
231
+ CREATE INDEX IF NOT EXISTS idx_terminal_memory_timeline_owner
232
+ ON terminal_memory_scope_timeline(owner_key, lane, created_at);
233
+ `);
234
+ }
235
+
236
+ function validTurnId(value) {
237
+ const id = String(value || "").trim();
238
+ return /^[A-Za-z0-9][A-Za-z0-9:._-]{7,159}$/.test(id) ? id : null;
239
+ }
240
+
241
+ function beginTurn(db, input = {}) {
242
+ ensureGovernanceSchema(db);
243
+ const permission = normalizePermission(input.permission);
244
+ const pKey = projectKey(input.projectPath);
245
+ const oKey = ownerKey(input.agentId);
246
+ const explicitTurnId = validTurnId(input.stableTurnId);
247
+ const policy = ownerPolicyFromPrompt(input.prompt);
248
+ const conversationDigest = sha256(input.conversationRef || "none");
249
+ const priorDigest = sha256(input.priorContextDigest || input.priorContext || "none");
250
+ const contextKey = sha256(stableJson({
251
+ project_key: pKey,
252
+ owner_key: oKey,
253
+ conversation_digest: conversationDigest,
254
+ prior_digest: priorDigest,
255
+ surface: String(input.surface || "terminal-turn").slice(0, 64),
256
+ }));
257
+ const baseRequestFingerprintPayload = {
258
+ schema: GOVERNANCE_SCHEMA_VERSION,
259
+ context_key: contextKey,
260
+ prompt_digest: sha256(input.prompt || ""),
261
+ permission,
262
+ };
263
+ const legacyRequestFingerprint = sha256(stableJson(baseRequestFingerprintPayload));
264
+ // A host-authored id distinguishes two intentional user turns that happen
265
+ // to have identical text/context. Reusing that same id still converges.
266
+ const requestFingerprint = explicitTurnId
267
+ ? sha256(stableJson({ ...baseRequestFingerprintPayload, stable_turn_id: explicitTurnId }))
268
+ : legacyRequestFingerprint;
269
+ const now = new Date().toISOString();
270
+
271
+ const run = db.transaction(() => {
272
+ if (explicitTurnId) {
273
+ const existing = db.prepare("SELECT * FROM terminal_memory_turn_intents WHERE turn_id=?").get(explicitTurnId);
274
+ // Accept the pre-v1 host fingerprint for an in-flight turn created
275
+ // before this distinction was introduced; all new rows use the bound id.
276
+ if (existing && ![requestFingerprint, legacyRequestFingerprint].includes(existing.request_fingerprint)) {
277
+ throw new Error("stable memory turn id is already bound to a different request fingerprint");
278
+ }
279
+ if (existing) return { row: existing, reused: true };
280
+ } else {
281
+ const candidates = db.prepare(
282
+ "SELECT * FROM terminal_memory_turn_intents WHERE request_fingerprint=? AND status IN ('pending','curating') ORDER BY started_at DESC LIMIT 8",
283
+ ).all(requestFingerprint);
284
+ const reusable = candidates.find((row) => {
285
+ const started = Date.parse(row.started_at || "");
286
+ return Number.isFinite(started) && Date.now() - started <= PENDING_REUSE_MS;
287
+ });
288
+ if (reusable) return { row: reusable, reused: true };
289
+ for (const stale of candidates) {
290
+ db.prepare("UPDATE terminal_memory_turn_intents SET status='stale' WHERE turn_id=? AND status IN ('pending','curating')")
291
+ .run(stale.turn_id);
292
+ }
293
+ }
294
+
295
+ const turnId = explicitTurnId || `terminal-turn:${crypto.randomUUID()}`;
296
+ db.prepare(
297
+ "INSERT OR IGNORE INTO terminal_memory_turn_intents (turn_id,request_fingerprint,context_key,permission,project_key,owner_key,owner_policy_json,status,started_at) VALUES (?,?,?,?,?,?,?,'pending',?)",
298
+ ).run(turnId, requestFingerprint, contextKey, permission, pKey, oKey, JSON.stringify(policy), now);
299
+ const inserted = db.prepare("SELECT * FROM terminal_memory_turn_intents WHERE turn_id=?").get(turnId);
300
+ if (!inserted) {
301
+ const concurrent = db.prepare(
302
+ "SELECT * FROM terminal_memory_turn_intents WHERE request_fingerprint=? AND status IN ('pending','curating') ORDER BY started_at DESC LIMIT 1",
303
+ ).get(requestFingerprint);
304
+ if (concurrent) return { row: concurrent, reused: true };
305
+ throw new Error("memory turn intent could not be persisted");
306
+ }
307
+ return {
308
+ row: inserted,
309
+ reused: false,
310
+ };
311
+ });
312
+
313
+ const result = run();
314
+ let storedPolicy = policy;
315
+ try { storedPolicy = JSON.parse(result.row.owner_policy_json || "{}"); } catch { /* use current safe policy */ }
316
+ return {
317
+ turnId: result.row.turn_id,
318
+ requestFingerprint: result.row.request_fingerprint,
319
+ contextKey: result.row.context_key,
320
+ permission: result.row.permission,
321
+ projectKey: result.row.project_key || null,
322
+ ownerKey: result.row.owner_key || null,
323
+ ownerPolicy: storedPolicy,
324
+ reused: result.reused,
325
+ };
326
+ }
327
+
328
+ function extractJson(text) {
329
+ const value = String(text || "").trim();
330
+ const candidates = [];
331
+ const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
332
+ if (fence) candidates.push(fence[1].trim());
333
+ if (value) candidates.push(value);
334
+ const firstObject = value.indexOf("{");
335
+ const lastObject = value.lastIndexOf("}");
336
+ if (firstObject >= 0 && lastObject > firstObject) candidates.push(value.slice(firstObject, lastObject + 1));
337
+ const firstArray = value.indexOf("[");
338
+ const lastArray = value.lastIndexOf("]");
339
+ if (firstArray >= 0 && lastArray > firstArray) candidates.push(value.slice(firstArray, lastArray + 1));
340
+ for (const candidate of candidates) {
341
+ try { return JSON.parse(candidate); } catch { /* continue */ }
342
+ }
343
+ return null;
344
+ }
345
+
346
+ function normalizeObservation(raw) {
347
+ const value = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
348
+ const summary = typeof value.summary === "string" ? value.summary.trim().slice(0, MAX_OBSERVATION_CHARS) : "";
349
+ const reasons = contentGateReasons(summary, { allowEmpty: true, maxChars: MAX_OBSERVATION_CHARS });
350
+ return {
351
+ outcome: normalizeOutcome(value.outcome),
352
+ summary: reasons.length ? "" : summary,
353
+ safe: reasons.length === 0,
354
+ reasonCodes: reasons,
355
+ };
356
+ }
357
+
358
+ function normalizeCandidates(rawCandidates, turnId) {
359
+ if (!Array.isArray(rawCandidates)) return [];
360
+ return rawCandidates.slice(0, MAX_CANDIDATES).map((raw, index) => {
361
+ const value = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
362
+ const content = typeof value.content === "string" ? value.content.trim().slice(0, MAX_CANDIDATE_CHARS + 1) : "";
363
+ const preGateReasons = contentGateReasons(content);
364
+ if (String(value.sensitivity || "").toLowerCase() === "secret") preGateReasons.push("declared_secret");
365
+ const contentDigest = `sha256:${sha256(content)}`;
366
+ return {
367
+ id: `candidate:${sha256(`${turnId}:${index}:${contentDigest}`).slice(0, 24)}`,
368
+ index,
369
+ kind: normalizeKind(value.memory_kind || value.kind),
370
+ content,
371
+ contentDigest,
372
+ suggestedScope: normalizeScope(value.suggested_scope || value.scope),
373
+ confidence: normalizeConfidence(value.confidence),
374
+ preGateReasons: [...new Set(preGateReasons)],
375
+ };
376
+ });
377
+ }
378
+
379
+ function requestCopyDetected(value, normalizedRequest) {
380
+ const content = String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
381
+ if (!content || !normalizedRequest) return false;
382
+ return content === normalizedRequest || (
383
+ content.length >= 40 && normalizedRequest.length >= 40 &&
384
+ (normalizedRequest.includes(content) || content.includes(normalizedRequest))
385
+ );
386
+ }
387
+
388
+ function applyRequestPrivacyGate(parsed, requestText) {
389
+ const request = String(requestText || "").replace(/\s+/g, " ").trim().toLowerCase();
390
+ if (!request) return parsed;
391
+ if (requestCopyDetected(parsed.observation?.summary, request)) {
392
+ parsed.observation.summary = "";
393
+ parsed.observation.safe = false;
394
+ parsed.observation.reasonCodes = [...new Set([
395
+ ...(parsed.observation.reasonCodes || []),
396
+ "raw_prompt_copy_detected",
397
+ ])];
398
+ }
399
+ for (const candidate of parsed.candidates) {
400
+ if (requestCopyDetected(candidate.content, request)) {
401
+ candidate.preGateReasons = [...new Set([...candidate.preGateReasons, "raw_prompt_copy_detected"])];
402
+ }
403
+ }
404
+ return parsed;
405
+ }
406
+
407
+ function blockUnboundCandidates(candidates, reason) {
408
+ for (const candidate of candidates) {
409
+ candidate.preGateReasons = [...new Set([...candidate.preGateReasons, reason])];
410
+ }
411
+ return candidates;
412
+ }
413
+
414
+ function parseMainOutput(text, turnId, heading = DEFAULT_EVENTS_HEADING) {
415
+ const source = String(text || "");
416
+ const index = source.lastIndexOf(heading);
417
+ if (index < 0) {
418
+ return {
419
+ cleaned: source.trim(),
420
+ parseStatus: "missing",
421
+ observation: normalizeObservation(null),
422
+ candidates: [],
423
+ declaredTurnId: null,
424
+ };
425
+ }
426
+ const cleaned = source.slice(0, index).trim();
427
+ const tail = source.slice(index + heading.length);
428
+ const parsed = extractJson(tail);
429
+ if (!parsed) {
430
+ return {
431
+ cleaned,
432
+ parseStatus: "malformed",
433
+ observation: normalizeObservation(null),
434
+ candidates: [],
435
+ declaredTurnId: null,
436
+ };
437
+ }
438
+
439
+ if (Array.isArray(parsed)) {
440
+ const candidates = normalizeCandidates(parsed, turnId);
441
+ return {
442
+ cleaned,
443
+ parseStatus: "legacy_array",
444
+ observation: normalizeObservation(null),
445
+ candidates: blockUnboundCandidates(candidates, "legacy_envelope_unbound"),
446
+ declaredTurnId: null,
447
+ };
448
+ }
449
+
450
+ if (!parsed || typeof parsed !== "object") {
451
+ return {
452
+ cleaned,
453
+ parseStatus: "malformed",
454
+ observation: normalizeObservation(null),
455
+ candidates: [],
456
+ declaredTurnId: null,
457
+ };
458
+ }
459
+ const declaredTurnId = validTurnId(parsed.turn_id || parsed.turnId);
460
+ const hasObservation = parsed.observation && typeof parsed.observation === "object";
461
+ const hasCandidates = Array.isArray(parsed.candidates);
462
+ let parseStatus = hasObservation && hasCandidates ? "ok" : "partial";
463
+ if (!declaredTurnId) parseStatus = "turn_id_missing";
464
+ else if (declaredTurnId !== turnId) parseStatus = "turn_id_mismatch";
465
+ const candidates = normalizeCandidates(hasCandidates ? parsed.candidates : [], turnId);
466
+ if (parseStatus !== "ok") blockUnboundCandidates(candidates, `main_envelope_${parseStatus}`);
467
+ return {
468
+ cleaned,
469
+ parseStatus,
470
+ observation: normalizeObservation(parsed.observation),
471
+ candidates,
472
+ declaredTurnId,
473
+ };
474
+ }
475
+
476
+ function buildCuratorPayload(turn, parsed, input = {}) {
477
+ return {
478
+ schema_version: "agentlas.memory-curator-input.v1",
479
+ turn_id: turn.turnId,
480
+ parse_status: parsed.parseStatus,
481
+ // The permission frozen at beginTurn is authoritative. Completion input
482
+ // can never upgrade a read-only turn into a durable write.
483
+ permission: normalizePermission(turn.permission),
484
+ context: {
485
+ project_available: Boolean(turn.projectKey),
486
+ agent_or_team_available: Boolean(turn.ownerKey),
487
+ global_write_authorized: turn.ownerPolicy?.globalWriteAuthorized === true,
488
+ },
489
+ observation: {
490
+ outcome: parsed.observation.outcome,
491
+ summary: parsed.observation.safe ? parsed.observation.summary : "",
492
+ safe: parsed.observation.safe,
493
+ },
494
+ candidates: parsed.candidates.map((candidate) => ({
495
+ candidate_id: candidate.id,
496
+ memory_kind: candidate.kind,
497
+ content: candidate.preGateReasons.length ? undefined : candidate.content,
498
+ content_digest: candidate.contentDigest,
499
+ suggested_scope: candidate.suggestedScope,
500
+ confidence: candidate.confidence,
501
+ eligible: candidate.preGateReasons.length === 0,
502
+ blocked_reason_codes: candidate.preGateReasons,
503
+ })),
504
+ };
505
+ }
506
+
507
+ function sanitizeReasonCode(value) {
508
+ const reason = String(value || "semantic_decision").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
509
+ return reason.slice(0, 64) || "semantic_decision";
510
+ }
511
+
512
+ function parseCuratorOutput(text, candidateIds) {
513
+ const parsed = extractJson(text);
514
+ if (
515
+ !parsed ||
516
+ typeof parsed !== "object" ||
517
+ parsed.schema_version !== "agentlas.memory-curator.v1" ||
518
+ !Array.isArray(parsed.decisions)
519
+ ) {
520
+ return { status: "malformed", decisions: new Map() };
521
+ }
522
+ const allowedIds = new Set(candidateIds);
523
+ const decisions = new Map();
524
+ for (const raw of parsed.decisions) {
525
+ if (!raw || typeof raw !== "object") continue;
526
+ const candidateId = String(raw.candidate_id || raw.candidateId || "");
527
+ if (!allowedIds.has(candidateId) || decisions.has(candidateId)) continue;
528
+ const disposition = String(raw.disposition || "review").trim().toLowerCase();
529
+ const scope = normalizeScope(raw.scope);
530
+ decisions.set(candidateId, {
531
+ candidateId,
532
+ disposition: SEMANTIC_DISPOSITIONS.has(disposition) ? disposition : "review",
533
+ scope: FINAL_SCOPES.has(scope) ? scope : "session",
534
+ reasonCode: sanitizeReasonCode(raw.reason_code || raw.reasonCode),
535
+ });
536
+ }
537
+ return { status: "ok", decisions };
538
+ }
539
+
540
+ function finalDecision(candidate, semantic, context) {
541
+ const reasons = [...candidate.preGateReasons];
542
+ const permission = normalizePermission(context.permission);
543
+ const semanticDisposition = semantic?.disposition || "review";
544
+ const semanticScope = semantic?.scope || "session";
545
+ if (semantic?.reasonCode) reasons.push(semantic.reasonCode);
546
+
547
+ if (permission === "read") {
548
+ reasons.push("read_only_receipt");
549
+ return finishDecision("receipt_only", "session", reasons);
550
+ }
551
+ if (context.curatorStatus !== "ok") {
552
+ reasons.push(`curator_${context.curatorStatus}`);
553
+ return finishDecision("review", "session", reasons);
554
+ }
555
+ if (candidate.preGateReasons.length) return finishDecision("discard", "discard", reasons);
556
+ if (semanticDisposition !== "retain") {
557
+ const finalScope = semanticDisposition === "session" ? "session" : semanticDisposition === "discard" ? "discard" : "session";
558
+ return finishDecision(semanticDisposition, finalScope, reasons);
559
+ }
560
+ if (candidate.suggestedScope === "discard") {
561
+ reasons.push("candidate_requested_discard");
562
+ return finishDecision("discard", "discard", reasons);
563
+ }
564
+ if (!FINAL_SCOPES.has(semanticScope) || ["session", "discard"].includes(semanticScope)) {
565
+ reasons.push("semantic_scope_not_durable");
566
+ return finishDecision("session", "session", reasons);
567
+ }
568
+ if (!context.memoryStoreAvailable) {
569
+ reasons.push("memory_store_unavailable");
570
+ return finishDecision("review", "session", reasons);
571
+ }
572
+ if (semanticScope === "user_global") {
573
+ if (context.ownerPolicy?.globalWriteAuthorized !== true) {
574
+ reasons.push("owner_global_consent_required");
575
+ return finishDecision("review", "session", reasons);
576
+ }
577
+ if (candidate.confidence !== "high" || !["fact", "decision", "preference", "procedure"].includes(candidate.kind)) {
578
+ reasons.push("global_quality_gate");
579
+ return finishDecision("review", "session", reasons);
580
+ }
581
+ }
582
+ if (semanticScope === "project" && !context.projectKey) {
583
+ reasons.push("project_scope_unavailable");
584
+ return finishDecision("review", "session", reasons);
585
+ }
586
+ if (["team", "agent"].includes(semanticScope) && !context.ownerKey) {
587
+ reasons.push("owner_scope_unavailable");
588
+ return finishDecision("review", "session", reasons);
589
+ }
590
+ return finishDecision("retain", semanticScope, reasons);
591
+ }
592
+
593
+ function finishDecision(disposition, scope, reasons) {
594
+ return {
595
+ finalDisposition: disposition,
596
+ finalScope: scope,
597
+ reasonCodes: [...new Set(reasons.map(sanitizeReasonCode))],
598
+ };
599
+ }
600
+
601
+ function storageScope(scope) {
602
+ return {
603
+ user_global: "user_identity",
604
+ team: "team_memory",
605
+ agent: "agent_repo",
606
+ project: "project",
607
+ }[scope] || null;
608
+ }
609
+
610
+ function decisionBatchFor(turnId, timestamp, projectKeyValue, parseStatus, curatorStatus, decisions) {
611
+ return {
612
+ schemaVersion: DECISION_SCHEMA_VERSION,
613
+ id: `curator-decision:${turnId}`,
614
+ turnId,
615
+ timestamp,
616
+ projectKey: projectKeyValue || null,
617
+ parseStatus,
618
+ curatorStatus,
619
+ decisions: decisions.map((decision) => ({
620
+ candidateId: decision.candidate.id,
621
+ contentDigest: decision.candidate.contentDigest,
622
+ semanticDisposition: decision.semanticDisposition,
623
+ semanticScope: decision.semanticScope,
624
+ finalDisposition: decision.finalDisposition,
625
+ finalScope: decision.finalScope,
626
+ reasonCodes: decision.reasonCodes,
627
+ })),
628
+ };
629
+ }
630
+
631
+ function ticketFor(turn, parsed, input, timestamp, curatorStatus, decisions, retainedCount) {
632
+ const permission = normalizePermission(turn.permission);
633
+ const outcome = normalizeOutcome(input.outcome || parsed.observation.outcome);
634
+ const action = retainedCount > 0
635
+ ? "promote"
636
+ : ["failed", "cancelled"].includes(outcome) ||
637
+ ["malformed", "missing", "partial", "turn_id_missing", "turn_id_mismatch", "legacy_array"].includes(parsed.parseStatus) ||
638
+ curatorStatus !== "ok"
639
+ ? "needs_review"
640
+ : "discard";
641
+ return {
642
+ id: `memory-ticket:${turn.turnId}`,
643
+ timestamp,
644
+ sourceAgent: `terminal:${turn.ownerKey || "direct"}`,
645
+ scope: turn.projectKey ? "project" : "session",
646
+ trustLabel: "verified",
647
+ summary: `episode receipt; outcome=${outcome}; parse=${parsed.parseStatus}; candidates=${parsed.candidates.length}; retained=${retainedCount}; permission=${permission}`,
648
+ evidence: [
649
+ `schema:${RECEIPT_SCHEMA_VERSION}`,
650
+ `turn:${turn.turnId}`,
651
+ `observation:sha256:${sha256(stableJson(parsed.observation))}`,
652
+ `outcome:${outcome}`,
653
+ `curator:${curatorStatus}`,
654
+ `project:${turn.projectKey || "none"}`,
655
+ ],
656
+ action,
657
+ status: "accepted",
658
+ };
659
+ }
660
+
661
+ function assertSafeLogRecord(record) {
662
+ const encoded = JSON.stringify(record);
663
+ if (hasSecret(encoded) || hasAbsolutePath(encoded) || hasTranscriptBody(encoded)) {
664
+ throw new Error("refusing to write an unsafe memory governance decision log");
665
+ }
666
+ }
667
+
668
+ function appendJsonlOnce(filePath, record) {
669
+ assertSafeLogRecord(record);
670
+ let stat;
671
+ try { stat = fs.lstatSync(filePath); } catch { return false; }
672
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_MIRROR_BYTES) return false;
673
+ const lockPath = `${filePath}.lock`;
674
+ let lockFd = null;
675
+ for (let attempt = 0; attempt < 50 && lockFd == null; attempt += 1) {
676
+ try {
677
+ lockFd = fs.openSync(lockPath, "wx", 0o600);
678
+ } catch (error) {
679
+ if (!error || error.code !== "EEXIST") return false;
680
+ try {
681
+ const lockStat = fs.lstatSync(lockPath);
682
+ if (lockStat.isFile() && !lockStat.isSymbolicLink() && Date.now() - lockStat.mtimeMs > 30_000) {
683
+ fs.unlinkSync(lockPath);
684
+ continue;
685
+ }
686
+ } catch { /* lock disappeared; retry */ }
687
+ Atomics.wait(LOG_LOCK_WAIT, 0, 0, 20);
688
+ }
689
+ }
690
+ if (lockFd == null) return false;
691
+ try {
692
+ stat = fs.lstatSync(filePath);
693
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_MIRROR_BYTES) return false;
694
+ let current = "";
695
+ try { current = fs.readFileSync(filePath, "utf8"); } catch { return false; }
696
+ for (const line of current.split(/\r?\n/)) {
697
+ if (!line.trim()) continue;
698
+ try {
699
+ const parsed = JSON.parse(line);
700
+ if (parsed && parsed.id === record.id) return true;
701
+ } catch { /* preserve unrelated malformed local lines */ }
702
+ }
703
+ fs.appendFileSync(filePath, JSON.stringify(record) + "\n", { encoding: "utf8", mode: 0o600 });
704
+ try { fs.chmodSync(filePath, 0o600); } catch { /* Windows/ACL-only host */ }
705
+ return true;
706
+ } finally {
707
+ try { fs.closeSync(lockFd); } catch { /* ignore */ }
708
+ try { fs.unlinkSync(lockPath); } catch { /* a stale lock is recovered on the next turn */ }
709
+ }
710
+ }
711
+
712
+ function mirrorCoreLogs(projectPathValue, ticket, decisionBatch, options = {}) {
713
+ if (!projectPathValue) return { ticket: false, decisions: false };
714
+ let root;
715
+ try { root = fs.realpathSync(path.resolve(String(projectPathValue))); } catch { return { ticket: false, decisions: false }; }
716
+ const memoryDir = options.memoryDir || ".agentlas";
717
+ const stateDir = path.join(root, memoryDir);
718
+ let state;
719
+ try { state = fs.lstatSync(stateDir); } catch { return { ticket: false, decisions: false }; }
720
+ if (!state.isDirectory() || state.isSymbolicLink()) return { ticket: false, decisions: false };
721
+ const ticketPath = path.join(stateDir, options.ticketFile || "memory-tickets.jsonl");
722
+ const decisionPath = path.join(stateDir, options.decisionFile || "curator-decisions.jsonl");
723
+ // Core JSONL files are a best-effort projection of the authoritative DB
724
+ // receipt. Filesystem trouble must not undo or crash a completed host turn.
725
+ let ticketWritten = false;
726
+ let decisionsWritten = false;
727
+ try { ticketWritten = appendJsonlOnce(ticketPath, ticket); } catch { /* DB receipt remains authoritative */ }
728
+ try { decisionsWritten = appendJsonlOnce(decisionPath, decisionBatch); } catch { /* DB receipt remains authoritative */ }
729
+ return { ticket: ticketWritten, decisions: decisionsWritten };
730
+ }
731
+
732
+ function readExistingReceipt(db, turnId) {
733
+ const row = db.prepare("SELECT ticket_json FROM terminal_memory_episode_receipts WHERE turn_id=?").get(turnId);
734
+ if (!row) return null;
735
+ try { return JSON.parse(row.ticket_json); } catch { return null; }
736
+ }
737
+
738
+ function readDecisionBatch(db, turnId, ticket, projectKeyValue = null) {
739
+ const rows = db.prepare(
740
+ "SELECT * FROM terminal_memory_curator_decisions WHERE turn_id=? ORDER BY candidate_id",
741
+ ).all(turnId);
742
+ const parseStatus = String(ticket?.summary || "").match(/(?:^|; )parse=([a-z0-9_-]+)/i)?.[1] || "replayed";
743
+ const curatorStatus = (ticket?.evidence || []).map(String).find((value) => value.startsWith("curator:"))?.slice("curator:".length) || "replayed";
744
+ return {
745
+ schemaVersion: DECISION_SCHEMA_VERSION,
746
+ id: `curator-decision:${turnId}`,
747
+ turnId,
748
+ timestamp: ticket?.timestamp || new Date().toISOString(),
749
+ projectKey: projectKeyValue,
750
+ parseStatus,
751
+ curatorStatus,
752
+ decisions: rows.map((row) => ({
753
+ candidateId: row.candidate_id,
754
+ contentDigest: row.content_digest,
755
+ semanticDisposition: row.semantic_disposition,
756
+ semanticScope: row.semantic_scope,
757
+ finalDisposition: row.final_disposition,
758
+ finalScope: row.final_scope,
759
+ reasonCodes: safeJsonArray(row.reason_codes_json),
760
+ })),
761
+ };
762
+ }
763
+
764
+ function safeJsonArray(value) {
765
+ try {
766
+ const parsed = JSON.parse(value || "[]");
767
+ return Array.isArray(parsed) ? parsed : [];
768
+ } catch {
769
+ return [];
770
+ }
771
+ }
772
+
773
+ function loadTurnMemories(db, turnId) {
774
+ if (!tableExists(db, "memory_entries")) return [];
775
+ try {
776
+ return db.prepare(
777
+ "SELECT m.id,m.scope,m.kind,m.content,m.confidence,m.sensitivity,m.context_json FROM terminal_memory_scope_timeline t JOIN memory_entries m ON m.id=t.memory_id WHERE t.turn_id=? ORDER BY t.created_at,t.event_id",
778
+ ).all(turnId);
779
+ } catch {
780
+ return [];
781
+ }
782
+ }
783
+
784
+ async function completeTurn(db, input = {}) {
785
+ ensureGovernanceSchema(db);
786
+ const turnId = validTurnId(input.turnId);
787
+ if (!turnId) throw new Error("memory governance completion requires an explicit stable turn id");
788
+ const parsed = parseMainOutput(input.mainOutput, turnId, input.eventsHeading || DEFAULT_EVENTS_HEADING);
789
+ // Runtime outcome is host evidence and overrides a model-authored claim.
790
+ parsed.observation.outcome = normalizeOutcome(input.outcome || parsed.observation.outcome);
791
+ applyRequestPrivacyGate(parsed, input.requestText);
792
+ const intent = db.prepare("SELECT * FROM terminal_memory_turn_intents WHERE turn_id=?").get(turnId);
793
+ if (!intent) throw new Error("memory governance completion has no matching turn intent");
794
+ const turn = {
795
+ turnId,
796
+ permission: intent.permission,
797
+ projectKey: intent.project_key || null,
798
+ ownerKey: intent.owner_key || null,
799
+ ownerPolicy: (() => { try { return JSON.parse(intent.owner_policy_json || "{}"); } catch { return {}; } })(),
800
+ };
801
+ const completionProjectKey = projectKey(input.projectPath);
802
+ const completionOwnerKey = ownerKey(input.agentId);
803
+ const projectContextMatches = completionProjectKey === turn.projectKey;
804
+ const ownerContextMatches = completionOwnerKey === turn.ownerKey;
805
+ if (!projectContextMatches) blockUnboundCandidates(parsed.candidates, "completion_project_context_mismatch");
806
+ if (!ownerContextMatches) blockUnboundCandidates(parsed.candidates, "completion_owner_context_mismatch");
807
+ const boundProjectPath = projectContextMatches ? (input.projectPath || null) : null;
808
+ const boundAgentId = ownerContextMatches ? (input.agentId || null) : null;
809
+
810
+ const existing = readExistingReceipt(db, turnId);
811
+ if (existing) {
812
+ const decisionBatch = readDecisionBatch(db, turnId, existing, turn.projectKey);
813
+ if (normalizePermission(turn.permission) !== "read" && projectContextMatches && ownerContextMatches) {
814
+ mirrorCoreLogs(boundProjectPath, existing, decisionBatch, input.coreFiles);
815
+ }
816
+ return {
817
+ cleaned: parsed.cleaned,
818
+ parseStatus: parsed.parseStatus,
819
+ receipt: existing,
820
+ decisionBatch,
821
+ curatedMemories: loadTurnMemories(db, turnId),
822
+ idempotentReplay: true,
823
+ curatorInvoked: false,
824
+ };
825
+ }
826
+
827
+ const payload = buildCuratorPayload(turn, parsed, input);
828
+ let curatorStatus = "unavailable";
829
+ let semantic = { status: "unavailable", decisions: new Map() };
830
+ if (typeof input.invokeCurator === "function") {
831
+ try {
832
+ const output = await input.invokeCurator(payload, CURATOR_SYSTEM_PROMPT);
833
+ semantic = parseCuratorOutput(output, parsed.candidates.map((candidate) => candidate.id));
834
+ curatorStatus = semantic.status;
835
+ } catch {
836
+ curatorStatus = "error";
837
+ semantic = { status: "error", decisions: new Map() };
838
+ }
839
+ }
840
+
841
+ const memoryStoreAvailable = tableExists(db, "memory_entries");
842
+ const decisions = parsed.candidates.map((candidate) => {
843
+ const semanticDecision = semantic.decisions.get(candidate.id) || null;
844
+ const final = finalDecision(candidate, semanticDecision, {
845
+ permission: turn.permission,
846
+ curatorStatus,
847
+ memoryStoreAvailable,
848
+ projectKey: turn.projectKey,
849
+ ownerKey: turn.ownerKey,
850
+ ownerPolicy: turn.ownerPolicy,
851
+ });
852
+ return {
853
+ candidate,
854
+ semanticDisposition: semanticDecision?.disposition || "review",
855
+ semanticScope: semanticDecision?.scope || "session",
856
+ ...final,
857
+ };
858
+ });
859
+
860
+ const timestamp = new Date().toISOString();
861
+ let ticket = null;
862
+ let retainedMemories = [];
863
+ let decisionBatch = null;
864
+ let replayedByRace = false;
865
+ const run = db.transaction(() => {
866
+ const raced = readExistingReceipt(db, turnId);
867
+ if (raced) {
868
+ ticket = raced;
869
+ replayedByRace = true;
870
+ retainedMemories = loadTurnMemories(db, turnId);
871
+ decisionBatch = readDecisionBatch(db, turnId, raced, turn.projectKey);
872
+ return;
873
+ }
874
+
875
+ for (const decision of decisions) {
876
+ if (decision.finalDisposition !== "retain") continue;
877
+ const candidate = decision.candidate;
878
+ const dbScope = storageScope(decision.finalScope);
879
+ if (!dbScope) continue;
880
+ const scopedProjectId = decision.finalScope === "user_global" ? null : turn.projectKey;
881
+ const scopedProjectPath = decision.finalScope === "user_global" ? null : boundProjectPath;
882
+ const scopedAgentId = ["team", "agent"].includes(decision.finalScope) ? boundAgentId : null;
883
+ let memoryId = null;
884
+ try {
885
+ const duplicate = db.prepare(
886
+ "SELECT id FROM memory_entries WHERE scope=? AND kind=? AND lower(trim(content))=? AND superseded_at IS NULL AND (project_path IS ? OR project_path=?) AND (agent_id IS ? OR agent_id=?) LIMIT 1",
887
+ ).get(dbScope, candidate.kind, candidate.content.toLowerCase(), scopedProjectPath, scopedProjectPath, scopedAgentId, scopedAgentId);
888
+ memoryId = duplicate && duplicate.id;
889
+ if (memoryId) decision.reasonCodes = [...new Set([...decision.reasonCodes, "deduplicated"])];
890
+ } catch { /* deterministic id below still prevents replay duplicates */ }
891
+ if (!memoryId) {
892
+ memoryId = `memory:${sha256(`${turnId}:${candidate.id}:${decision.finalScope}`).slice(0, 32)}`;
893
+ const safeContext = {
894
+ governance_schema: GOVERNANCE_SCHEMA_VERSION,
895
+ turn_id: turnId,
896
+ candidate_id: candidate.id,
897
+ project_key: turn.projectKey,
898
+ lane: decision.finalScope,
899
+ observation_digest: `sha256:${sha256(stableJson(parsed.observation))}`,
900
+ };
901
+ db.prepare(
902
+ "INSERT OR IGNORE INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)",
903
+ ).run(
904
+ memoryId,
905
+ dbScope,
906
+ candidate.kind,
907
+ candidate.content,
908
+ scopedProjectId,
909
+ scopedProjectPath,
910
+ scopedAgentId,
911
+ null,
912
+ candidate.confidence,
913
+ "internal",
914
+ JSON.stringify([`memory-ticket:${turnId}`, candidate.id]),
915
+ JSON.stringify(safeContext),
916
+ timestamp,
917
+ );
918
+ }
919
+ const lane = decision.finalScope;
920
+ const timelineId = `timeline:${sha256(`${turnId}:${candidate.id}:${memoryId}:${lane}`).slice(0, 32)}`;
921
+ db.prepare(
922
+ "INSERT OR IGNORE INTO terminal_memory_scope_timeline (event_id,turn_id,memory_id,lane,project_key,owner_key,created_at) VALUES (?,?,?,?,?,?,?)",
923
+ ).run(
924
+ timelineId,
925
+ turnId,
926
+ memoryId,
927
+ lane,
928
+ lane === "global" || lane === "user_global" ? null : turn.projectKey,
929
+ ["team", "agent"].includes(lane) ? turn.ownerKey : null,
930
+ timestamp,
931
+ );
932
+ const row = db.prepare(
933
+ "SELECT id,scope,kind,content,confidence,sensitivity,context_json FROM memory_entries WHERE id=?",
934
+ ).get(memoryId);
935
+ if (row && !retainedMemories.some((item) => item.id === row.id)) retainedMemories.push(row);
936
+ }
937
+
938
+ for (const decision of decisions) {
939
+ db.prepare(
940
+ "INSERT OR IGNORE INTO terminal_memory_curator_decisions (decision_id,turn_id,candidate_id,content_digest,semantic_disposition,semantic_scope,final_disposition,final_scope,reason_codes_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
941
+ ).run(
942
+ `decision:${sha256(`${turnId}:${decision.candidate.id}`).slice(0, 32)}`,
943
+ turnId,
944
+ decision.candidate.id,
945
+ decision.candidate.contentDigest,
946
+ decision.semanticDisposition,
947
+ decision.semanticScope,
948
+ decision.finalDisposition,
949
+ decision.finalScope,
950
+ JSON.stringify(decision.reasonCodes),
951
+ timestamp,
952
+ );
953
+ }
954
+
955
+ ticket = ticketFor(turn, parsed, input, timestamp, curatorStatus, decisions, retainedMemories.length);
956
+ decisionBatch = decisionBatchFor(turnId, timestamp, turn.projectKey, parsed.parseStatus, curatorStatus, decisions);
957
+ assertSafeLogRecord(ticket);
958
+ assertSafeLogRecord(decisionBatch);
959
+ db.prepare(
960
+ "INSERT INTO terminal_memory_episode_receipts (turn_id,ticket_json,project_key,created_at) VALUES (?,?,?,?)",
961
+ ).run(turnId, JSON.stringify(ticket), turn.projectKey, timestamp);
962
+ db.prepare(
963
+ "UPDATE terminal_memory_turn_intents SET status='completed', completed_at=? WHERE turn_id=?",
964
+ ).run(timestamp, turnId);
965
+ });
966
+ run();
967
+
968
+ if (normalizePermission(turn.permission) !== "read" && projectContextMatches && ownerContextMatches) {
969
+ mirrorCoreLogs(boundProjectPath, ticket, decisionBatch, input.coreFiles);
970
+ }
971
+ return {
972
+ cleaned: parsed.cleaned,
973
+ parseStatus: parsed.parseStatus,
974
+ receipt: ticket,
975
+ decisionBatch,
976
+ curatedMemories: retainedMemories,
977
+ idempotentReplay: replayedByRace,
978
+ curatorInvoked: typeof input.invokeCurator === "function",
979
+ curatorStatus,
980
+ };
981
+ }
982
+
983
+ function listScopedTimeline(db, input = {}) {
984
+ ensureGovernanceSchema(db);
985
+ if (!tableExists(db, "memory_entries")) return [];
986
+ const pKey = projectKey(input.projectPath);
987
+ const oKey = ownerKey(input.agentId);
988
+ const limit = Math.max(1, Math.min(100, Number(input.limit) || 20));
989
+ try {
990
+ return db.prepare(`
991
+ SELECT m.id,m.scope,m.kind,m.content,m.confidence,m.sensitivity,m.context_json,
992
+ t.lane,t.project_key,t.owner_key,t.created_at
993
+ FROM terminal_memory_scope_timeline t
994
+ JOIN memory_entries m ON m.id=t.memory_id
995
+ WHERE m.superseded_at IS NULL AND (
996
+ t.lane='user_global'
997
+ OR (t.lane='project' AND t.project_key=?)
998
+ OR (t.lane IN ('team','agent') AND t.owner_key=? AND (t.project_key IS NULL OR t.project_key=?))
999
+ )
1000
+ ORDER BY t.created_at DESC,t.event_id DESC
1001
+ LIMIT ?
1002
+ `).all(pKey, oKey, pKey, limit);
1003
+ } catch {
1004
+ return [];
1005
+ }
1006
+ }
1007
+
1008
+ module.exports = {
1009
+ GOVERNANCE_SCHEMA_VERSION,
1010
+ RECEIPT_SCHEMA_VERSION,
1011
+ DECISION_SCHEMA_VERSION,
1012
+ DEFAULT_EVENTS_HEADING,
1013
+ CURATOR_SYSTEM_PROMPT,
1014
+ ensureGovernanceSchema,
1015
+ ownerPolicyFromPrompt,
1016
+ projectKey,
1017
+ ownerKey,
1018
+ contentGateReasons,
1019
+ beginTurn,
1020
+ parseMainOutput,
1021
+ buildCuratorPayload,
1022
+ parseCuratorOutput,
1023
+ completeTurn,
1024
+ listScopedTimeline,
1025
+ mirrorCoreLogs,
1026
+ hasSecret,
1027
+ hasAbsolutePath,
1028
+ hasTranscriptBody,
1029
+ };