@sema-agent/core 5.48.0 → 5.49.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 +63 -0
- package/dist/agents/agent-transcript-tool.d.ts +1 -1
- package/dist/agents/agent-transcript-tool.js +1 -1
- package/dist/agents/send-message-tool.d.ts +2 -2
- package/dist/agents/send-message-tool.js +2 -2
- package/dist/agents/teacher.d.ts +25 -1
- package/dist/agents/teacher.js +85 -12
- package/dist/core/background-agent-store.d.ts +1 -1
- package/dist/core/background-agent-store.js +5 -4
- package/dist/core/memory-engine/delegation-settlement.d.ts +27 -0
- package/dist/core/memory-engine/delegation-settlement.js +31 -4
- package/dist/core/memory-engine/dual-root.js +11 -0
- package/dist/core/memory-engine/engine.d.ts +6 -1
- package/dist/core/memory-engine/engine.js +136 -21
- package/dist/core/memory-engine/memory-backend-contract.js +33 -0
- package/dist/core/memory-engine/origin-clearance.d.ts +19 -0
- package/dist/core/memory-engine/origin-clearance.js +10 -0
- package/dist/core/memory-engine/provenance-wording.d.ts +15 -1
- package/dist/core/memory-engine/provenance-wording.js +1 -0
- package/dist/core/memory-engine/tools.js +6 -4
- package/dist/core/runner/prepare-task.js +7 -7
- package/dist/core/runner/runtask.d.ts +26 -1
- package/dist/core/runner/runtask.js +18 -2
- package/dist/core/strategy-store.d.ts +180 -3
- package/dist/core/strategy-store.js +172 -23
- package/dist/core/types.d.ts +7 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/stores/file/file-snapshot-store.js +7 -1
- package/dist/stores/file/index.d.ts +8 -0
- package/dist/stores/file/index.js +12 -0
- package/dist/stores/file/session-policy-store.d.ts +0 -13
- package/dist/stores/file/session-policy-store.js +7 -1
- package/dist/stores/file/session-store.d.ts +4 -1
- package/dist/stores/file/session-store.js +7 -1
- package/dist/stores/file/strategy-store.d.ts +97 -0
- package/dist/stores/file/strategy-store.js +340 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +8 -1
|
@@ -1,17 +1,4 @@
|
|
|
1
1
|
import { type PutRulesOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "../../core/session-policy-store.js";
|
|
2
|
-
/**
|
|
3
|
-
* design/99 §E6 — file-backed {@link SessionPolicyStore} for the local (TOC) backend: ONE JSON file per
|
|
4
|
-
* `(sessionId, principal)`, semantics **byte-for-byte identical to `InMemorySessionPolicyStore`** (the
|
|
5
|
-
* cross-backend equivalence contract). CAS-rev OCC + tighten-only reuse the SAME pure helpers
|
|
6
|
-
* (`loosenReasons`/`normalizeRules`/`stripRev`) as core — no re-implemented rule logic that could drift.
|
|
7
|
-
*
|
|
8
|
-
* Atomicity: the file backend is single-process (the `FileStorageBackend` boot lock guarantees ONE writer per
|
|
9
|
-
* data dir), and `getRules`/`putRules` read-check-write SYNCHRONOUSLY (no await between the rev read and the
|
|
10
|
-
* atomic write), so the read-modify-write is atomic in the one event loop — exactly the InMemory store's premise.
|
|
11
|
-
* Cross-process CORRECT concurrency is the Pg/TiDB backend's job (a CAS WHERE clause), by design.
|
|
12
|
-
*/
|
|
13
|
-
/** Coordinates of one corrupt-treated-as-absent policy read (the {@link FileSessionPolicyStoreOptions.onCorruptRead}
|
|
14
|
-
* payload). Named rather than inline so the backend option that forwards it names the SAME shape. */
|
|
15
2
|
export interface SessionPolicyCorruptReadInfo {
|
|
16
3
|
/** The session whose read observed the corrupt row. On the enumeration face this is the session being
|
|
17
4
|
* ENUMERATED — a corrupt row's own `__sid` is by definition unreadable, so it cannot be attributed. */
|
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
|
|
4
4
|
import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
|
|
5
5
|
import { assertAdoptionBootGate, readRootAdoptionFile } from "./adoption/marker.js";
|
|
6
|
+
function containSinkThenable(r) {
|
|
7
|
+
if (typeof r?.then === "function") {
|
|
8
|
+
r.then(undefined, () => {
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
6
12
|
export class FileSessionPolicyStore {
|
|
7
13
|
dir;
|
|
8
14
|
onCorruptRead;
|
|
@@ -18,7 +24,7 @@ export class FileSessionPolicyStore {
|
|
|
18
24
|
}
|
|
19
25
|
disclose(info) {
|
|
20
26
|
try {
|
|
21
|
-
this.onCorruptRead?.(info);
|
|
27
|
+
containSinkThenable(this.onCorruptRead?.(info));
|
|
22
28
|
}
|
|
23
29
|
catch {
|
|
24
30
|
}
|
|
@@ -29,7 +29,10 @@ export declare class FileSessionRepo implements SessionRepo {
|
|
|
29
29
|
private readonly joined;
|
|
30
30
|
constructor(root: string, opts?: FileSessionRepoOptions);
|
|
31
31
|
/** The one delivery point for {@link FileSessionRepoOptions.onCorruptRead}; swallow-guarded here so
|
|
32
|
-
* no call site has to remember.
|
|
32
|
+
* no call site has to remember. The sink seat is void-typed but a host may hand it an async
|
|
33
|
+
* function — an async sink's rejection is observed off its returned thenable (same containment
|
|
34
|
+
* as the strategy store's incident sink), so neither a sync throw nor an async rejection can
|
|
35
|
+
* re-introduce the failure mode the fail-open avoids. */
|
|
33
36
|
private disclose;
|
|
34
37
|
private pathFor;
|
|
35
38
|
/** Read + torn-tail-recover a session file into (meta, entries); a missing file → not_found. */
|
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { BaseSessionStorage, StoredSession, SessionError, getEntriesToFork, uuidv7, validateEntriesForImport, } from "../../internal/harness.js";
|
|
4
4
|
import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords, sanitizePathComponent } from "./fs-atomic.js";
|
|
5
5
|
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
6
|
+
function containSinkThenable(r) {
|
|
7
|
+
if (typeof r?.then === "function") {
|
|
8
|
+
r.then(undefined, () => {
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
}
|
|
6
12
|
const SUFFIX = ".jsonl";
|
|
7
13
|
const sharedSessionStorages = new Map();
|
|
8
14
|
const sessionStorageFinalizer = new FinalizationRegistry(({ canonical, log }) => {
|
|
@@ -67,7 +73,7 @@ export class FileSessionRepo {
|
|
|
67
73
|
}
|
|
68
74
|
disclose(path, reason) {
|
|
69
75
|
try {
|
|
70
|
-
this.onCorruptRead?.({ path, reason });
|
|
76
|
+
containSinkThenable(this.onCorruptRead?.({ path, reason }));
|
|
71
77
|
}
|
|
72
78
|
catch {
|
|
73
79
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type StoredStrategy, type StrategyStore, type StrategyStoreIncident } from "../../core/strategy-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* File-backed {@link StrategyStore} — the persistence twin of `InMemoryStrategyStore`, so the teacher
|
|
4
|
+
* mode's cross-session reuse premise actually holds on a local (TOC) deployment where every session is
|
|
5
|
+
* a fresh process (an in-memory strategy repository there re-learns everything, every time).
|
|
6
|
+
*
|
|
7
|
+
* DESIGN STANCE — this is a **cache, not an authority store**: every entry is regenerable (worst case:
|
|
8
|
+
* ask the teacher again), so losing one never loses correctness. It therefore deliberately skips the
|
|
9
|
+
* journal/shadow armor of the memory file backend and does NOT fsync-harden beyond the shared
|
|
10
|
+
* atomic-write primitive, does NOT take a cross-process lock, and accepts a narrow multi-process race
|
|
11
|
+
* on save-side dedup (two processes can each write one copy of the same normalized entry — the read
|
|
12
|
+
* side dedups, and a lost max-confidence merge is cache-grade). What it does NOT relax: scope
|
|
13
|
+
* isolation and loud-refusal discipline, which are held to the same standard as the durable stores.
|
|
14
|
+
*
|
|
15
|
+
* Layout: `<root>/<scopeDir>/<id>.json`, one strategy per file, whole-file atomic replace on write.
|
|
16
|
+
* `scopeDir = slug(scope) + "-" + sha256(scope).slice(0,24)` — the slug is a strict `[a-z0-9-]`
|
|
17
|
+
* whitelist purely for readability; IDENTITY lives in the 96-bit hash, so no scope value, however
|
|
18
|
+
* hostile, can traverse out of the root or collide another scope's directory. Ids are gated to
|
|
19
|
+
* `[A-Za-z0-9_-]{1,64}` at save (the escalation loop mints UUIDs; the interface is public, so a
|
|
20
|
+
* hand-rolled id must not be able to name a path).
|
|
21
|
+
*
|
|
22
|
+
* `scope` is a NAMESPACE, not an authorization boundary — the store cannot authenticate its caller;
|
|
23
|
+
* whoever holds the instance can address any scope (same posture as the memory store). Authorization
|
|
24
|
+
* is the host's obligation.
|
|
25
|
+
*/
|
|
26
|
+
export interface FileStrategyStoreOptions {
|
|
27
|
+
/** Directory to keep strategies under (created `0o700` if absent; a non-directory or unwritable
|
|
28
|
+
* path is refused loudly at construction — a store that cannot persist must not pretend to). */
|
|
29
|
+
root: string;
|
|
30
|
+
/** Per-scope capacity cap (entries), evicting the lowest-scoring on overflow. Default 100. */
|
|
31
|
+
maxPerScope?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Disclosure sink for contained store incidents (a corrupt entry quarantined, an eviction that
|
|
34
|
+
* failed after a successful save, a degraded read). Absent ⇒ `console.warn`, once per op kind —
|
|
35
|
+
* a contained fault must be loud somewhere, but must never flood.
|
|
36
|
+
*/
|
|
37
|
+
onIncident?: (i: StrategyStoreIncident) => void;
|
|
38
|
+
}
|
|
39
|
+
export declare class FileStrategyStore implements StrategyStore {
|
|
40
|
+
private readonly root;
|
|
41
|
+
private readonly maxPerScope;
|
|
42
|
+
private readonly onIncident?;
|
|
43
|
+
/** Ops that already warned on the absent-sink fallback (bounded disclosure — never flood). */
|
|
44
|
+
private readonly warnedOps;
|
|
45
|
+
/** Isolation scope for the host incident sink (sync throw AND async rejection contained). */
|
|
46
|
+
private readonly sinkNotifier;
|
|
47
|
+
/** Scope dirs whose stale temps this instance already swept (once per scope per instance). */
|
|
48
|
+
private readonly sweptTemps;
|
|
49
|
+
constructor(opts: FileStrategyStoreOptions);
|
|
50
|
+
/** `slug(scope)-sha256(scope)[0..24]` — slug is readability only; the hash is the injective key. */
|
|
51
|
+
private scopeDirName;
|
|
52
|
+
private scopeDirPath;
|
|
53
|
+
/** The scope dir must be a REAL directory — a symlink here means the store's namespace was tampered
|
|
54
|
+
* with (a link could point retrieval at another scope's data, or writes out of the root). The
|
|
55
|
+
* check-then-use window against a same-uid local attacker is out of the threat model: such an
|
|
56
|
+
* attacker already holds the same rights as the store itself. */
|
|
57
|
+
private assertScopeDirSafe;
|
|
58
|
+
private incident;
|
|
59
|
+
/** Sweep crashed writers' stale temps (once per scope per instance). Fresh temps are left alone —
|
|
60
|
+
* they may belong to a live concurrent writer. */
|
|
61
|
+
private sweepStaleTemps;
|
|
62
|
+
/** Load every VALID entry of a scope dir. Corrupt files are quarantined (`.bad` rename) with one
|
|
63
|
+
* disclosure; entries that violate the read-side invariants (foreign scope / id≠filename) are
|
|
64
|
+
* skipped with disclosure but NOT quarantined (a copied-in file may be someone's valid data);
|
|
65
|
+
* normalized duplicates collapse to the best-scoring copy. */
|
|
66
|
+
private loadScope;
|
|
67
|
+
/** Rename a corrupt file to `<name>.bad` — one disclosure now, zero parse cost on every later read
|
|
68
|
+
* (the suffix no longer matches the entry-file grammar). Never faults the calling operation. */
|
|
69
|
+
private quarantine;
|
|
70
|
+
/** Serialize + write one entry, refusing a record whose SERIALIZED form exceeds the read bound.
|
|
71
|
+
* The field caps bound RAW string bytes, but JSON escaping expands control characters up to 6x —
|
|
72
|
+
* without this door a save could succeed and then self-quarantine on the very next read (the
|
|
73
|
+
* worst possible shape: an accepted write the store itself later refuses to serve). */
|
|
74
|
+
private writeEntry;
|
|
75
|
+
save(s: StoredStrategy): void;
|
|
76
|
+
private evict;
|
|
77
|
+
find(scope: string, query: string, limit: number): StoredStrategy[];
|
|
78
|
+
/** Bounded candidate view for the RETRIEVAL-adjacent paths (find/hasStrategy/save): an externally
|
|
79
|
+
* inflated directory must not make a hot operation unbounded. MAINTENANCE passes Infinity — see
|
|
80
|
+
* {@link prune}. */
|
|
81
|
+
private retrievalReadCap;
|
|
82
|
+
/**
|
|
83
|
+
* Trims to `maxSize` over an UNCAPPED enumeration: prune is the reconciliation verb, so it must see
|
|
84
|
+
* every entry file — under the bounded retrieval view, a capacity SHRINK across restarts (or
|
|
85
|
+
* `maxPerScope: 0`) left files beyond the window untouched, reporting success while the "removed"
|
|
86
|
+
* strategies sat on disk ready to resurrect under a later, larger capacity. Host-invoked
|
|
87
|
+
* maintenance accepts the O(all files) cost the retrieval path refuses.
|
|
88
|
+
*/
|
|
89
|
+
prune(scope: string, maxSize: number): void;
|
|
90
|
+
/** Uncapped for the same reason as {@link prune}: this face feeds the seed capacity door, and an
|
|
91
|
+
* under-count there turns "refuse what cannot fit" into silent eviction of fresh seeds. */
|
|
92
|
+
scopeUsage(scope: string): {
|
|
93
|
+
used: number;
|
|
94
|
+
capacity: number;
|
|
95
|
+
};
|
|
96
|
+
hasStrategy(scope: string, entry: Pick<StoredStrategy, "problem" | "strategy">): boolean;
|
|
97
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { accessSync, constants as FS, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { atomicWriteFile } from "./fs-atomic.js";
|
|
5
|
+
import { createSafeNotifier, observeThenableRejection } from "../../core/safe-notify.js";
|
|
6
|
+
import { MAX_STRATEGY_INJECTION_TOTAL_BYTES, MAX_STRATEGY_TEXT_BYTES, compileStrategyQuery, resolveCapacityCap, resolveFindLimit, scoreStoredStrategy, storedStrategyShapeIssue, strategyDedupKey, validateStrategyForWrite, } from "../../core/strategy-store.js";
|
|
7
|
+
function configRefusal(message, code) {
|
|
8
|
+
const e = new Error(message);
|
|
9
|
+
e.code = code;
|
|
10
|
+
return e;
|
|
11
|
+
}
|
|
12
|
+
const SCOPE_SLUG_MAX = 40;
|
|
13
|
+
const READ_FILE_CAP_FACTOR = 4;
|
|
14
|
+
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
15
|
+
const MAX_ENTRY_FILE_BYTES = 65536;
|
|
16
|
+
const FILE_RE = /^([A-Za-z0-9_-]{1,64})\.json$/;
|
|
17
|
+
export class FileStrategyStore {
|
|
18
|
+
root;
|
|
19
|
+
maxPerScope;
|
|
20
|
+
onIncident;
|
|
21
|
+
warnedOps = new Set();
|
|
22
|
+
sinkNotifier = createSafeNotifier();
|
|
23
|
+
sweptTemps = new Set();
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.maxPerScope = resolveCapacityCap("maxPerScope", opts.maxPerScope ?? 100);
|
|
26
|
+
this.onIncident = opts.onIncident;
|
|
27
|
+
const root = opts.root;
|
|
28
|
+
if (typeof root !== "string" || root.length === 0) {
|
|
29
|
+
throw configRefusal("FileStrategyStore: root must be a non-empty path", "config.strategy_root_invalid");
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw configRefusal(`FileStrategyStore: cannot create root ${root}: ${String(err.message ?? err)}`, "config.strategy_root_invalid");
|
|
36
|
+
}
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
st = statSync(root);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw configRefusal(`FileStrategyStore: cannot stat root ${root}: ${String(err.message ?? err)}`, "config.strategy_root_invalid");
|
|
43
|
+
}
|
|
44
|
+
if (!st.isDirectory()) {
|
|
45
|
+
throw configRefusal(`FileStrategyStore: root ${root} exists and is not a directory`, "config.strategy_root_invalid");
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
accessSync(root, FS.W_OK);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw configRefusal(`FileStrategyStore: root ${root} is not writable`, "config.strategy_root_invalid");
|
|
52
|
+
}
|
|
53
|
+
this.root = root;
|
|
54
|
+
}
|
|
55
|
+
scopeDirName(scope) {
|
|
56
|
+
const slug = scope
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
59
|
+
.replace(/^-+|-+$/g, "")
|
|
60
|
+
.slice(0, SCOPE_SLUG_MAX);
|
|
61
|
+
const h = createHash("sha256").update(scope, "utf8").digest("hex").slice(0, 24);
|
|
62
|
+
return `${slug || "s"}-${h}`;
|
|
63
|
+
}
|
|
64
|
+
scopeDirPath(scope) {
|
|
65
|
+
return join(this.root, this.scopeDirName(scope));
|
|
66
|
+
}
|
|
67
|
+
assertScopeDirSafe(dir) {
|
|
68
|
+
let st;
|
|
69
|
+
try {
|
|
70
|
+
st = lstatSync(dir);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err.code === "ENOENT")
|
|
74
|
+
return;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
78
|
+
const e = new Error(`FileStrategyStore: scope directory ${dir} is not a plain directory (symlink or non-dir refused)`);
|
|
79
|
+
e.code = "strategy.scope_dir_invalid";
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
incident(i) {
|
|
84
|
+
const sink = this.onIncident;
|
|
85
|
+
if (sink !== undefined) {
|
|
86
|
+
const site = `FileStrategyStore.onIncident.${i.op}`;
|
|
87
|
+
this.sinkNotifier.notify(() => observeThenableRejection(sink(i), this.sinkNotifier, site), site);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!this.warnedOps.has(i.op)) {
|
|
91
|
+
this.warnedOps.add(i.op);
|
|
92
|
+
console.warn(`FileStrategyStore ${i.op} incident: ${i.error}${i.path !== undefined ? ` (${i.path})` : ""}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
sweepStaleTemps(dir) {
|
|
96
|
+
if (this.sweptTemps.has(dir))
|
|
97
|
+
return;
|
|
98
|
+
this.sweptTemps.add(dir);
|
|
99
|
+
let names;
|
|
100
|
+
try {
|
|
101
|
+
names = readdirSync(dir);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const cutoff = Date.now() - STALE_TEMP_MS;
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
if (!name.endsWith(".tmp"))
|
|
109
|
+
continue;
|
|
110
|
+
const p = join(dir, name);
|
|
111
|
+
try {
|
|
112
|
+
if (statSync(p).mtimeMs < cutoff)
|
|
113
|
+
unlinkSync(p);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
loadScope(scope, dir, readCap) {
|
|
120
|
+
let names;
|
|
121
|
+
try {
|
|
122
|
+
names = readdirSync(dir);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (err.code === "ENOENT")
|
|
126
|
+
return [];
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
let candidates = names.filter((n) => FILE_RE.test(n)).sort();
|
|
130
|
+
if (Number.isFinite(readCap) && candidates.length > readCap) {
|
|
131
|
+
this.incident({
|
|
132
|
+
op: "find",
|
|
133
|
+
error: `scope directory holds ${candidates.length} entry files, over the ${readCap} read cap — parsing the first ${readCap} only (an external writer likely inflated this directory)`,
|
|
134
|
+
path: dir,
|
|
135
|
+
});
|
|
136
|
+
candidates = candidates.slice(0, readCap);
|
|
137
|
+
}
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const name of candidates) {
|
|
140
|
+
const p = join(dir, name);
|
|
141
|
+
let fst;
|
|
142
|
+
try {
|
|
143
|
+
fst = lstatSync(p);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (err.code === "ENOENT")
|
|
147
|
+
continue;
|
|
148
|
+
this.incident({ op: "find", error: `unstat-able entry skipped: ${String(err.message ?? err)}`, path: p });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!fst.isFile()) {
|
|
152
|
+
this.incident({ op: "find", error: "non-regular entry skipped (symlink or special file refused)", path: p });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (fst.size > MAX_ENTRY_FILE_BYTES) {
|
|
156
|
+
this.quarantine(p, `entry file is ${fst.size} bytes, over the ${MAX_ENTRY_FILE_BYTES} read bound`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = readFileSync(p, "utf8");
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (err.code === "ENOENT")
|
|
165
|
+
continue;
|
|
166
|
+
this.incident({ op: "find", error: `unreadable entry skipped: ${String(err.message ?? err)}`, path: p });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(raw);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
this.quarantine(p, "unparseable JSON");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
178
|
+
this.quarantine(p, "top-level JSON value is not an object");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const rec = parsed;
|
|
182
|
+
if (rec.v !== 1) {
|
|
183
|
+
this.incident({ op: "parse", error: `unknown schema version ${String(rec.v)} — entry skipped, file left in place`, path: p });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const { v: _v, ...candidate } = rec;
|
|
187
|
+
const issue = storedStrategyShapeIssue(candidate);
|
|
188
|
+
if (issue !== null) {
|
|
189
|
+
this.quarantine(p, issue);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const entry = candidate;
|
|
193
|
+
const fileId = FILE_RE.exec(name)[1];
|
|
194
|
+
if (entry.scope !== scope || entry.id !== fileId) {
|
|
195
|
+
this.incident({
|
|
196
|
+
op: "find",
|
|
197
|
+
error: `entry skipped: scope/id mismatch (file claims scope ${JSON.stringify(entry.scope)}, id ${JSON.stringify(entry.id)})`,
|
|
198
|
+
id: fileId,
|
|
199
|
+
path: p,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
out.push({ entry, path: p });
|
|
204
|
+
}
|
|
205
|
+
const byKey = new Map();
|
|
206
|
+
for (const le of out) {
|
|
207
|
+
const key = strategyDedupKey(le.entry);
|
|
208
|
+
const prev = byKey.get(key);
|
|
209
|
+
if (prev === undefined || scoreStoredStrategy(le.entry) > scoreStoredStrategy(prev.entry))
|
|
210
|
+
byKey.set(key, le);
|
|
211
|
+
}
|
|
212
|
+
return [...byKey.values()];
|
|
213
|
+
}
|
|
214
|
+
quarantine(p, reason) {
|
|
215
|
+
try {
|
|
216
|
+
renameSync(p, `${p}.bad`);
|
|
217
|
+
this.incident({ op: "parse", error: `corrupt entry quarantined (${reason})`, path: p });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
this.incident({ op: "parse", error: `corrupt entry skipped (${reason}); quarantine rename failed: ${String(err.message ?? err)}`, path: p });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
writeEntry(dir, path, entry) {
|
|
224
|
+
const record = {
|
|
225
|
+
v: 1,
|
|
226
|
+
id: entry.id,
|
|
227
|
+
problem: entry.problem,
|
|
228
|
+
strategy: entry.strategy,
|
|
229
|
+
confidence: entry.confidence,
|
|
230
|
+
scope: entry.scope,
|
|
231
|
+
ts: entry.ts,
|
|
232
|
+
...(entry.teacherModel !== undefined ? { teacherModel: entry.teacherModel } : {}),
|
|
233
|
+
...(entry.signature !== undefined ? { signature: entry.signature } : {}),
|
|
234
|
+
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
|
235
|
+
};
|
|
236
|
+
const bytes = JSON.stringify(record);
|
|
237
|
+
if (Buffer.byteLength(bytes, "utf8") > MAX_ENTRY_FILE_BYTES) {
|
|
238
|
+
const e = new Error(`FileStrategyStore: entry serializes over the ${MAX_ENTRY_FILE_BYTES}-byte read bound (JSON escaping expanded it past the field caps) — refusing a write the read side would quarantine`);
|
|
239
|
+
e.code = "strategy.entry_invalid";
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
atomicWriteFile(dir, path, bytes);
|
|
243
|
+
}
|
|
244
|
+
save(s) {
|
|
245
|
+
validateStrategyForWrite(s);
|
|
246
|
+
const dir = this.scopeDirPath(s.scope);
|
|
247
|
+
this.assertScopeDirSafe(dir);
|
|
248
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
249
|
+
this.sweepStaleTemps(dir);
|
|
250
|
+
const existing = this.loadScope(s.scope, dir, this.retrievalReadCap());
|
|
251
|
+
const key = strategyDedupKey(s);
|
|
252
|
+
const dup = existing.find((le) => strategyDedupKey(le.entry) === key);
|
|
253
|
+
let all;
|
|
254
|
+
if (dup !== undefined) {
|
|
255
|
+
const refreshed = {
|
|
256
|
+
...dup.entry,
|
|
257
|
+
...(s.confidence >= dup.entry.confidence ? { confidence: s.confidence, ts: s.ts } : {}),
|
|
258
|
+
...(s.teacherModel !== undefined ? { teacherModel: s.teacherModel } : {}),
|
|
259
|
+
};
|
|
260
|
+
this.writeEntry(dir, dup.path, refreshed);
|
|
261
|
+
dup.entry = refreshed;
|
|
262
|
+
all = existing;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const p = join(dir, `${s.id}.json`);
|
|
266
|
+
const byId = existing.find((le) => le.entry.id === s.id);
|
|
267
|
+
this.writeEntry(dir, p, s);
|
|
268
|
+
if (byId !== undefined) {
|
|
269
|
+
byId.entry = s;
|
|
270
|
+
all = existing;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
all = [...existing, { entry: s, path: p }];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (all.length > this.maxPerScope) {
|
|
277
|
+
this.evict(all, this.maxPerScope);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
evict(all, keep) {
|
|
281
|
+
const sorted = [...all].sort((a, b) => scoreStoredStrategy(b.entry) - scoreStoredStrategy(a.entry));
|
|
282
|
+
for (const le of sorted.slice(keep)) {
|
|
283
|
+
try {
|
|
284
|
+
unlinkSync(le.path);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (err.code === "ENOENT")
|
|
288
|
+
continue;
|
|
289
|
+
this.incident({ op: "evict", error: String(err.message ?? err), id: le.entry.id, path: le.path });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
find(scope, query, limit) {
|
|
294
|
+
const cap = resolveFindLimit(limit);
|
|
295
|
+
const dir = this.scopeDirPath(scope);
|
|
296
|
+
this.assertScopeDirSafe(dir);
|
|
297
|
+
const matches = compileStrategyQuery(query);
|
|
298
|
+
if (matches === null)
|
|
299
|
+
return [];
|
|
300
|
+
const ranked = this.loadScope(scope, dir, this.retrievalReadCap())
|
|
301
|
+
.map((le) => le.entry)
|
|
302
|
+
.filter((e) => matches(e.problem))
|
|
303
|
+
.sort((a, b) => scoreStoredStrategy(b) - scoreStoredStrategy(a))
|
|
304
|
+
.slice(0, cap);
|
|
305
|
+
let bytes = 0;
|
|
306
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
307
|
+
bytes += Buffer.byteLength(ranked[i].strategy, "utf8");
|
|
308
|
+
if (bytes > MAX_STRATEGY_INJECTION_TOTAL_BYTES) {
|
|
309
|
+
this.incident({
|
|
310
|
+
op: "find",
|
|
311
|
+
error: `result truncated at ${i} of ${ranked.length} entries — combined strategy text exceeded ${MAX_STRATEGY_INJECTION_TOTAL_BYTES} bytes (per-entry cap ${MAX_STRATEGY_TEXT_BYTES})`,
|
|
312
|
+
});
|
|
313
|
+
return ranked.slice(0, i);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return ranked;
|
|
317
|
+
}
|
|
318
|
+
retrievalReadCap() {
|
|
319
|
+
return this.maxPerScope * READ_FILE_CAP_FACTOR;
|
|
320
|
+
}
|
|
321
|
+
prune(scope, maxSize) {
|
|
322
|
+
const n = resolveCapacityCap("maxSize", maxSize);
|
|
323
|
+
const dir = this.scopeDirPath(scope);
|
|
324
|
+
this.assertScopeDirSafe(dir);
|
|
325
|
+
const all = this.loadScope(scope, dir, Number.POSITIVE_INFINITY);
|
|
326
|
+
if (all.length > n)
|
|
327
|
+
this.evict(all, n);
|
|
328
|
+
}
|
|
329
|
+
scopeUsage(scope) {
|
|
330
|
+
const dir = this.scopeDirPath(scope);
|
|
331
|
+
this.assertScopeDirSafe(dir);
|
|
332
|
+
return { used: this.loadScope(scope, dir, Number.POSITIVE_INFINITY).length, capacity: this.maxPerScope };
|
|
333
|
+
}
|
|
334
|
+
hasStrategy(scope, entry) {
|
|
335
|
+
const dir = this.scopeDirPath(scope);
|
|
336
|
+
this.assertScopeDirSafe(dir);
|
|
337
|
+
const key = strategyDedupKey(entry);
|
|
338
|
+
return this.loadScope(scope, dir, this.retrievalReadCap()).some((le) => strategyDedupKey(le.entry) === key);
|
|
339
|
+
}
|
|
340
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
|
|
3
|
-
"count":
|
|
3
|
+
"count": 1646,
|
|
4
4
|
"exports": {
|
|
5
5
|
"A2ATaskState": "type",
|
|
6
6
|
"A2ATaskStateReversal": "type",
|
|
@@ -317,6 +317,8 @@
|
|
|
317
317
|
"FileStorageBackend": "class",
|
|
318
318
|
"FileStorageBackendOptions": "interface",
|
|
319
319
|
"FileStorageCorruptReadInfo": "interface",
|
|
320
|
+
"FileStrategyStore": "class",
|
|
321
|
+
"FileStrategyStoreOptions": "interface",
|
|
320
322
|
"FileToolResultStore": "class",
|
|
321
323
|
"FileUsageWindowStore": "class",
|
|
322
324
|
"FileWorkflowJournalStore": "class",
|
|
@@ -866,6 +868,8 @@
|
|
|
866
868
|
"SecretEnvFindingKind": "type",
|
|
867
869
|
"SecretRef": "interface",
|
|
868
870
|
"SectionRenderInputs": "interface",
|
|
871
|
+
"SeedStrategiesReport": "interface",
|
|
872
|
+
"SeedStrategyEntry": "type",
|
|
869
873
|
"SelectiveRecallOptions": "interface",
|
|
870
874
|
"SelectiveRecallResult": "type",
|
|
871
875
|
"SemaTaskHandle": "interface",
|
|
@@ -929,7 +933,9 @@
|
|
|
929
933
|
"StoredSession": "class",
|
|
930
934
|
"StoredSessionRules": "interface",
|
|
931
935
|
"StoredStrategy": "interface",
|
|
936
|
+
"StrategyOrigin": "type",
|
|
932
937
|
"StrategyStore": "interface",
|
|
938
|
+
"StrategyStoreIncident": "interface",
|
|
933
939
|
"StreamFn": "type",
|
|
934
940
|
"StreamingImportValidator": "class",
|
|
935
941
|
"StrictControlPlaneLedger": "type",
|
|
@@ -1571,6 +1577,7 @@
|
|
|
1571
1577
|
"screenInboundEntries": "function",
|
|
1572
1578
|
"screenRuleSyncState": "function",
|
|
1573
1579
|
"scrubSecretEnv": "function",
|
|
1580
|
+
"seedStrategies": "function",
|
|
1574
1581
|
"selectModel": "function",
|
|
1575
1582
|
"selectModelOrThrow": "function",
|
|
1576
1583
|
"selfOrchestrationFailClosedReason": "function",
|