@sema-agent/core 5.26.0 → 5.27.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/CHANGELOG.md +49 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/subagent.d.ts +5 -2
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +39 -4
- package/dist/core/hooks.js +15 -12
- package/dist/core/memory-engine/engine.d.ts +8 -5
- package/dist/core/memory-engine/engine.js +20 -6
- package/dist/core/memory-engine/file-backend.d.ts +81 -0
- package/dist/core/memory-engine/file-backend.js +250 -24
- package/dist/core/memory-engine/types.d.ts +8 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-model.d.ts +70 -5
- package/dist/core/permission-rule-model.js +58 -0
- package/dist/core/runner/prepare-memory.js +14 -9
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +29 -8
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/task-registry-agent.d.ts +4 -3
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +9 -2
- package/dist/core/types.d.ts +35 -7
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -229,6 +229,29 @@ export class FileMemoryEngineBackend {
|
|
|
229
229
|
scopeDir(scope) {
|
|
230
230
|
return registerScope(this.directoryRoot, this.controlPlaneRoot, scope);
|
|
231
231
|
}
|
|
232
|
+
containInboundReject(finding, absPath, text, entryId, committed) {
|
|
233
|
+
this.inboundFindings.push(finding);
|
|
234
|
+
const q = quarantineAndTombstone(absPath, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
|
|
235
|
+
const shadowText = committed !== undefined ? this.readCommittedShadow(entryId) : undefined;
|
|
236
|
+
let restoredShadow = false;
|
|
237
|
+
if (shadowText !== undefined) {
|
|
238
|
+
try {
|
|
239
|
+
atomicWriteFileSync(absPath, shadowText);
|
|
240
|
+
restoredShadow = true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
|
|
246
|
+
const contained = q.removed || restoredShadow;
|
|
247
|
+
this.inboundFindings.push({
|
|
248
|
+
path: finding.path,
|
|
249
|
+
code: "quarantine_failed",
|
|
250
|
+
reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return shadowText;
|
|
254
|
+
}
|
|
232
255
|
inboundGate(relPath, text) {
|
|
233
256
|
const nameFinding = scanMemoryFileName(relPath);
|
|
234
257
|
if (nameFinding !== undefined)
|
|
@@ -288,27 +311,7 @@ export class FileMemoryEngineBackend {
|
|
|
288
311
|
}
|
|
289
312
|
}
|
|
290
313
|
if (finding) {
|
|
291
|
-
this.
|
|
292
|
-
const q = quarantineAndTombstone(f.path, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
|
|
293
|
-
const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
|
|
294
|
-
let restoredShadow = false;
|
|
295
|
-
if (shadowText !== undefined) {
|
|
296
|
-
try {
|
|
297
|
-
atomicWriteFileSync(f.path, shadowText);
|
|
298
|
-
restoredShadow = true;
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
}
|
|
302
|
-
entry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
303
|
-
}
|
|
304
|
-
if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
|
|
305
|
-
const contained = q.removed || restoredShadow;
|
|
306
|
-
this.inboundFindings.push({
|
|
307
|
-
path: finding.path,
|
|
308
|
-
code: "quarantine_failed",
|
|
309
|
-
reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
314
|
+
const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
|
|
312
315
|
if (shadowText === undefined) {
|
|
313
316
|
if (committed !== undefined) {
|
|
314
317
|
delete ledger[entry.id];
|
|
@@ -316,6 +319,7 @@ export class FileMemoryEngineBackend {
|
|
|
316
319
|
}
|
|
317
320
|
continue;
|
|
318
321
|
}
|
|
322
|
+
entry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
319
323
|
}
|
|
320
324
|
else {
|
|
321
325
|
if (committed !== undefined)
|
|
@@ -377,10 +381,13 @@ export class FileMemoryEngineBackend {
|
|
|
377
381
|
return this.listHeadersWith(scopes, true);
|
|
378
382
|
}
|
|
379
383
|
listHeadersWith(scopes, adopt) {
|
|
384
|
+
return this.listHeadersFrom(scopes, (s) => this.readScope(s, adopt));
|
|
385
|
+
}
|
|
386
|
+
listHeadersFrom(scopes, read) {
|
|
380
387
|
const out = [];
|
|
381
388
|
for (const scope of scopes) {
|
|
382
389
|
const dir = this.scopeDir(scope);
|
|
383
|
-
for (const e of
|
|
390
|
+
for (const e of read(scope)) {
|
|
384
391
|
out.push(headerOf(e, join(dir, `${e.slug}.md`)));
|
|
385
392
|
}
|
|
386
393
|
}
|
|
@@ -390,10 +397,13 @@ export class FileMemoryEngineBackend {
|
|
|
390
397
|
return this.getByIdsWith(ids, true);
|
|
391
398
|
}
|
|
392
399
|
getByIdsWith(ids, adopt) {
|
|
400
|
+
return this.getByIdsFrom(ids, (s) => this.readScope(s, adopt));
|
|
401
|
+
}
|
|
402
|
+
getByIdsFrom(ids, read) {
|
|
393
403
|
const want = new Set(ids);
|
|
394
404
|
const out = [];
|
|
395
405
|
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
396
|
-
for (const e of
|
|
406
|
+
for (const e of read(scope)) {
|
|
397
407
|
if (want.has(e.id))
|
|
398
408
|
out.push(e);
|
|
399
409
|
}
|
|
@@ -413,10 +423,226 @@ export class FileMemoryEngineBackend {
|
|
|
413
423
|
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
414
424
|
};
|
|
415
425
|
}
|
|
426
|
+
restrictedFindingKeys = new Set();
|
|
427
|
+
recordRestrictedFinding(key, finding) {
|
|
428
|
+
if (this.restrictedFindingKeys.has(key))
|
|
429
|
+
return;
|
|
430
|
+
this.restrictedFindingKeys.add(key);
|
|
431
|
+
this.inboundFindings.push(finding);
|
|
432
|
+
}
|
|
433
|
+
readScopeCommitted(scope, audit) {
|
|
434
|
+
const dir = this.scopeDir(scope);
|
|
435
|
+
const isRoot = dir === this.directoryRoot;
|
|
436
|
+
const files = scanEntryFiles(dir, {
|
|
437
|
+
exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
|
|
438
|
+
onSkip: audit
|
|
439
|
+
? (p, kind) => {
|
|
440
|
+
if (kind !== "unreadable")
|
|
441
|
+
return;
|
|
442
|
+
const rel = relative(this.directoryRoot, p);
|
|
443
|
+
this.recordRestrictedFinding(`unreadable|${rel}`, {
|
|
444
|
+
path: rel,
|
|
445
|
+
code: "unreadable",
|
|
446
|
+
reason: "the path could not be read (lstat/readdir failed) — the restricted-session divergence audit is incomplete under it",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
: undefined,
|
|
450
|
+
});
|
|
451
|
+
this.ledger = undefined;
|
|
452
|
+
const ledger = this.loadLedger();
|
|
453
|
+
const entries = [];
|
|
454
|
+
for (const f of files) {
|
|
455
|
+
const rel = relative(this.directoryRoot, f.path);
|
|
456
|
+
let text;
|
|
457
|
+
try {
|
|
458
|
+
text = readFileSync(f.path, "utf8");
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
if (audit) {
|
|
462
|
+
this.recordRestrictedFinding(`unreadable|${rel}`, {
|
|
463
|
+
path: rel,
|
|
464
|
+
code: "unreadable",
|
|
465
|
+
reason: "memory entry file could not be read — it is missing from this restricted session's view",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const parsed = parseEntryFile(text);
|
|
471
|
+
if (parsed.id === undefined)
|
|
472
|
+
continue;
|
|
473
|
+
const entry = entryFromFile(text, parsed.id, f.slug, scope);
|
|
474
|
+
const committed = ledger[entry.id];
|
|
475
|
+
if (committed === entry.rev) {
|
|
476
|
+
if (audit && this.readCommittedShadow(entry.id) === undefined) {
|
|
477
|
+
try {
|
|
478
|
+
atomicWriteFileSync(this.shadowPath(entry.id), text);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
entries.push(entry);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
|
|
487
|
+
if (shadowText !== undefined) {
|
|
488
|
+
const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
489
|
+
if (shadowEntry.rev === entry.rev) {
|
|
490
|
+
entries.push(entry);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
|
|
494
|
+
if (otherProjection !== undefined) {
|
|
495
|
+
if (audit) {
|
|
496
|
+
const finding = this.inboundGate(rel, text);
|
|
497
|
+
if (finding !== undefined) {
|
|
498
|
+
this.containInboundReject(finding, f.path, text, entry.id, undefined);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
|
|
502
|
+
path: rel,
|
|
503
|
+
code: "restricted_divergence",
|
|
504
|
+
reason: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (audit) {
|
|
511
|
+
const finding = this.inboundGate(rel, text);
|
|
512
|
+
if (finding !== undefined) {
|
|
513
|
+
this.containInboundReject(finding, f.path, text, entry.id, committed);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.recordRestrictedFinding(`divergence|${rel}|${entry.rev}`, {
|
|
517
|
+
path: rel,
|
|
518
|
+
code: "restricted_divergence",
|
|
519
|
+
reason: "on-disk content diverges from the committed entry with no backend transaction backing it; this adoption-restricted session serves the committed content and leaves the disk state pending — an unrestricted session can adopt it normally",
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
entries.push(shadowEntry);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (committed !== undefined) {
|
|
527
|
+
if (audit) {
|
|
528
|
+
this.recordRestrictedFinding(`legacy|${rel}|${entry.rev}`, {
|
|
529
|
+
path: rel,
|
|
530
|
+
code: "restricted_divergence",
|
|
531
|
+
reason: "on-disk content diverges from a committed entry that has no committed shadow copy; this adoption-restricted session withholds the entry (nothing trustworthy to serve) — an unrestricted session can adjudicate it",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (audit) {
|
|
537
|
+
const finding = this.inboundGate(rel, text);
|
|
538
|
+
if (finding !== undefined) {
|
|
539
|
+
this.containInboundReject(finding, f.path, text, entry.id, undefined);
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
this.recordRestrictedFinding(`uncommitted|${rel}|${entry.rev}`, {
|
|
543
|
+
path: rel,
|
|
544
|
+
code: "restricted_divergence",
|
|
545
|
+
reason: "an id-bearing memory file exists on disk that the committed account has never seen; this adoption-restricted session neither adopts nor serves it — it stays on disk for an unrestricted session to adopt normally",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return entries;
|
|
551
|
+
}
|
|
552
|
+
idProjectionElsewhere(id, excludeAbsPath) {
|
|
553
|
+
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
554
|
+
const dir = this.scopeDir(scope);
|
|
555
|
+
const isRoot = dir === this.directoryRoot;
|
|
556
|
+
for (const f of scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined })) {
|
|
557
|
+
if (f.path === excludeAbsPath)
|
|
558
|
+
continue;
|
|
559
|
+
const text = readSafe(f.path);
|
|
560
|
+
if (text === undefined)
|
|
561
|
+
continue;
|
|
562
|
+
if (parseEntryFile(text).id === id)
|
|
563
|
+
return relative(this.directoryRoot, f.path);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
restrictedAdoptionView(opts) {
|
|
569
|
+
const audit = opts?.audit === true;
|
|
570
|
+
const refuse = (op) => {
|
|
571
|
+
throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
|
|
572
|
+
};
|
|
573
|
+
return {
|
|
574
|
+
listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScopeCommitted(s, audit)),
|
|
575
|
+
getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScopeCommitted(s, audit)),
|
|
576
|
+
search: async (query, scopes, o) => this.searchFrom(query, scopes, o, (s) => this.readScopeCommitted(s, audit)),
|
|
577
|
+
applyPatches: async () => refuse("applyPatches"),
|
|
578
|
+
getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
|
|
579
|
+
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
auditRestrictedDivergence(scopes) {
|
|
583
|
+
for (const scope of scopes)
|
|
584
|
+
this.readScopeCommitted(scope, true);
|
|
585
|
+
const ledger = { ...this.loadLedger() };
|
|
586
|
+
const present = new Map();
|
|
587
|
+
let complete = true;
|
|
588
|
+
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
589
|
+
const dir = this.scopeDir(scope);
|
|
590
|
+
const isRoot = dir === this.directoryRoot;
|
|
591
|
+
const files = scanEntryFiles(dir, {
|
|
592
|
+
exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
|
|
593
|
+
onSkip: (_p, kind) => {
|
|
594
|
+
if (kind === "unreadable")
|
|
595
|
+
complete = false;
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
for (const f of files) {
|
|
599
|
+
const text = readSafe(f.path);
|
|
600
|
+
if (text === undefined) {
|
|
601
|
+
complete = false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const id = parseEntryFile(text).id;
|
|
605
|
+
if (id !== undefined)
|
|
606
|
+
present.set(id, [...(present.get(id) ?? []), relative(this.directoryRoot, f.path)]);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
for (const [id, paths] of present) {
|
|
610
|
+
if (paths.length < 2 || ledger[id] === undefined)
|
|
611
|
+
continue;
|
|
612
|
+
this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
|
|
613
|
+
path: paths[0] ?? id,
|
|
614
|
+
code: "restricted_divergence",
|
|
615
|
+
reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates which projection is real`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (!complete) {
|
|
619
|
+
this.recordRestrictedFinding(`missing-audit-incomplete`, {
|
|
620
|
+
path: ".",
|
|
621
|
+
code: "restricted_divergence",
|
|
622
|
+
reason: "the divergence audit could not scan every path (unreadable subtree) — missing-entry detection was skipped this pass rather than judged from an incomplete scan",
|
|
623
|
+
});
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
for (const [id, rev] of Object.entries(ledger)) {
|
|
627
|
+
if (present.has(id))
|
|
628
|
+
continue;
|
|
629
|
+
this.ledger = undefined;
|
|
630
|
+
if (this.loadLedger()[id] === undefined)
|
|
631
|
+
continue;
|
|
632
|
+
this.recordRestrictedFinding(`missing|${id}|${rev}`, {
|
|
633
|
+
path: `id:${id}`,
|
|
634
|
+
code: "restricted_divergence",
|
|
635
|
+
reason: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
416
639
|
async search(query, scopes, opts) {
|
|
417
640
|
return this.searchWith(query, scopes, opts, true);
|
|
418
641
|
}
|
|
419
642
|
searchWith(query, scopes, opts, adopt) {
|
|
643
|
+
return this.searchFrom(query, scopes, opts, (s) => this.readScope(s, adopt));
|
|
644
|
+
}
|
|
645
|
+
searchFrom(query, scopes, opts, read) {
|
|
420
646
|
const limit = opts?.limit ?? 20;
|
|
421
647
|
const q = termSet(query);
|
|
422
648
|
if (q.size === 0)
|
|
@@ -424,7 +650,7 @@ export class FileMemoryEngineBackend {
|
|
|
424
650
|
const scored = [];
|
|
425
651
|
for (const scope of scopes) {
|
|
426
652
|
const dir = this.scopeDir(scope);
|
|
427
|
-
for (const e of
|
|
653
|
+
for (const e of read(scope)) {
|
|
428
654
|
const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
|
|
429
655
|
const d = jaccardDistance(q, haystack);
|
|
430
656
|
if (d === null)
|
|
@@ -223,9 +223,16 @@ export interface MemorySessionHandle {
|
|
|
223
223
|
* the gate's whole purpose is keeping those bytes out of the prompt, so a refused clear must not
|
|
224
224
|
* leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
|
|
225
225
|
indexOnDiskUntrusted?: boolean;
|
|
226
|
+
/** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
|
|
227
|
+
* either the plane is a read-only layering (`writeScope === null`) or the caller declared the
|
|
228
|
+
* session unable to persist (an explicit verdict — never inferred down here). Restricted sessions
|
|
229
|
+
* read committed state only (ledger + control-plane shadow); disk divergence with no transaction
|
|
230
|
+
* backing is neither adopted into the committed account nor served, and `inject` reads the
|
|
231
|
+
* materialize-time index text instead of the live on-disk file. */
|
|
232
|
+
adoptionRestricted?: boolean;
|
|
226
233
|
}
|
|
227
234
|
/** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
|
|
228
|
-
export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "invalid";
|
|
235
|
+
export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "restricted_divergence" | "invalid";
|
|
229
236
|
/** One rejected file: path (relative to the memory dir), stable code, and a model-readable reason. */
|
|
230
237
|
export interface HarvestRejection {
|
|
231
238
|
path: string;
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
* MySQL is NOT "can't support vectors" — it is `portable`-capable (JSON column + in-process cosine), just not
|
|
14
14
|
* `native`. A deployment injects an embedder (config-driven); the store then reports the achieved rung.
|
|
15
15
|
*/
|
|
16
|
-
/** Lower-cased
|
|
16
|
+
/** Lower-cased lexical term set: alphanumeric runs PLUS CJK character bigrams (a single-character
|
|
17
|
+
* run contributes its unigram). CJK scripts have no `a-z0-9` runs at all, so an alphanumeric-only
|
|
18
|
+
* tokenizer made every pure-CJK entry an EMPTY set — never a lexical-rung candidate for any query
|
|
19
|
+
* — and a pure-CJK query returned nothing; character bigrams are the standard analyzer unit there
|
|
20
|
+
* (word boundaries are not written). Bigrams stay within one run: adjacency across an intervening
|
|
21
|
+
* non-CJK character is not real adjacency. */
|
|
17
22
|
export declare function termSet(s: string): Set<string>;
|
|
18
23
|
/** Lexical stand-in distance: `1 - Jaccard(terms)` ∈ [0,1] ⊂ [0,2]; `null` = no overlap (not a candidate). */
|
|
19
24
|
export declare function jaccardDistance(query: Set<string>, text: string): number | null;
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
const CJK_RUN = /[\u3040-\u30FF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7A3\uF900-\uFAFF]+/g;
|
|
1
2
|
export function termSet(s) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const lower = s.toLowerCase();
|
|
4
|
+
const out = new Set();
|
|
5
|
+
for (const run of lower.split(/[^a-z0-9]+/))
|
|
6
|
+
if (run)
|
|
7
|
+
out.add(run);
|
|
8
|
+
for (const m of lower.matchAll(CJK_RUN)) {
|
|
9
|
+
const chars = [...m[0]];
|
|
10
|
+
for (const c of chars)
|
|
11
|
+
out.add(c);
|
|
12
|
+
for (let i = 0; i + 1 < chars.length; i++)
|
|
13
|
+
out.add(chars[i] + chars[i + 1]);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
6
16
|
}
|
|
7
17
|
export function jaccardDistance(query, text) {
|
|
8
18
|
const t = termSet(text);
|
package/dist/core/memory.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { uuidv7 } from "../internal/harness.js";
|
|
2
2
|
import { parseScopeKey } from "./memory-engine/scope-contract.js";
|
|
3
|
+
import { termSet } from "./memory-vector.js";
|
|
3
4
|
import { sanitizeUntrustedText } from "./untrusted-text.js";
|
|
4
5
|
export function supportsConsolidation(store) {
|
|
5
6
|
return (typeof store.searchScored === "function" &&
|
|
@@ -148,12 +149,6 @@ export function guardedMemoryStore(inner, utilityGate) {
|
|
|
148
149
|
function renderBullet(e) {
|
|
149
150
|
return `- (${e.ts} UTC) ${e.text}`;
|
|
150
151
|
}
|
|
151
|
-
function termSet(s) {
|
|
152
|
-
return new Set(s
|
|
153
|
-
.toLowerCase()
|
|
154
|
-
.split(/[^a-z0-9]+/)
|
|
155
|
-
.filter(Boolean));
|
|
156
|
-
}
|
|
157
152
|
export class InMemoryMemoryStore {
|
|
158
153
|
byScope = new Map();
|
|
159
154
|
cursorByScope = new Map();
|
|
@@ -120,6 +120,53 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
|
|
|
120
120
|
* an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
|
|
121
121
|
*/
|
|
122
122
|
export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
123
|
+
/**
|
|
124
|
+
* design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
|
|
125
|
+
* (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
|
|
126
|
+
* thing that would let it produce a prefix).
|
|
127
|
+
*
|
|
128
|
+
* A flat set of BODIES — word sequences, each at least two words. A prefix candidate exists for a
|
|
129
|
+
* command iff some body here is a word-boundary prefix of its folded form, and the LONGEST hit wins:
|
|
130
|
+
* the deeper body is the narrower rule, so listing (or not listing) a deeper body is how this table
|
|
131
|
+
* sets suggestion granularity per branch. No groups, no denylist, and no fallback arm: a head outside
|
|
132
|
+
* the table, an unreviewed subcommand, a runtime-defined name (a git alias, a `git-<x>`/`cargo-<x>`
|
|
133
|
+
* external subcommand, a gh extension, a kubectl plugin), a flag or operand in a body position and a
|
|
134
|
+
* quoted token all fail the same way — by not being listed. Closure comes from positive enumeration
|
|
135
|
+
* itself, never from an exclusion list racing names that only exist at runtime.
|
|
136
|
+
*
|
|
137
|
+
* Review criteria — every row must pass BOTH axes (the same principle as the interpreter refusal
|
|
138
|
+
* above: the rule text must not read narrower than what it grants):
|
|
139
|
+
* · "runs what it is told to": a body whose use is fetching or naming a program to execute
|
|
140
|
+
* (`npm exec`, `docker run`, `kubectl exec`, `gh extension`, `git submodule foreach`, the install
|
|
141
|
+
* family) is refused — one click cannot be read as having granted arbitrary execution. Running the
|
|
142
|
+
* WORKSPACE'S OWN pinned content (`npm run`, `npm ci`, `cargo run`, `cargo test`) is inside the
|
|
143
|
+
* boundary: the scripts and lockfiles those execute are checked into the repository being worked on.
|
|
144
|
+
* · "rewrites what others execute": a body whose main use is writing configuration that changes what
|
|
145
|
+
* OTHER commands later run (`git config` — hooksPath/pager/alias; `kubectl config` —
|
|
146
|
+
* exec-credential; `npm config`/`npm set` — script-shell; `go env` — persisted GOFLAGS/GOBIN) is
|
|
147
|
+
* refused — its readable width and its real width differ by a whole composition surface.
|
|
148
|
+
* Past both axes there is deliberately NO "dangerousness" axis: `git push:*` and `git rebase:*` are
|
|
149
|
+
* wide but readable, and a person nodding at that text is granting exactly that.
|
|
150
|
+
*
|
|
151
|
+
* Residual width, stated rather than hidden (a reviewed trade, not an oversight): a prefix rule
|
|
152
|
+
* admits ANY arguments after its body, and some listed bodies carry flags that name a program to
|
|
153
|
+
* execute (`go build`/`go test`/`go vet -toolexec`, `git fetch --upload-pack`, `git rebase -x`,
|
|
154
|
+
* `git grep -O`, `git push --receive-pack`). The axes judge a body's MAIN use, not every flag —
|
|
155
|
+
* per-flag grammar is the road this module's history rejected twice, and applied consistently it
|
|
156
|
+
* would empty the table. Three standing fences hold that residue: the org deny/ask layer runs ahead
|
|
157
|
+
* of the rule lane and cannot be silenced by it; a mandated ask (egress/irreversibility marks,
|
|
158
|
+
* shellGate:"always") is not rule-clearable either; and the narrower exact candidate — plus minting
|
|
159
|
+
* no rule at all — is always on the same card.
|
|
160
|
+
*
|
|
161
|
+
* Maintenance: adding a row is a reviewed change — keep the per-group reasoning beside it current.
|
|
162
|
+
* The integrity pins (every body ≥ 2 words, lowercase word shape, no interpreter heads, no
|
|
163
|
+
* duplicates) are enforced by this module's test suite. This table and the read-only classifier's
|
|
164
|
+
* allowlists are DIFFERENT instruments and must never be merged or cross-referenced: that one is a
|
|
165
|
+
* machine auto-allow face whose criterion is "provably read-only"; this one is a human suggestion
|
|
166
|
+
* face whose criterion is "width a person can read off the rule text". One guards against a machine
|
|
167
|
+
* loosening; the other against a person being misled.
|
|
168
|
+
*/
|
|
169
|
+
export declare const SUGGESTION_LEXICON: readonly string[];
|
|
123
170
|
/**
|
|
124
171
|
* Parse one rule text into its canonical shape, or refuse it with a reason.
|
|
125
172
|
*
|
|
@@ -184,11 +231,29 @@ export interface RuleSuggestion {
|
|
|
184
231
|
/**
|
|
185
232
|
* The 1-2 candidates offered on an approval card for `command`.
|
|
186
233
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
234
|
+
* **Array order is a documented CONTRACT, not an implementation accident**: display order = array
|
|
235
|
+
* order = narrowest first. The EXACT form (this whole command line) is always index 0 whenever
|
|
236
|
+
* anything is offered at all; a broader reviewed PREFIX form — at most one — follows at index 1.
|
|
237
|
+
* Selection indices and redemption tickets are index-keyed against this order (a card's
|
|
238
|
+
* `selectedCandidate` and its `rt.<index>.` tickets), so consumers may rely on it.
|
|
239
|
+
*
|
|
240
|
+
* The prefix candidate comes ONLY from {@link SUGGESTION_LEXICON} — the longest reviewed body that is
|
|
241
|
+
* a word-boundary prefix of the folded command. History, and why there is no heuristic arm: two
|
|
242
|
+
* rounds of guessing produced two different wrong answers — a bare program name (`rm -f x` →
|
|
243
|
+
* `Bash(rm:*)`), then an operand mistaken for a subcommand (`rm harmless.txt` →
|
|
244
|
+
* `Bash(rm harmless.txt:*)`, which admits a second, unnamed target) — and both failed the same way:
|
|
245
|
+
* nothing in the command TEXT distinguishes a subcommand from an operand without a per-command
|
|
246
|
+
* grammar. The lexicon IS that grammar, per reviewed row; anything it does not list (a bare verb, an
|
|
247
|
+
* interpreter head, an unreviewed subcommand, a runtime-defined name) yields no prefix, with no
|
|
248
|
+
* fallback. Naive spacing note: `folded` keeps quoted whitespace, so splitting on single spaces can
|
|
249
|
+
* shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
|
|
250
|
+
* characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
|
|
251
|
+
*
|
|
252
|
+
* Every produced candidate must survive the round trip — parse as a rule AND admit the very command
|
|
253
|
+
* it was minted from. True by construction (a word-boundary lexicon prefix of a folded simple command
|
|
254
|
+
* is exactly the matcher's two arms); enforced anyway, fail-closed: a candidate that would not
|
|
255
|
+
* round-trip is silently not offered, since offering an option redemption would refuse is worse than
|
|
256
|
+
* offering one fewer.
|
|
192
257
|
*
|
|
193
258
|
* Returns an empty array for anything the rule lane cannot speak for (compounds, redirections,
|
|
194
259
|
* substitutions) — the card then simply carries no "don't ask again" option, which is the honest answer.
|
|
@@ -6,6 +6,54 @@ export const BARE_INTERPRETER_NAMES = new Set([
|
|
|
6
6
|
"node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php",
|
|
7
7
|
"osascript", "env", "eval", "exec", "xargs", "nohup", "sudo", "doas", "su", "ssh",
|
|
8
8
|
]);
|
|
9
|
+
export const SUGGESTION_LEXICON = [
|
|
10
|
+
"git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
|
|
11
|
+
"git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
|
|
12
|
+
"git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
|
|
13
|
+
"git rev-parse", "git ls-files", "git grep",
|
|
14
|
+
"git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
|
|
15
|
+
"git remote show", "git worktree list", "git submodule update", "git submodule status",
|
|
16
|
+
"npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
|
|
17
|
+
"npm pack", "npm publish", "npm version", "npm why",
|
|
18
|
+
"pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
|
|
19
|
+
"yarn run", "yarn test", "yarn workspaces list",
|
|
20
|
+
"cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
|
|
21
|
+
"cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
|
|
22
|
+
"go build", "go test", "go vet", "go fmt", "go doc",
|
|
23
|
+
"go mod tidy", "go mod download", "go mod verify", "go mod graph",
|
|
24
|
+
"docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
|
|
25
|
+
"docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
|
|
26
|
+
"docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
|
|
27
|
+
"docker compose build", "docker compose pull",
|
|
28
|
+
"kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
|
|
29
|
+
"kubectl diff", "kubectl explain", "kubectl top",
|
|
30
|
+
"kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
|
|
31
|
+
"gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
|
|
32
|
+
"gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
|
|
33
|
+
"gh repo clone", "gh release list", "gh release view", "gh run list", "gh run view",
|
|
34
|
+
"gh run watch", "gh workflow list", "gh workflow view", "gh auth status",
|
|
35
|
+
"gh search repos", "gh search issues", "gh search prs", "gh search code",
|
|
36
|
+
];
|
|
37
|
+
const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
|
|
38
|
+
function longestReviewedBody(tokens) {
|
|
39
|
+
let best;
|
|
40
|
+
for (const words of LEXICON_BODIES) {
|
|
41
|
+
if (words.length > tokens.length)
|
|
42
|
+
continue;
|
|
43
|
+
if (best !== undefined && words.length <= best.length)
|
|
44
|
+
continue;
|
|
45
|
+
let hit = true;
|
|
46
|
+
for (let i = 0; i < words.length; i++) {
|
|
47
|
+
if (words[i] !== tokens[i]) {
|
|
48
|
+
hit = false;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (hit)
|
|
53
|
+
best = words;
|
|
54
|
+
}
|
|
55
|
+
return best?.join(" ");
|
|
56
|
+
}
|
|
9
57
|
function foldSpacing(s) {
|
|
10
58
|
let out = "";
|
|
11
59
|
let quote;
|
|
@@ -131,5 +179,15 @@ export function suggestRulesForCommand(command) {
|
|
|
131
179
|
const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
|
|
132
180
|
if ("rule" in exact)
|
|
133
181
|
out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
|
|
182
|
+
if (out.length === 1) {
|
|
183
|
+
const body = longestReviewedBody(folded.split(" "));
|
|
184
|
+
if (body !== undefined) {
|
|
185
|
+
const text = formatAllowRuleText(body, "prefix");
|
|
186
|
+
const parsed = parseAllowRuleText(text);
|
|
187
|
+
if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
|
|
188
|
+
out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
134
192
|
return out;
|
|
135
193
|
}
|
|
@@ -126,7 +126,12 @@ export async function prepareMemory(input) {
|
|
|
126
126
|
};
|
|
127
127
|
};
|
|
128
128
|
const onEngineIncident = (err) => deps.onError?.(err, { phase: "memory", sessionId });
|
|
129
|
-
const
|
|
129
|
+
const adoptionRestricted = input.memoryPersistenceDeclared === false;
|
|
130
|
+
const retrievalBackend = (b, planeRestricted) => planeRestricted
|
|
131
|
+
? (b.restrictedAdoptionView?.({ audit: false }) ??
|
|
132
|
+
b.retrievalView?.() ??
|
|
133
|
+
b)
|
|
134
|
+
: (b.retrievalView?.() ?? b);
|
|
130
135
|
const planeScopes = (scopes, write) => [...new Set([...scopes, ...(write !== null ? [write] : [])])];
|
|
131
136
|
const pollutedOpts = (engine) => {
|
|
132
137
|
const rec = engine.sessionPollution(sessionId);
|
|
@@ -173,8 +178,8 @@ export async function prepareMemory(input) {
|
|
|
173
178
|
const personal = createPersonalEngine(personalBackendChosen);
|
|
174
179
|
const personalEngine = personal.engine;
|
|
175
180
|
const p = planes;
|
|
176
|
-
const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
|
|
177
|
-
const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null);
|
|
181
|
+
const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null, { adoptionRestricted });
|
|
182
|
+
const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null, { adoptionRestricted });
|
|
178
183
|
const writeIsPersonal = p.writePlane === "personal";
|
|
179
184
|
writeEngine = writeIsPersonal ? personalEngine : projectEngine;
|
|
180
185
|
writeHandle = writeIsPersonal ? personalHandle : projectHandle;
|
|
@@ -183,13 +188,13 @@ export async function prepareMemory(input) {
|
|
|
183
188
|
injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
|
|
184
189
|
toolPlanes = [
|
|
185
190
|
{
|
|
186
|
-
backend: retrievalBackend(backend),
|
|
191
|
+
backend: retrievalBackend(backend, adoptionRestricted || p.writePlane !== "project"),
|
|
187
192
|
scopes: planeScopes(p.project, p.writePlane === "project" ? memorySpec.writeScope : null),
|
|
188
193
|
recordRetrieved: (ids) => projectEngine.recordRetrieved(ids),
|
|
189
194
|
challengeExclusions: () => projectEngine.readChallengeExclusions(),
|
|
190
195
|
},
|
|
191
196
|
{
|
|
192
|
-
backend: retrievalBackend(personal.backend),
|
|
197
|
+
backend: retrievalBackend(personal.backend, adoptionRestricted || p.writePlane !== "personal"),
|
|
193
198
|
scopes: planeScopes(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null),
|
|
194
199
|
recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
|
|
195
200
|
challengeExclusions: () => personalEngine.readChallengeExclusions(),
|
|
@@ -216,14 +221,14 @@ export async function prepareMemory(input) {
|
|
|
216
221
|
else if (personalOnly) {
|
|
217
222
|
const personal = createPersonalEngine(choosePersonalBackend());
|
|
218
223
|
const personalEngine = personal.engine;
|
|
219
|
-
const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
224
|
+
const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
|
|
220
225
|
writeEngine = personalEngine;
|
|
221
226
|
writeHandle = handle;
|
|
222
227
|
injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
|
|
223
228
|
harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId, ...admitNothingOpts });
|
|
224
229
|
toolPlanes = [
|
|
225
230
|
{
|
|
226
|
-
backend: retrievalBackend(personal.backend),
|
|
231
|
+
backend: retrievalBackend(personal.backend, adoptionRestricted || memorySpec.writeScope === null),
|
|
227
232
|
scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
|
|
228
233
|
recordRetrieved: (ids) => personalEngine.recordRetrieved(ids),
|
|
229
234
|
challengeExclusions: () => personalEngine.readChallengeExclusions(),
|
|
@@ -237,14 +242,14 @@ export async function prepareMemory(input) {
|
|
|
237
242
|
controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot),
|
|
238
243
|
onIncident: onEngineIncident,
|
|
239
244
|
});
|
|
240
|
-
const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
245
|
+
const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope, { adoptionRestricted });
|
|
241
246
|
writeEngine = engine;
|
|
242
247
|
writeHandle = handle;
|
|
243
248
|
injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
|
|
244
249
|
harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId, ...admitNothingOpts });
|
|
245
250
|
toolPlanes = [
|
|
246
251
|
{
|
|
247
|
-
backend: retrievalBackend(backend),
|
|
252
|
+
backend: retrievalBackend(backend, adoptionRestricted || memorySpec.writeScope === null),
|
|
248
253
|
scopes: planeScopes(memorySpec.scopes, memorySpec.writeScope),
|
|
249
254
|
recordRetrieved: (ids) => engine.recordRetrieved(ids),
|
|
250
255
|
challengeExclusions: () => engine.readChallengeExclusions(),
|