@sema-agent/core 5.17.0 → 5.18.1

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 (57) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/agents/subagent.js +24 -0
  3. package/dist/core/auto-compaction.d.ts +6 -0
  4. package/dist/core/auto-compaction.js +15 -1
  5. package/dist/core/checkpoint-store.d.ts +4 -2
  6. package/dist/core/governance-codes.js +1 -0
  7. package/dist/core/hooks.d.ts +9 -0
  8. package/dist/core/hooks.js +21 -0
  9. package/dist/core/mcp.js +3 -0
  10. package/dist/core/memory-engine/content-origin.d.ts +27 -0
  11. package/dist/core/memory-engine/content-origin.js +38 -0
  12. package/dist/core/memory-engine/engine.d.ts +12 -2
  13. package/dist/core/memory-engine/engine.js +172 -12
  14. package/dist/core/memory-engine/file-backend.d.ts +4 -0
  15. package/dist/core/memory-engine/file-backend.js +25 -3
  16. package/dist/core/memory-engine/index.d.ts +2 -1
  17. package/dist/core/memory-engine/index.js +2 -1
  18. package/dist/core/memory-engine/layout.d.ts +16 -0
  19. package/dist/core/memory-engine/layout.js +90 -2
  20. package/dist/core/memory-engine/sync-client.d.ts +1 -0
  21. package/dist/core/memory-engine/sync-client.js +23 -5
  22. package/dist/core/memory-engine/tools.d.ts +55 -0
  23. package/dist/core/memory-engine/tools.js +307 -0
  24. package/dist/core/memory-engine/types.d.ts +1 -1
  25. package/dist/core/memory.d.ts +4 -0
  26. package/dist/core/memory.js +15 -2
  27. package/dist/core/permission-rule-consent.d.ts +138 -0
  28. package/dist/core/permission-rule-consent.js +318 -0
  29. package/dist/core/permission-rule-model.d.ts +66 -0
  30. package/dist/core/permission-rule-model.js +135 -0
  31. package/dist/core/permission-rule-store.d.ts +89 -0
  32. package/dist/core/permission-rule-store.js +145 -0
  33. package/dist/core/permission-rules.d.ts +3 -2
  34. package/dist/core/permission-rules.js +9 -4
  35. package/dist/core/runner/prepare-memory.d.ts +3 -1
  36. package/dist/core/runner/prepare-memory.js +54 -14
  37. package/dist/core/runner/prepare-task.d.ts +12 -0
  38. package/dist/core/runner/prepare-task.js +206 -12
  39. package/dist/core/runner/runtask.d.ts +3 -1
  40. package/dist/core/runner/runtask.js +47 -5
  41. package/dist/core/runner/tool-output-projection.js +1 -1
  42. package/dist/core/tool-policy.d.ts +13 -1
  43. package/dist/core/tool-policy.js +93 -12
  44. package/dist/core/tools.js +1 -0
  45. package/dist/core/trace.d.ts +20 -0
  46. package/dist/core/types.d.ts +15 -0
  47. package/dist/core/wiring-manifest.d.ts +5 -1
  48. package/dist/core/wiring-manifest.js +2 -0
  49. package/dist/index.d.ts +7 -3
  50. package/dist/index.js +6 -2
  51. package/dist/stores/file/permission-rule-store.d.ts +32 -0
  52. package/dist/stores/file/permission-rule-store.js +213 -0
  53. package/dist/tools/fs/fs-bash.js +12 -5
  54. package/dist/tools/fs/fs-shared.d.ts +12 -0
  55. package/dist/tools/fs/fs-shared.js +65 -1
  56. package/dist/tools/web.js +2 -0
  57. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { formatMemoryAge } from "../memory-recall.js";
7
7
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
8
  import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./file-backend.js";
9
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
9
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, } from "./layout.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
11
11
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
12
12
 
@@ -32,6 +32,7 @@ export function buildMemoryInstruction(memoryDir, instructionFileName) {
32
32
  const dir = memoryDir.endsWith("/") ? memoryDir : `${memoryDir}/`;
33
33
  return MEMORY_INSTRUCTION_TEMPLATE.replaceAll("{{MEMORY_DIR}}", dir).replaceAll("{{INSTRUCTION_FILE}}", instructionFileName ?? "CLAUDE.md");
34
34
  }
35
+ export const MEMORY_RECALL_DISCIPLINE = "Before answering questions about earlier work, decisions, dates, people, or the user's preferences, look them up: `memory_search` finds entries by keyword and `memory_get` reads a full entry — the injected memory index only lists what exists. When a lookup comes up empty, say that you checked memory and found nothing instead of guessing.";
35
36
  export const MEMORY_INDEX_MAX_LINES = 200;
36
37
  export const MEMORY_INDEX_MAX_BYTES = 25 * 1024;
37
38
  export const STUB_ARCHIVED_LINE = "[body archived — request hydration by listing the slug in memory/.hydrate]";
@@ -85,6 +86,56 @@ export class MemoryEngine {
85
86
  catch {
86
87
  }
87
88
  }
89
+ recordRetrieved(ids) {
90
+ try {
91
+ recordRetrievedAccount(this.controlDir, ids, this.now);
92
+ }
93
+ catch {
94
+ }
95
+ }
96
+ readRetrievedAccount() {
97
+ try {
98
+ return readRetrievedAccount(this.controlDir);
99
+ }
100
+ catch {
101
+ return {};
102
+ }
103
+ }
104
+ pollutedSessions = new Map();
105
+ markSessionPolluted(sessionId, reason) {
106
+ if (!this.pollutedSessions.has(sessionId))
107
+ this.pollutedSessions.set(sessionId, { at: this.now(), reason });
108
+ let durable = false;
109
+ try {
110
+ durable = markSessionPolluted(this.controlDir, sessionId, reason, this.now);
111
+ }
112
+ catch {
113
+ durable = false;
114
+ }
115
+ if (!durable) {
116
+ const sink = this.onIncident;
117
+ if (sink !== undefined) {
118
+ try {
119
+ const err = new Error(`memory pollution marker for session could not be persisted — the mark holds in-process only until it succeeds`);
120
+ err.code = "memory.pollution_mark_failed";
121
+ sink(err);
122
+ }
123
+ catch {
124
+ }
125
+ }
126
+ }
127
+ }
128
+ sessionPollution(sessionId) {
129
+ const inProcess = this.pollutedSessions.get(sessionId);
130
+ if (inProcess !== undefined)
131
+ return inProcess;
132
+ try {
133
+ return readSessionPollution(this.controlDir, sessionId);
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
88
139
  async materialize(scopes, writeScope) {
89
140
  ensureDirExists(this.memoryDir);
90
141
  ensureDirExists(this.controlDir);
@@ -246,8 +297,8 @@ export class MemoryEngine {
246
297
  : `${f.reason}. ${scanRemediation(f.code)} Nothing was written.`;
247
298
  return { ok: false, code: f.code, reason, muted };
248
299
  }
249
- async harvest(handle) {
250
- const report = await this.harvestCore(handle);
300
+ async harvest(handle, opts) {
301
+ const report = await this.harvestCore(handle, opts);
251
302
  try {
252
303
  const gateItems = gateAnnouncementItems(report);
253
304
  if (gateItems.length > 0)
@@ -262,7 +313,8 @@ export class MemoryEngine {
262
313
  }
263
314
  return report;
264
315
  }
265
- async harvestCore(handle) {
316
+ async harvestCore(handle, opts) {
317
+ const pollutedReason = opts?.polluted?.reason;
266
318
  const startedAt = this.now();
267
319
  const report = {
268
320
  ok: true,
@@ -326,6 +378,97 @@ export class MemoryEngine {
326
378
  if (r.parsed.id !== undefined)
327
379
  presentIds.add(r.parsed.id);
328
380
  const recordByPath = new Map(records.map((r) => [r.canonical, r]));
381
+ const pollutedStubs = new Map(handle.materialized.filter((m) => !m.readonly && m.stub).map((m) => [m.path, m]));
382
+ const containPollutedRecord = async (f) => {
383
+ const rel = f.rel;
384
+ if (f.canonical !== handle.writableRoot && !f.canonical.startsWith(`${handle.writableRoot}${sep}`)) {
385
+ report.rejections.push({ path: rel, code: "outside_root", reason: "file resolves outside the writable memory root (containment gate, fail-closed)" });
386
+ return;
387
+ }
388
+ const baseId = handle.baseIds.get(f.canonical);
389
+ if (baseId !== undefined && !pollutedStubs.has(f.canonical) && revOfText(f.text, baseId) === handle.baseRevs.get(f.canonical))
390
+ return;
391
+ const pollutedStub = pollutedStubs.get(f.canonical);
392
+ if (pollutedStub) {
393
+ if (revOfText(f.text, pollutedStub.id) !== pollutedStub.rev) {
394
+ const sq = quarantineAndTombstone(f.canonical, f.text, join(this.controlDir, QUARANTINE_DIR), this.now);
395
+ const stubCaptured = sq.dest !== undefined;
396
+ if (stubCaptured && sq.removed)
397
+ report.movedToQuarantine.push(rel);
398
+ report.rejections.push({
399
+ path: rel,
400
+ code: "polluted",
401
+ reason: `memory write withheld: this session invoked a tool classified as an external content source, so its memory changes were not committed (archived-body stub; ${stubCaptured && sq.removed ? "file moved to quarantine for host review, and the next session re-projects the stub" : "containment incomplete — the edited stub may still be on the model-visible plane"})`,
402
+ });
403
+ if (sq.detail !== undefined || !sq.removed || !stubCaptured) {
404
+ const detail = `${sq.detail ?? (stubCaptured ? "suspect content still on the model-visible plane" : "quarantine capture failed")}${sq.removed ? "" : " — NOT contained"}`;
405
+ (report.quarantineFailures ??= []).push({ path: rel, contained: sq.removed, detail });
406
+ report.warnings.push(`quarantine escalation for ${rel}: ${detail}`);
407
+ }
408
+ }
409
+ return;
410
+ }
411
+ const q = quarantineAndTombstone(f.canonical, f.text, join(this.controlDir, QUARANTINE_DIR), this.now);
412
+ let contained = q.removed;
413
+ const priorId = handle.baseIds.get(f.canonical);
414
+ if (priorId !== undefined) {
415
+ const committed = await this.committedContentFor(priorId);
416
+ if (committed !== undefined) {
417
+ try {
418
+ writeFileNoFollow(f.canonical, committed);
419
+ report.restored.push(rel);
420
+ contained = true;
421
+ }
422
+ catch {
423
+ }
424
+ }
425
+ }
426
+ const captured = q.dest !== undefined;
427
+ if (captured && contained)
428
+ report.movedToQuarantine.push(rel);
429
+ report.rejections.push({
430
+ path: rel,
431
+ code: "polluted",
432
+ reason: `memory write withheld: this session invoked a tool classified as an external content source, so its memory changes were not committed (${captured && contained ? "file moved to quarantine for host review" : captured ? "quarantine copy captured; removal from the model-visible plane incomplete" : "quarantine copy FAILED; file removal " + (contained ? "succeeded" : "incomplete")})`,
433
+ });
434
+ if (q.detail !== undefined || !contained || !captured) {
435
+ const parts = [q.detail, !captured ? "quarantine capture failed" : undefined].filter((x) => x !== undefined);
436
+ const detail = `${parts.length > 0 ? parts.join("; ") : "suspect content still on the model-visible plane"}${contained ? "" : " — NOT contained"}`;
437
+ (report.quarantineFailures ??= []).push({ path: rel, contained, detail });
438
+ report.warnings.push(`quarantine escalation for ${rel}: ${detail}`);
439
+ }
440
+ };
441
+ const restorePollutedIndex = () => {
442
+ const pollutedIndexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
443
+ const canonicalIndex = canonicalize(pollutedIndexPath);
444
+ if (canonicalIndex !== pollutedIndexPath && !canonicalIndex.startsWith(`${handle.writableRoot}${sep}`)) {
445
+ report.warnings.push("memory index NOT restored: its path resolves outside the writable memory root (containment gate, fail-closed)");
446
+ return;
447
+ }
448
+ const indexNow = readSafe(pollutedIndexPath);
449
+ if (indexNow === undefined || indexNow === handle.indexText)
450
+ return;
451
+ try {
452
+ const dest = join(this.controlDir, QUARANTINE_DIR, `${this.now()}-polluted-${MEMORY_INDEX_FILENAME}`);
453
+ ensureDirExists(dirname(dest));
454
+ writeFileSync(dest, indexNow, "utf8");
455
+ }
456
+ catch {
457
+ }
458
+ try {
459
+ writeFileNoFollow(pollutedIndexPath, handle.indexText);
460
+ report.warnings.push("memory index restored to its pre-session state — this session's index additions were not retained (session polluted; the removed text was captured to quarantine)");
461
+ }
462
+ catch (err) {
463
+ report.warnings.push(`memory index could NOT be restored to its pre-session state: ${err instanceof Error ? err.message : String(err)}`);
464
+ }
465
+ };
466
+ const containPollutedDomain = async () => {
467
+ for (const f of records)
468
+ await containPollutedRecord(f);
469
+ restorePollutedIndex();
470
+ report.warnings.push(`memory harvest committed nothing this session: ${inlineUntrusted(pollutedReason, 200)}`);
471
+ };
329
472
  for (const m of handle.materialized.filter((x) => x.readonly)) {
330
473
  const text = readSafe(m.path);
331
474
  if (text === undefined) {
@@ -355,6 +498,8 @@ export class MemoryEngine {
355
498
  kind: "mass_deletion",
356
499
  detail: `${missingWritable.length}/${writable.length} materialized memory files are missing — judged an accident, harvest refused (no delete patches produced; explicit frontmatter tombstones are the only deletion channel)`,
357
500
  };
501
+ if (pollutedReason !== undefined)
502
+ await containPollutedDomain();
358
503
  return report;
359
504
  }
360
505
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
@@ -362,6 +507,8 @@ export class MemoryEngine {
362
507
  if (handle.indexBaselineLines > 0 && indexNow !== undefined && indexNow.trim() === "" && missingWritable.length > 0) {
363
508
  report.ok = false;
364
509
  report.incident = { kind: "index_cleared", detail: "MEMORY.md was emptied alongside missing memory files — judged an accident, harvest refused" };
510
+ if (pollutedReason !== undefined)
511
+ await containPollutedDomain();
365
512
  return report;
366
513
  }
367
514
  for (const m of missingWritable) {
@@ -382,7 +529,7 @@ export class MemoryEngine {
382
529
  .sort((a, b) => Number(baselinePaths.has(b.canonical)) - Number(baselinePaths.has(a.canonical)) || a.slug.localeCompare(b.slug));
383
530
  const kept = [];
384
531
  for (const f of inDomain) {
385
- if (kept.length >= this.maxFiles) {
532
+ if (pollutedReason === undefined && kept.length >= this.maxFiles) {
386
533
  report.rejections.push({ path: f.rel, code: "file_cap", reason: `memory file count exceeds the cap (${this.maxFiles}); consolidate before adding more` });
387
534
  continue;
388
535
  }
@@ -401,7 +548,7 @@ export class MemoryEngine {
401
548
  let processed = 0;
402
549
  for (let i = 0; i < kept.length; i++) {
403
550
  const f = kept[i];
404
- if (processed >= this.harvestFileBudget || this.now() - startedAt > this.harvestDeadlineMs) {
551
+ if (pollutedReason === undefined && (processed >= this.harvestFileBudget || this.now() - startedAt > this.harvestDeadlineMs)) {
405
552
  report.degraded = {
406
553
  reason: processed >= this.harvestFileBudget ? "file_budget" : "deadline",
407
554
  pending: kept.slice(i).map((r) => r.rel),
@@ -417,6 +564,10 @@ export class MemoryEngine {
417
564
  if (fastBaseId !== undefined && !stubByPath.has(f.canonical) && revOfText(f.text, fastBaseId) === revByBasePath.get(f.canonical))
418
565
  continue;
419
566
  processed++;
567
+ if (pollutedReason !== undefined) {
568
+ await containPollutedRecord(f);
569
+ continue;
570
+ }
420
571
  if (f.sizeBytes > this.perFileBytes) {
421
572
  report.rejections.push({ path: rel, code: "too_large", reason: `memory file is ${f.sizeBytes} bytes — over the ${this.perFileBytes}-byte cap; split or trim it (rejected, NOT truncated)` });
422
573
  continue;
@@ -524,13 +675,18 @@ export class MemoryEngine {
524
675
  patches.push({ op: "add", id: entry.id, entry });
525
676
  }
526
677
  let patchReport;
527
- try {
528
- patchReport = await this.backend.applyPatches(patches);
678
+ if (pollutedReason !== undefined) {
679
+ patchReport = { applied: [], conflicts: [] };
529
680
  }
530
- catch (err) {
531
- report.ok = false;
532
- report.incident = { kind: "sidecar_corrupt", detail: `memory commit refused: ${err instanceof Error ? err.message : String(err)}` };
533
- return report;
681
+ else {
682
+ try {
683
+ patchReport = await this.backend.applyPatches(patches);
684
+ }
685
+ catch (err) {
686
+ report.ok = false;
687
+ report.incident = { kind: "sidecar_corrupt", detail: `memory commit refused: ${err instanceof Error ? err.message : String(err)}` };
688
+ return report;
689
+ }
534
690
  }
535
691
  const appliedIds = new Set(patchReport.applied.filter((a) => a.op !== "delete").map((a) => a.id));
536
692
  for (const p of pendingProjections) {
@@ -558,6 +714,10 @@ export class MemoryEngine {
558
714
  const drained = this.backend.drainInboundFindings?.();
559
715
  if (drained !== undefined && drained.length > 0)
560
716
  report.inboundFindings = drained;
717
+ if (pollutedReason !== undefined) {
718
+ restorePollutedIndex();
719
+ report.warnings.push(`memory harvest committed nothing this session: ${inlineUntrusted(pollutedReason, 200)}`);
720
+ }
561
721
  const indexRejection = this.gateDerivedIndex(handle);
562
722
  if (indexRejection !== undefined)
563
723
  report.rejections.push(indexRejection);
@@ -38,10 +38,14 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
38
38
  private inboundGate;
39
39
  private readScope;
40
40
  listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
41
+ private listHeadersWith;
41
42
  getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
43
+ private getByIdsWith;
44
+ retrievalView(): MemoryBackend;
42
45
  search(query: string, scopes: readonly string[], opts?: {
43
46
  limit?: number;
44
47
  }): Promise<ScoredMemoryEntry[]>;
48
+ private searchWith;
45
49
  applyPatches(patches: readonly NotePatch[]): Promise<PatchReport>;
46
50
  private txnLockDir;
47
51
  private acquireTxnLock;
@@ -374,27 +374,49 @@ export class FileMemoryEngineBackend {
374
374
  return entries;
375
375
  }
376
376
  async listHeaders(scopes) {
377
+ return this.listHeadersWith(scopes, true);
378
+ }
379
+ listHeadersWith(scopes, adopt) {
377
380
  const out = [];
378
381
  for (const scope of scopes) {
379
382
  const dir = this.scopeDir(scope);
380
- for (const e of this.readScope(scope)) {
383
+ for (const e of this.readScope(scope, adopt)) {
381
384
  out.push(headerOf(e, join(dir, `${e.slug}.md`)));
382
385
  }
383
386
  }
384
387
  return out;
385
388
  }
386
389
  async getByIds(ids) {
390
+ return this.getByIdsWith(ids, true);
391
+ }
392
+ getByIdsWith(ids, adopt) {
387
393
  const want = new Set(ids);
388
394
  const out = [];
389
395
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
390
- for (const e of this.readScope(scope)) {
396
+ for (const e of this.readScope(scope, adopt)) {
391
397
  if (want.has(e.id))
392
398
  out.push(e);
393
399
  }
394
400
  }
395
401
  return out;
396
402
  }
403
+ retrievalView() {
404
+ const refuse = (op) => {
405
+ throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
406
+ };
407
+ return {
408
+ listHeaders: async (scopes) => this.listHeadersWith(scopes, false),
409
+ getByIds: async (ids) => this.getByIdsWith(ids, false),
410
+ search: async (query, scopes, opts) => this.searchWith(query, scopes, opts, false),
411
+ applyPatches: async () => refuse("applyPatches"),
412
+ getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
413
+ setConsolidationCursor: async () => refuse("setConsolidationCursor"),
414
+ };
415
+ }
397
416
  async search(query, scopes, opts) {
417
+ return this.searchWith(query, scopes, opts, true);
418
+ }
419
+ searchWith(query, scopes, opts, adopt) {
398
420
  const limit = opts?.limit ?? 20;
399
421
  const q = termSet(query);
400
422
  if (q.size === 0)
@@ -402,7 +424,7 @@ export class FileMemoryEngineBackend {
402
424
  const scored = [];
403
425
  for (const scope of scopes) {
404
426
  const dir = this.scopeDir(scope);
405
- for (const e of this.readScope(scope)) {
427
+ for (const e of this.readScope(scope, adopt)) {
406
428
  const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
407
429
  const d = jaccardDistance(q, haystack);
408
430
  if (d === null)
@@ -1,4 +1,5 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
2
+ export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, type MemorySearchDetails, type MemorySearchHit, type MemoryGetDetails, } from "./tools.js";
2
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
3
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile } from "./file-backend.js";
4
5
  export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, } from "./layout.js";
@@ -1,4 +1,5 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
2
+ export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, } from "./tools.js";
2
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
3
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH } from "./file-backend.js";
4
5
  export { ControlPlaneCorruptError, deriveControlPlaneDir, deriveRepoControlPlaneDir, deriveRepoKey, deriveRepoMemoryDir, deriveProjectMemoryDir, deriveProjectControlDir, recordProjectIdHint, lookupProjectIdHint, PROJECT_ID_HINTS_FILE, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, claimRootScope, rootScopeOf, enqueueMemoryAnnouncement, drainMemoryAnnouncements, peekMemoryAnnouncements, bumpScanFuse, scanFuseCount, clearScanFuse, ANNOUNCEMENTS_FILE, MEMORY_ANNOUNCEMENTS_MAX, SCAN_FUSE_FILE, SCAN_FUSE_THRESHOLD, } from "./layout.js";
@@ -36,6 +36,7 @@ export interface QuarantineOutcome {
36
36
  removed: boolean;
37
37
  detail?: string;
38
38
  }
39
+ export declare function writeFileNoFollow(path: string, content: string): void;
39
40
  export declare function quarantineAndTombstone(path: string, content: string, quarantineDir: string, now: () => number): QuarantineOutcome;
40
41
  export declare const ANNOUNCEMENTS_FILE = "announcements.json";
41
42
  export declare const MEMORY_ANNOUNCEMENTS_MAX = 20;
@@ -57,6 +58,21 @@ export declare function bumpScanFuse(controlDir: string, key: string): number;
57
58
  export declare function readIndexRevs(controlDir: string): Record<string, string>;
58
59
  export declare function writeIndexRevs(controlDir: string, revs: Record<string, string>): void;
59
60
  export declare function scanFuseCount(controlDir: string, key: string): number;
61
+ export declare const SESSION_POLLUTION_DIR = "session-pollution";
62
+ export interface SessionPollutionRecord {
63
+ at: number;
64
+ reason: string;
65
+ }
66
+ export declare function markSessionPolluted(controlDir: string, sessionId: string, reason: string, now: () => number): boolean;
67
+ export declare function readSessionPollution(controlDir: string, sessionId: string): SessionPollutionRecord | undefined;
68
+ export declare const USAGE_RETRIEVED_FILE = "usage-retrieved.json";
69
+ export declare const USAGE_RETRIEVED_MAX_IDS = 4096;
70
+ export interface RetrievedAccountRow {
71
+ count: number;
72
+ lastAt: number;
73
+ }
74
+ export declare function recordRetrievedAccount(controlDir: string, ids: readonly string[], now: () => number): void;
75
+ export declare function readRetrievedAccount(controlDir: string): Record<string, RetrievedAccountRow>;
60
76
  export declare function clearScanFuse(controlDir: string, keys: Iterable<string>): void;
61
77
  export declare function writeAllSync(fd: number, data: string): void;
62
78
  export declare function atomicWriteFileSync(path: string, data: string): void;
@@ -1,4 +1,5 @@
1
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
1
+ import { closeSync, constants as fsConstants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
+ const { O_WRONLY, O_CREAT, O_TRUNC, O_NOFOLLOW } = fsConstants;
2
3
  import { homedir } from "node:os";
3
4
  import { createHash } from "node:crypto";
4
5
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
@@ -306,6 +307,15 @@ export function ensureDirExists(dir) {
306
307
  }
307
308
  export const QUARANTINE_DIR = "quarantine";
308
309
  const DELETED_TOMBSTONE = "---\ndeleted: true\n---\n";
310
+ export function writeFileNoFollow(path, content) {
311
+ const fd = openSync(path, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0o644);
312
+ try {
313
+ writeFileSync(fd, content, "utf8");
314
+ }
315
+ finally {
316
+ closeSync(fd);
317
+ }
318
+ }
309
319
  let quarantineSeq = 0;
310
320
  export function quarantineAndTombstone(path, content, quarantineDir, now) {
311
321
  let captured;
@@ -340,7 +350,7 @@ export function quarantineAndTombstone(path, content, quarantineDir, now) {
340
350
  }
341
351
  catch (rmErr) {
342
352
  try {
343
- writeFileSync(path, DELETED_TOMBSTONE, "utf8");
353
+ writeFileNoFollow(path, DELETED_TOMBSTONE);
344
354
  removed = true;
345
355
  detail = `${detail !== undefined ? `${detail}; ` : ""}delete failed (${rmErr instanceof Error ? rmErr.message : String(rmErr)}) — tombstoned in place`;
346
356
  }
@@ -589,6 +599,84 @@ export function writeIndexRevs(controlDir, revs) {
589
599
  export function scanFuseCount(controlDir, key) {
590
600
  return coerceFuse(readSidecarJson(controlDir, SCAN_FUSE_FILE))[key] ?? 0;
591
601
  }
602
+ export const SESSION_POLLUTION_DIR = "session-pollution";
603
+ function pollutionPath(controlDir, sessionId) {
604
+ return join(controlDir, SESSION_POLLUTION_DIR, `${encodeURIComponent(sessionId)}.json`);
605
+ }
606
+ export function markSessionPolluted(controlDir, sessionId, reason, now) {
607
+ const path = pollutionPath(controlDir, sessionId);
608
+ try {
609
+ ensureDirExists(dirname(path));
610
+ const record = { at: now(), reason };
611
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
612
+ return true;
613
+ }
614
+ catch (err) {
615
+ if (err instanceof Error && "code" in err && err.code === "EEXIST")
616
+ return true;
617
+ return false;
618
+ }
619
+ }
620
+ export function readSessionPollution(controlDir, sessionId) {
621
+ const path = pollutionPath(controlDir, sessionId);
622
+ let raw;
623
+ try {
624
+ raw = readFileSync(path, "utf8");
625
+ }
626
+ catch {
627
+ if (!existsSync(path))
628
+ return undefined;
629
+ return { at: 0, reason: "pollution marker present but unreadable (kept fail-closed)" };
630
+ }
631
+ try {
632
+ const parsed = JSON.parse(raw);
633
+ if (parsed !== null && typeof parsed === "object") {
634
+ const at = parsed.at;
635
+ const reason = parsed.reason;
636
+ if (typeof at === "number" && typeof reason === "string")
637
+ return { at, reason };
638
+ }
639
+ }
640
+ catch {
641
+ }
642
+ return { at: 0, reason: "pollution marker present but unreadable (kept fail-closed)" };
643
+ }
644
+ export const USAGE_RETRIEVED_FILE = "usage-retrieved.json";
645
+ export const USAGE_RETRIEVED_MAX_IDS = 4096;
646
+ function coerceRetrievedAccount(raw) {
647
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
648
+ return {};
649
+ const out = {};
650
+ for (const [k, v] of Object.entries(raw)) {
651
+ const row = v;
652
+ if (row && typeof row === "object" && typeof row.count === "number" && Number.isFinite(row.count) && row.count > 0 && typeof row.lastAt === "number" && Number.isFinite(row.lastAt)) {
653
+ out[k] = { count: Math.floor(row.count), lastAt: row.lastAt };
654
+ }
655
+ }
656
+ return out;
657
+ }
658
+ export function recordRetrievedAccount(controlDir, ids, now) {
659
+ if (ids.length === 0)
660
+ return;
661
+ const at = now();
662
+ lockedJournaledUpdate(controlDir, USAGE_RETRIEVED_FILE, (current) => {
663
+ const rows = coerceRetrievedAccount(current);
664
+ for (const id of ids) {
665
+ const prior = rows[id];
666
+ rows[id] = { count: (prior?.count ?? 0) + 1, lastAt: at };
667
+ }
668
+ const keys = Object.keys(rows);
669
+ if (keys.length > USAGE_RETRIEVED_MAX_IDS) {
670
+ keys.sort((a, b) => rows[a].lastAt - rows[b].lastAt || (a < b ? -1 : 1));
671
+ for (const cold of keys.slice(0, keys.length - USAGE_RETRIEVED_MAX_IDS))
672
+ delete rows[cold];
673
+ }
674
+ return rows;
675
+ });
676
+ }
677
+ export function readRetrievedAccount(controlDir) {
678
+ return coerceRetrievedAccount(readSidecarJson(controlDir, USAGE_RETRIEVED_FILE));
679
+ }
592
680
  export function clearScanFuse(controlDir, keys) {
593
681
  const wanted = [...keys];
594
682
  if (wanted.length === 0)
@@ -46,6 +46,7 @@ export interface SyncMemoryScopeOptions {
46
46
  now?: () => number;
47
47
  maxPushEntries?: number;
48
48
  maxPullEntries?: number;
49
+ maxEntryBytes?: number;
49
50
  }
50
51
  export interface MemorySyncClientResult {
51
52
  ok: boolean;
@@ -1,5 +1,7 @@
1
- import { computeEntryRev } from "./frontmatter.js";
1
+ import { computeEntryRev, serializeEntryFile } from "./frontmatter.js";
2
2
  import { screenInboundEntries } from "./data-plane.js";
3
+ import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
4
+ import { MAX_MEMORY_BYTES } from "../memory.js";
3
5
  import { reconcileMemoryEntries } from "./sync.js";
4
6
  function memorySyncPath(scope) {
5
7
  return `/v1/memory/sync/${encodeURIComponent(scope)}`;
@@ -153,7 +155,7 @@ export async function syncMemoryScope(opts) {
153
155
  if (cursor !== undefined && cursor.peer !== peer) {
154
156
  throw new Error(`memory sync: cursor is for peer ${JSON.stringify(cursor.peer)}, not ${JSON.stringify(peer)}`);
155
157
  }
156
- for (const [name, v] of [["maxPushEntries", opts.maxPushEntries], ["maxPullEntries", opts.maxPullEntries]]) {
158
+ for (const [name, v] of [["maxPushEntries", opts.maxPushEntries], ["maxPullEntries", opts.maxPullEntries], ["maxEntryBytes", opts.maxEntryBytes]]) {
157
159
  if (v !== undefined && (!Number.isSafeInteger(v) || v < 1)) {
158
160
  throw new Error(`memory sync: ${name} must be a positive integer (>= 1) when set, got ${String(v)}`);
159
161
  }
@@ -167,10 +169,26 @@ export async function syncMemoryScope(opts) {
167
169
  if (plan.pull.length > 0 || plan.deleteLocal.length > 0 || plan.conflicts.length > 0 || plan.cleared.length > 0) {
168
170
  throw new Error("memory sync: internal invariant violated — a baseline-stub peer produced pull/deleteLocal/conflict/cleared legs");
169
171
  }
170
- let pushEntries = plan.push;
172
+ const pushGateConflicts = [];
173
+ const gatedPush = plan.push.filter((e) => {
174
+ const findings = [];
175
+ const nameFinding = scanMemoryFileName(`${e.slug}.md`);
176
+ if (nameFinding !== undefined)
177
+ findings.push(nameFinding);
178
+ findings.push(...scanMemoryWrite(serializeEntryFile(e), { maxBytes: opts.maxEntryBytes ?? MAX_MEMORY_BYTES }));
179
+ if (findings.length === 0)
180
+ return true;
181
+ pushGateConflicts.push({
182
+ side: "local",
183
+ id: e.id,
184
+ reason: `push_gate: ${findings.map((f) => `${f.code}: ${f.reason}`).join("; ")} — the entry was NOT pushed (it stays local; fix or remove it to stop this report)`,
185
+ });
186
+ return false;
187
+ });
188
+ let pushEntries = gatedPush;
171
189
  let pushTruncated = false;
172
190
  if (opts.maxPushEntries !== undefined) {
173
- pushEntries = [...plan.push].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
191
+ pushEntries = [...gatedPush].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
174
192
  if (pushEntries.length > opts.maxPushEntries) {
175
193
  pushEntries = pushEntries.slice(0, opts.maxPushEntries);
176
194
  pushTruncated = true;
@@ -184,7 +202,7 @@ export async function syncMemoryScope(opts) {
184
202
  ...(opts.maxPullEntries !== undefined ? { pull: { limit: opts.maxPullEntries } } : {}),
185
203
  };
186
204
  const resp = parseMemorySyncResponse(await transport(memorySyncPath(scope), request), scope, peer);
187
- const conflicts = resp.conflicts.map((c) => ({ side: "server", ...c }));
205
+ const conflicts = [...pushGateConflicts, ...resp.conflicts.map((c) => ({ side: "server", ...c }))];
188
206
  const rejected = new Set();
189
207
  const localIds = new Set(local.map((e) => e.id));
190
208
  const serverEntryIds = new Set(resp.serverEntries.map((e) => e.id));
@@ -0,0 +1,55 @@
1
+ import type { ToolSpec } from "../types.js";
2
+ import type { MemoryBackend } from "./types.js";
3
+ export declare const MEMORY_SEARCH_TOOL_NAME = "memory_search";
4
+ export declare const MEMORY_GET_TOOL_NAME = "memory_get";
5
+ export declare const MEMORY_ENGINE_TOOL_NAMES: readonly ["memory_search", "memory_get"];
6
+ export declare const MEMORY_SEARCH_DEFAULT_LIMIT = 8;
7
+ export declare const MEMORY_SEARCH_MAX_LIMIT = 20;
8
+ export declare const MEMORY_SEARCH_SNIPPET_CAP = 600;
9
+ export declare const MEMORY_GET_PAGE_LINES = 200;
10
+ export declare const MEMORY_GET_MAX_PAGE_LINES = 1000;
11
+ export declare const MEMORY_GET_PAGE_CAP_BYTES: number;
12
+ export interface MemoryEnginePlane {
13
+ backend: MemoryBackend;
14
+ scopes: readonly string[];
15
+ recordRetrieved: (ids: readonly string[]) => void;
16
+ }
17
+ export interface MemoryEngineToolsOptions {
18
+ planes: ReadonlyArray<MemoryEnginePlane>;
19
+ now?: () => number;
20
+ }
21
+ export interface MemorySearchHit {
22
+ id: string;
23
+ scope: string;
24
+ slug: string;
25
+ name?: string;
26
+ description?: string;
27
+ score: number;
28
+ mtimeMs: number;
29
+ sizeBytes: number;
30
+ }
31
+ export interface MemorySearchDetails {
32
+ outcome: "ok" | "refused" | "failed";
33
+ reason?: string;
34
+ hits?: MemorySearchHit[];
35
+ }
36
+ export interface MemoryGetDetails {
37
+ outcome: "ok" | "not_found" | "ambiguous" | "refused" | "failed";
38
+ reason?: string;
39
+ id?: string;
40
+ scope?: string;
41
+ slug?: string;
42
+ candidates?: Array<{
43
+ scope: string;
44
+ slug: string;
45
+ id: string;
46
+ }>;
47
+ offset?: number;
48
+ lines?: number;
49
+ totalLines?: number;
50
+ }
51
+ export declare function cutToBytes(text: string, maxBytes: number): {
52
+ text: string;
53
+ omittedBytes: number;
54
+ };
55
+ export declare function createMemoryEngineTools(opts: MemoryEngineToolsOptions): ToolSpec[];