@arnilo/prism 0.10.0 → 0.11.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +18 -16
  3. package/dist/agent-run-lifecycle.d.ts +2 -1
  4. package/dist/agent-run-lifecycle.js +1 -1
  5. package/dist/agent-session/session/assemble.js +9 -7
  6. package/dist/agent-session/session/tool-round.js +30 -20
  7. package/dist/agent-session/session/types.d.ts +1 -0
  8. package/dist/agent-session/session.d.ts +1 -0
  9. package/dist/agent-session/session.js +3 -2
  10. package/dist/checkpoint-restore.d.ts +50 -14
  11. package/dist/checkpoint-restore.js +104 -28
  12. package/dist/contracts-core/session.d.ts +2 -1
  13. package/dist/contracts-run-state.d.ts +12 -4
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +1 -1
  16. package/dist/leases.js +32 -6
  17. package/dist/node/contribution-discovery.d.ts +16 -1
  18. package/dist/node/contribution-discovery.js +47 -0
  19. package/dist/node/session-store-jsonl.js +67 -17
  20. package/dist/run-limits.d.ts +11 -5
  21. package/dist/session-stores.js +61 -12
  22. package/dist/testing/prefix-stability-conformance.d.ts +44 -1
  23. package/dist/testing/prefix-stability-conformance.js +92 -29
  24. package/dist/usage-estimation.d.ts +7 -1
  25. package/dist/usage-estimation.js +16 -10
  26. package/docs/acp.md +2 -2
  27. package/docs/agent-events.md +7 -6
  28. package/docs/agent-session-runtime.md +1 -1
  29. package/docs/coding-agent-tools.md +1 -1
  30. package/docs/coding-tools.md +7 -11
  31. package/docs/context-and-skills.md +6 -7
  32. package/docs/contribution-discovery.md +13 -0
  33. package/docs/durable-runs.md +10 -3
  34. package/docs/embeddings.md +3 -1
  35. package/docs/execution-timeline.md +6 -0
  36. package/docs/extensions.md +1 -2
  37. package/docs/impeccable.md +1 -2
  38. package/docs/index.md +24 -20
  39. package/docs/live-testing.md +1 -2
  40. package/docs/memory-fabric.md +3 -2
  41. package/docs/migrate-to-0.11.md +65 -0
  42. package/docs/migration.md +12 -1
  43. package/docs/node-jsonl-session-store.md +4 -3
  44. package/docs/operations.md +1 -1
  45. package/docs/peer-dependencies.md +3 -5
  46. package/docs/policy-and-audit.md +1 -1
  47. package/docs/prefix-stability-conformance.md +30 -7
  48. package/docs/provider-packages.md +20 -20
  49. package/docs/public-contracts.md +1 -1
  50. package/docs/rag.md +2 -2
  51. package/docs/release-and-install.md +57 -57
  52. package/docs/runs-and-usage.md +6 -4
  53. package/docs/session-stores.md +2 -2
  54. package/docs/supervisors.md +14 -6
  55. package/docs/testing.md +17 -9
  56. package/docs/workflows.md +2 -2
  57. package/package.json +5 -4
  58. package/docs/caveman.md +0 -130
  59. package/docs/graft.md +0 -149
  60. package/docs/ponytail.md +0 -129
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export type { CacheTelemetry, CacheTelemetryOptions, CacheTelemetryReport, Cache
17
17
  export { CACHE_TELEMETRY_OVERFLOW_KEY, CacheTelemetryError, createCacheTelemetry, DEFAULT_CACHE_TELEMETRY_CAP, } from "./cache-telemetry.js";
18
18
  export type { ProviderCapture, ProviderCaptureEntry, ProviderCaptureOptions, ProviderCapturePolicy } from "./capture.js";
19
19
  export { createProviderCapture } from "./capture.js";
20
- export type { CheckpointRestoreAudit, CheckpointRestoreAuditEntry, CheckpointRestoreHook, RunCheckpointRestoreHooksOptions, } from "./checkpoint-restore.js";
20
+ export type { CheckpointRestoreAudit, CheckpointRestoreAuditEntry, CheckpointRestoreCompensation, CheckpointRestoreHandler, CheckpointRestoreHook, RunCheckpointRestoreHooksOptions, } from "./checkpoint-restore.js";
21
21
  export { CheckpointRestoreError, DEFAULT_CHECKPOINT_RESTORE_TIMEOUT_MS, runCheckpointRestoreHooks } from "./checkpoint-restore.js";
22
22
  export type { MemoryCheckpointStoreOptions } from "./checkpoints.js";
23
23
  export { CHECKPOINT_CONFLICT_CODE, CheckpointConflictError, createMemoryCheckpointStore } from "./checkpoints.js";
@@ -96,7 +96,7 @@ export type { RunBundleSnapshot, RunBundleSnapshotInput } from "./run-bundle.js"
96
96
  export { RUN_BUNDLE_SCHEMA_VERSION, snapshotRunBundle } from "./run-bundle.js";
97
97
  export type { BatchedRunLedgerOptions } from "./run-ledger.js";
98
98
  export { createBatchedRunLedger, DEFAULT_LEDGER_BATCH_BYTES, DEFAULT_LEDGER_BATCH_DELAY_MS, DEFAULT_LEDGER_BATCH_ENTRIES, HARD_LEDGER_BATCH_BYTES, HARD_LEDGER_BATCH_DELAY_MS, HARD_LEDGER_BATCH_ENTRIES, isFlushableRunLedger, } from "./run-ledger.js";
99
- export type { RunLimitTrackerOptions } from "./run-limits.js";
99
+ export type { BudgetExhaustionAttribution, RunLimitTrackerOptions } from "./run-limits.js";
100
100
  export { createRunLimitTracker, DEFAULT_RUN_LIMITS, HARD_RUN_LIMITS, RunLimitError, RunLimitTracker, resolveRunLimits, } from "./run-limits.js";
101
101
  export type { HostCompositionGovernance, HostCompositionOptions, HostCompositionProfile, HostCompositionReport, HostCompositionToolReport, } from "./secure-agent.js";
102
102
  export { assertHostCompositionReadiness, createSecureAgent, HostCompositionError, inspectHostComposition, } from "./secure-agent.js";
@@ -131,5 +131,5 @@ export { MODEL_FAMILY_TOKENS, resolveModelFamily } from "./usage-estimation.js";
131
131
  export type { ResolvedUseCaseModel, ResolveUseCaseModelInput, UseCaseModelBinding, } from "./use-case-model.js";
132
132
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
133
133
  export declare const name = "prism";
134
- export declare const version = "0.10.0";
134
+ export declare const version = "0.11.0";
135
135
  export declare const description = "Agent harness for AI providers, agents, sessions, and tools.";
package/dist/index.js CHANGED
@@ -72,6 +72,6 @@ export { trimTrailingSlashes } from "./trim-trailing-slashes.js";
72
72
  export { MODEL_FAMILY_TOKENS, resolveModelFamily } from "./usage-estimation.js";
73
73
  export { resolveUseCaseModel, resolveUseCaseModelBinding, useCaseCredentialProviderId, } from "./use-case-model.js";
74
74
  export const name = "prism";
75
- export const version = "0.10.0";
75
+ export const version = "0.11.0";
76
76
  export const description = "Agent harness for AI providers, agents, sessions, and tools.";
77
77
  //# sourceMappingURL=index.js.map
package/dist/leases.js CHANGED
@@ -6,10 +6,24 @@ export class LeaseConflictError extends Error {
6
6
  this.name = "LeaseConflictError";
7
7
  }
8
8
  }
9
+ /** Drop expired rows once the map reaches this size. Live rows are never evicted. */
10
+ const EXPIRED_LEASE_SWEEP_AT = 1_024;
11
+ /** Test-only size probe. Not a LeaseStore field. */
12
+ const LEASE_RECORD_COUNT = Symbol.for("prism.lease.recordCount");
9
13
  /** In-process reference implementation. Durable adapters provide cross-process exclusion. */
10
14
  export function createMemoryLeaseStore() {
11
15
  const records = new Map();
12
- return {
16
+ // ponytail: full scan only at EXPIRED_LEASE_SWEEP_AT, so a write is O(1) until then.
17
+ // Live leases are unbounded. Upgrade: cap live rows if a host holds more than the threshold.
18
+ function sweepExpired(now) {
19
+ if (records.size < EXPIRED_LEASE_SWEEP_AT)
20
+ return;
21
+ for (const [id, record] of records) {
22
+ if (Date.parse(record.expiresAt) <= now)
23
+ records.delete(id);
24
+ }
25
+ }
26
+ const store = {
13
27
  async tryAcquireLease(input) {
14
28
  validateAcquire(input);
15
29
  const id = keyOf(input);
@@ -17,7 +31,9 @@ export function createMemoryLeaseStore() {
17
31
  if (current)
18
32
  assertOwnership(input, current);
19
33
  const now = Date.now();
20
- if (current && Date.parse(current.expiresAt) > now)
34
+ sweepExpired(now);
35
+ const held = records.get(id);
36
+ if (held && Date.parse(held.expiresAt) > now)
21
37
  return null;
22
38
  const timestamp = new Date(now).toISOString();
23
39
  const record = {
@@ -25,7 +41,7 @@ export function createMemoryLeaseStore() {
25
41
  key: input.key,
26
42
  ownerId: input.ownerId,
27
43
  token: crypto.randomUUID(),
28
- fencingToken: (current?.fencingToken ?? 0) + 1,
44
+ fencingToken: (held?.fencingToken ?? 0) + 1,
29
45
  acquiredAt: timestamp,
30
46
  expiresAt: new Date(now + input.ttlMs).toISOString(),
31
47
  updatedAt: timestamp,
@@ -37,30 +53,38 @@ export function createMemoryLeaseStore() {
37
53
  async renewLease(input) {
38
54
  validateClaim(input, true);
39
55
  const current = records.get(keyOf(input));
40
- if (!current)
56
+ if (!current) {
57
+ sweepExpired(Date.now());
41
58
  return null;
59
+ }
42
60
  assertOwnership(input, current);
43
61
  const now = Date.now();
44
- if (current.ownerId !== input.ownerId || current.token !== input.token || Date.parse(current.expiresAt) <= now)
62
+ if (current.ownerId !== input.ownerId || current.token !== input.token || Date.parse(current.expiresAt) <= now) {
63
+ sweepExpired(now);
45
64
  return null;
65
+ }
46
66
  const record = {
47
67
  ...current,
48
68
  expiresAt: new Date(now + input.ttlMs).toISOString(),
49
69
  updatedAt: new Date(now).toISOString(),
50
70
  };
51
71
  records.set(keyOf(input), record);
72
+ sweepExpired(now);
52
73
  return record;
53
74
  },
54
75
  async releaseLease(input) {
55
76
  validateClaim(input, false);
56
77
  const current = records.get(keyOf(input));
57
- if (!current)
78
+ if (!current) {
79
+ sweepExpired(Date.now());
58
80
  return false;
81
+ }
59
82
  assertOwnership(input, current);
60
83
  if (current.ownerId !== input.ownerId || current.token !== input.token)
61
84
  return false;
62
85
  const now = new Date().toISOString();
63
86
  records.set(keyOf(input), { ...current, expiresAt: now, updatedAt: now });
87
+ sweepExpired(Date.now());
64
88
  return true;
65
89
  },
66
90
  async getLease(input) {
@@ -72,6 +96,8 @@ export function createMemoryLeaseStore() {
72
96
  return Date.parse(current.expiresAt) > Date.now() ? current : null;
73
97
  },
74
98
  };
99
+ Object.defineProperty(store, LEASE_RECORD_COUNT, { get: () => records.size });
100
+ return store;
75
101
  }
76
102
  function keyOf(input) {
77
103
  return `${input.namespace}\0${input.key}`;
@@ -1,4 +1,4 @@
1
- import type { ContributionFileKind, DiscoveredContribution } from "../contracts.js";
1
+ import type { ContributionFileKind, DiscoveredContribution, Skill } from "../contracts.js";
2
2
  import type { PermissionPolicy, TrustPolicy } from "../security.js";
3
3
  export interface DiscoveryOptions {
4
4
  readonly kinds: readonly ContributionFileKind[];
@@ -14,3 +14,18 @@ export interface DiscoveryOptions {
14
14
  */
15
15
  export declare function discoverContributions(options: DiscoveryOptions): Promise<readonly DiscoveredContribution[]>;
16
16
  export declare function readOptionalFile(path: string): Promise<string | undefined>;
17
+ export interface LoadSkillDirectoryOptions {
18
+ /** Per-file byte cap; defaults to `HARD_MAX_SKILL_INSTRUCTION_BYTES` (262 144). */
19
+ readonly maxSkillBytes?: number;
20
+ }
21
+ /**
22
+ * Load a host-supplied `<directory>/<name>/SKILL.md` tree (an upstream persona
23
+ * or provider checkout) into inert {@link Skill} values. Bounded per file,
24
+ * symlink-contained, and deterministic (sorted by directory name); nothing is
25
+ * auto-activated — the host registers the result through `api.registerSkill` or
26
+ * its own loader.
27
+ *
28
+ * Throws when `directory` is unreadable or a SKILL.md exceeds the cap. A
29
+ * subdirectory without SKILL.md (or a symlink escaping `directory`) is skipped.
30
+ */
31
+ export declare function loadSkillDirectory(directory: string, options?: LoadSkillDirectoryOptions): Promise<readonly Skill[]>;
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { isJsonObject } from "../config.js";
4
4
  import { parseSkillFile } from "../contribution-parsing.js";
5
5
  import { assertPermission } from "../security.js";
6
+ import { HARD_MAX_SKILL_INSTRUCTION_BYTES } from "../skill-disclosure.js";
6
7
  import { isNodeErrorCode } from "./config.js";
7
8
  import { isPathInsideReal } from "./trust.js";
8
9
  /**
@@ -103,6 +104,52 @@ export async function readOptionalFile(path) {
103
104
  throw error;
104
105
  }
105
106
  }
107
+ /**
108
+ * Load a host-supplied `<directory>/<name>/SKILL.md` tree (an upstream persona
109
+ * or provider checkout) into inert {@link Skill} values. Bounded per file,
110
+ * symlink-contained, and deterministic (sorted by directory name); nothing is
111
+ * auto-activated — the host registers the result through `api.registerSkill` or
112
+ * its own loader.
113
+ *
114
+ * Throws when `directory` is unreadable or a SKILL.md exceeds the cap. A
115
+ * subdirectory without SKILL.md (or a symlink escaping `directory`) is skipped.
116
+ */
117
+ export async function loadSkillDirectory(directory, options = {}) {
118
+ const maxBytes = options.maxSkillBytes ?? HARD_MAX_SKILL_INSTRUCTION_BYTES;
119
+ let names;
120
+ try {
121
+ names = await readdir(directory);
122
+ }
123
+ catch (error) {
124
+ throw new Error(`No readable skill directory at ${directory}: ${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ const skills = [];
127
+ for (const name of [...names].sort()) {
128
+ const dir = join(directory, name);
129
+ let isDir;
130
+ try {
131
+ isDir = (await stat(dir)).isDirectory();
132
+ }
133
+ catch (error) {
134
+ if (isNodeErrorCode(error, "ENOENT"))
135
+ continue;
136
+ throw error;
137
+ }
138
+ if (!isDir || !(await isPathInsideReal(directory, dir)))
139
+ continue;
140
+ const path = join(dir, "SKILL.md");
141
+ if (!(await isPathInsideReal(directory, path)))
142
+ continue;
143
+ const text = await readOptionalFile(path);
144
+ if (text === undefined)
145
+ continue;
146
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
147
+ throw new Error(`Skill file ${path} exceeds ${maxBytes} byte cap`);
148
+ }
149
+ skills.push(parseSkillFile(text, path));
150
+ }
151
+ return skills;
152
+ }
106
153
  // --- Internal helpers. ---
107
154
  // ponytail: parseSkillFile/parseAgentFile live in ../contribution-parsing.ts (core, fs-free); this
108
155
  // Node module owns directory walking + manifest.json parsing only.
@@ -1,8 +1,19 @@
1
- import { appendFile, mkdir, readFile } from "node:fs/promises";
1
+ import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { isSessionEntryKind, SESSION_APPEND_CONFLICT_CODE, SESSION_ENTRY_SCHEMA_VERSION, SessionAppendConflictError, } from "../contracts.js";
4
4
  import { searchLinearSessions } from "../session-stores.js";
5
5
  import { isNodeErrorCode } from "./config.js";
6
+ const IDEMPOTENCY_SEEN_MAX = 4_096;
7
+ function rememberIdempotencyKey(seen, key) {
8
+ if (seen.size >= IDEMPOTENCY_SEEN_MAX) {
9
+ const oldest = seen.values().next().value;
10
+ if (oldest !== undefined)
11
+ seen.delete(oldest);
12
+ }
13
+ seen.add(key);
14
+ }
15
+ /** Test-only read counter on the store instance. Not a SessionStore field. */
16
+ const JSONL_FILE_READS = Symbol.for("prism.jsonl.fileReads");
6
17
  export function createJsonlSessionStore(pathOrOptions) {
7
18
  const options = typeof pathOrOptions === "string" ? { path: pathOrOptions, createDirectory: true } : pathOrOptions;
8
19
  const path = options.path;
@@ -13,14 +24,50 @@ export function createJsonlSessionStore(pathOrOptions) {
13
24
  // lock). The expectedParentId/idempotency guards below mirror the memory store;
14
25
  // a DB adapter enforces them via a conditional transaction + unique index.
15
26
  const idempotencySeen = new Set();
16
- return {
27
+ // ponytail: cache key is (size, mtimeMs). A same-size rewrite inside one filesystem
28
+ // timestamp tick is invisible. Upgrade: hash the file. Post-write size !== prior
29
+ // size + bytes written already drops the cache so the next read re-parses.
30
+ let cache;
31
+ let fileReads = 0;
32
+ async function readParsed() {
33
+ fileReads++;
34
+ return readJsonlSessionEntries(path);
35
+ }
36
+ async function readEntries() {
37
+ let st;
38
+ try {
39
+ const info = await stat(path);
40
+ st = { size: info.size, mtimeMs: info.mtimeMs };
41
+ }
42
+ catch (error) {
43
+ if (isNodeErrorCode(error, "ENOENT")) {
44
+ cache = undefined;
45
+ return [];
46
+ }
47
+ throw new Error(`Failed to read session store ${path}: ${errorMessage(error)}`);
48
+ }
49
+ if (cache && cache.size === st.size && cache.mtimeMs === st.mtimeMs)
50
+ return cache.entries;
51
+ const result = await readParsed();
52
+ cache = { size: st.size, mtimeMs: st.mtimeMs, entries: result.entries };
53
+ return result.entries;
54
+ }
55
+ const store = {
17
56
  append(entry, appendOptions) {
18
57
  const operation = appendChain
19
58
  .catch(() => undefined)
20
59
  .then(async () => {
21
60
  if (options.createDirectory !== false)
22
61
  await mkdir(dirname(path), { recursive: true });
23
- const readResult = await readJsonlSessionEntries(path);
62
+ let beforeSize = 0;
63
+ try {
64
+ beforeSize = (await stat(path)).size;
65
+ }
66
+ catch (error) {
67
+ if (!isNodeErrorCode(error, "ENOENT"))
68
+ throw error;
69
+ }
70
+ const readResult = await readParsed();
24
71
  if (readResult.errors.length > 0) {
25
72
  const first = readResult.errors[0];
26
73
  throw new Error(`Invalid JSONL at line ${first.line}: ${first.message}`);
@@ -39,23 +86,30 @@ export function createJsonlSessionStore(pathOrOptions) {
39
86
  if (entries.some((existing) => existing.id === entry.id))
40
87
  throw new Error(`Duplicate session entry id: ${entry.id}`);
41
88
  if (dedupKey !== undefined)
42
- idempotencySeen.add(dedupKey);
43
- await appendFile(path, `${JSON.stringify(entry)}\n`, "utf8");
89
+ rememberIdempotencyKey(idempotencySeen, dedupKey);
90
+ const line = `${JSON.stringify(entry)}\n`;
91
+ await appendFile(path, line, "utf8");
92
+ const info = await stat(path);
93
+ if (info.size === beforeSize + Buffer.byteLength(line)) {
94
+ cache = { size: info.size, mtimeMs: info.mtimeMs, entries: entries.concat(entry) };
95
+ }
96
+ else {
97
+ cache = undefined;
98
+ }
44
99
  });
45
100
  appendChain = operation.catch(() => undefined);
46
101
  return operation;
47
102
  },
48
103
  async list(sessionId) {
49
- return (await readEntries(path)).filter((entry) => entry.sessionId === sessionId);
104
+ return (await readEntries()).filter((entry) => entry.sessionId === sessionId);
50
105
  },
51
106
  async get(id) {
52
- return findEntry(path, id);
107
+ return (await readEntries()).find((entry) => entry.id === id);
53
108
  },
54
109
  async searchSessions(query) {
55
- // ponytail: no index - every search reads and parses the file (O(corpus) time and memory), the
56
- // recommended indexed paths are the SQLite/Postgres adapters. Corrupt lines are quarantined
57
- // exactly as in list()/get(), and the contract linear caps bound entries/text scanned.
58
- const { entries } = await readJsonlSessionEntries(path);
110
+ // ponytail: no index. Cache miss still reads and parses the file (O(corpus)); hit reuses the
111
+ // parsed array. Indexed path is SQLite/Postgres. Corrupt lines stay quarantined like list()/get().
112
+ const entries = await readEntries();
59
113
  const bySession = new Map();
60
114
  const leafBySession = new Map();
61
115
  for (const entry of entries) {
@@ -69,6 +123,8 @@ export function createJsonlSessionStore(pathOrOptions) {
69
123
  return searchLinearSessions(bySession, leafBySession, query);
70
124
  },
71
125
  };
126
+ Object.defineProperty(store, JSONL_FILE_READS, { get: () => fileReads });
127
+ return store;
72
128
  }
73
129
  /** Read a JSONL session file and return both valid entries and per-line parse errors. */
74
130
  export async function readJsonlSessionEntries(path) {
@@ -94,12 +150,6 @@ export async function readJsonlSessionEntries(path) {
94
150
  }
95
151
  return { entries, errors };
96
152
  }
97
- async function findEntry(path, id) {
98
- return (await readEntries(path)).find((entry) => entry.id === id);
99
- }
100
- async function readEntries(path) {
101
- return (await readJsonlSessionEntries(path)).entries;
102
- }
103
153
  function parseEntry(line, lineNumber) {
104
154
  let parsed;
105
155
  try {
@@ -51,13 +51,19 @@ export declare class RunLimitTracker {
51
51
  private exceed;
52
52
  }
53
53
  export declare function createRunLimitTracker(limits: RunLimits | undefined, options?: RunLimitTrackerOptions): RunLimitTracker;
54
+ /**
55
+ * Attribution carried by a limit death (plan 087 T2, plan 108 T5): the `budget_exhausted` payload
56
+ * without `limit`, which `AgentRunResult.limit` / `child_failed.limit` already carry.
57
+ */
58
+ export interface BudgetExhaustionAttribution {
59
+ readonly consumed: BudgetConsumedCounters;
60
+ readonly closestOtherAxes: readonly BudgetAxisUsage[];
61
+ readonly recentToolCalls: readonly ToolCallSummary[];
62
+ }
54
63
  /**
55
64
  * Build the `budget_exhausted` payload (plan 087 T2): which axis fired, the counters a host reads
56
65
  * first, the closest other axes, and the last dispatched tool calls (hashes only).
57
66
  */
58
- export declare function describeBudgetExhaustion(tracker: RunLimitTracker, breach: RunLimitBreach, recentToolCalls: readonly ToolCallSummary[]): {
59
- limit: RunLimitName;
60
- consumed: BudgetConsumedCounters;
61
- closestOtherAxes: BudgetAxisUsage[];
62
- recentToolCalls: ToolCallSummary[];
67
+ export declare function describeBudgetExhaustion(tracker: RunLimitTracker, breach: RunLimitBreach, recentToolCalls: readonly ToolCallSummary[]): BudgetExhaustionAttribution & {
68
+ readonly limit: RunLimitName;
63
69
  };
@@ -27,11 +27,11 @@ async function readBranchFromReader(reader, query) {
27
27
  if (!cursor)
28
28
  break;
29
29
  }
30
- // Reuse the validated in-memory walk: the reader returns the ancestor SET (any order);
31
- // indexEntries + the parentId walk order it and still reject missing parents / dupes.
32
- return getSessionBranchEntriesCore(items, { leafId: query.leafId });
30
+ // Reader returns an ancestor set in any order. Order once and keep those objects.
31
+ // Detachment is the store's clone (readBranchPath / list), not a second walk here.
32
+ return orderBranch(items, { leafId: query.leafId });
33
33
  }
34
- function getSessionBranchEntriesCore(entries, options = {}, index = indexEntries(entries)) {
34
+ function orderBranch(entries, options = {}, index = indexEntries(entries)) {
35
35
  const leafId = options.leafId ?? entries.at(-1)?.id;
36
36
  if (!leafId)
37
37
  return [];
@@ -45,7 +45,11 @@ function getSessionBranchEntriesCore(entries, options = {}, index = indexEntries
45
45
  branch.push(entry);
46
46
  id = entry.parentId;
47
47
  }
48
- return branch.reverse().map(cloneEntry);
48
+ branch.reverse();
49
+ return branch;
50
+ }
51
+ function getSessionBranchEntriesCore(entries, options = {}, index = indexEntries(entries)) {
52
+ return orderBranch(entries, options, index).map(cloneEntry);
49
53
  }
50
54
  export function listSessionBranches(entries) {
51
55
  const index = indexEntries(entries);
@@ -60,13 +64,15 @@ export function rebuildSessionContext(input, options = {}) {
60
64
  return rebuildSessionContextCore(input, options);
61
65
  }
62
66
  async function rebuildSessionContextFromReader(reader, query) {
63
- // ponytail: pass the drained branch back through the sync core so compaction logic has ONE
64
- // code path; the redundant re-walk is O(branch length) and branch chains are short.
65
- const branch = await readBranchFromReader(reader, query);
66
- return rebuildSessionContextCore(branch, { leafId: query.leafId });
67
+ // ponytail: one order pass on the reader's pages. No second walk and no second clone —
68
+ // stores that implement readBranchPath already detach. Upgrade path: clone here again if a
69
+ // host reader returns live store objects and a snapshot mutation is observed in the store.
70
+ return snapshotFromBranch(await readBranchFromReader(reader, query));
67
71
  }
68
72
  function rebuildSessionContextCore(entries, options = {}) {
69
- const branch = getSessionBranchEntriesCore(entries, options);
73
+ return snapshotFromBranch(getSessionBranchEntriesCore(entries, options));
74
+ }
75
+ function snapshotFromBranch(branch) {
70
76
  const compaction = [...branch]
71
77
  .reverse()
72
78
  .find((entry) => entry.kind === "compaction" && entry.summary && isCompactionEntryData(entry.data));
@@ -90,7 +96,7 @@ function rebuildSessionContextCore(entries, options = {}) {
90
96
  if (entry.id === compaction.id)
91
97
  continue;
92
98
  if (entry.kind === "message" && entry.message && (afterThrough || keepIds.has(entry.id)))
93
- messages.push(cloneEntry(entry.message));
99
+ messages.push(entry.message);
94
100
  if (entry.kind === "summary" && entry.summary && afterThrough)
95
101
  summaries.push(entry.summary);
96
102
  }
@@ -135,6 +141,18 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
135
141
  throw new SessionSearchUnsupportedError();
136
142
  return searchLinearSessions(bySession, leafBySession, query, searchCaps);
137
143
  },
144
+ async readBranchPath(query) {
145
+ const entries = bySession.get(query.sessionId) ?? [];
146
+ const leafId = query.leafId ?? tipLeafId(entries);
147
+ if (!leafId)
148
+ return { items: [] };
149
+ const chain = orderBranch(entries, { leafId });
150
+ const start = query.cursor !== undefined ? decodeBranchOffset(query.cursor) : 0;
151
+ const limit = query.limit ?? chain.length;
152
+ const items = chain.slice(start, start + limit).map(cloneEntry);
153
+ const next = start + items.length;
154
+ return { items, nextCursor: next < chain.length ? String(next) : undefined };
155
+ },
138
156
  };
139
157
  function add(entry, options) {
140
158
  // ponytail: idempotency dedup keyed on (session, key, expectedParentId) so a
@@ -167,7 +185,7 @@ export function createMemorySessionStore(initialEntries = [], options = {}) {
167
185
  if (byId.has(entry.id))
168
186
  throw new Error(`Duplicate session entry id: ${entry.id}`);
169
187
  if (dedupKey !== undefined)
170
- idempotencySeen.add(dedupKey);
188
+ rememberIdempotencyKey(idempotencySeen, dedupKey);
171
189
  byId.set(entry.id, entry);
172
190
  const entries = bySession.get(entry.sessionId) ?? [];
173
191
  entries.push(entry);
@@ -352,9 +370,40 @@ function isAfterSearchCursor(hit, cursor, order) {
352
370
  }
353
371
  return at < cursor.updatedAt || (at === cursor.updatedAt && hit.sessionId < cursor.sessionId);
354
372
  }
373
+ /** Dedup only covers retries near the append. Older keys append as new entries. */
374
+ const IDEMPOTENCY_SEEN_MAX = 4_096;
375
+ function rememberIdempotencyKey(seen, key) {
376
+ if (seen.size >= IDEMPOTENCY_SEEN_MAX) {
377
+ const oldest = seen.values().next().value;
378
+ if (oldest !== undefined)
379
+ seen.delete(oldest);
380
+ }
381
+ seen.add(key);
382
+ }
355
383
  function cloneEntry(entry) {
356
384
  return structuredClone(entry);
357
385
  }
386
+ function decodeBranchOffset(cursor) {
387
+ const value = Number(cursor);
388
+ if (!Number.isInteger(value) || value < 0)
389
+ throw new Error("Invalid branch pagination cursor");
390
+ return value;
391
+ }
392
+ /** Tip with the latest timestamp, then highest id. Matches SQLite `findLatestLeafId`. */
393
+ function tipLeafId(entries) {
394
+ const parents = new Set();
395
+ for (const entry of entries)
396
+ if (entry.parentId)
397
+ parents.add(entry.parentId);
398
+ let best;
399
+ for (const entry of entries) {
400
+ if (parents.has(entry.id))
401
+ continue;
402
+ if (!best || entry.timestamp > best.timestamp || (entry.timestamp === best.timestamp && entry.id > best.id))
403
+ best = entry;
404
+ }
405
+ return best?.id;
406
+ }
358
407
  function isCompactionEntryData(value) {
359
408
  if (!value || typeof value !== "object" || Array.isArray(value))
360
409
  return false;
@@ -1,4 +1,4 @@
1
- import type { AgentConfig, Skill } from "../contracts.js";
1
+ import type { AgentConfig, Message, ProviderRequest, Skill } from "../contracts.js";
2
2
  export interface PrefixStabilityConformanceOptions {
3
3
  /**
4
4
  * The host's own agent config, minus `provider`, `providerSource`, and `skills`:
@@ -25,6 +25,13 @@ export interface PrefixStabilityConformanceOptions {
25
25
  * fixture was supposed to invalidate the prefix, so a run that never did cannot pass vacuously.
26
26
  */
27
27
  readonly allowedResets?: number;
28
+ /**
29
+ * Install a runner-owned fixture tool (name constant) that returns exactly this many bytes of
30
+ * generated text, and add a second tool call for it alongside `load_skill` in the same round, so
31
+ * the attention compiler's tool-result stage has one row worth stubbing. The payload is
32
+ * `"x".repeat(bytes)`, never host content. Unset: the capture is byte-identical to today.
33
+ */
34
+ readonly foldableToolResultBytes?: number;
28
35
  }
29
36
  export interface PrefixStabilityConformanceResult {
30
37
  /** Provider requests captured by the fixture (two per turn: skill load, then completion). */
@@ -43,7 +50,43 @@ export interface PrefixStabilityConformanceResult {
43
50
  * prefix instead of appending. Empty when every gap stayed above the minimum.
44
51
  */
45
52
  readonly resets: readonly number[];
53
+ /**
54
+ * One row per `resets` entry, same order. `fraction` is the metric `assertOn` selected;
55
+ * `cacheableFraction` is the tail-excluded fraction for that pair. Empty when `resets` is.
56
+ */
57
+ readonly resetDetails: readonly PrefixStabilityResetDetail[];
58
+ }
59
+ export interface ScorePrefixStabilityOptions {
60
+ /** Session tail map. Omitted or unmatched: both fractions stay equal — never a fabricated `1`. */
61
+ readonly tailSegments?: ReadonlyMap<string, Message>;
62
+ /** Minimum shared byte-prefix fraction. Default `0.95`. Selects which pairs land in `resets`. */
63
+ readonly minContinuity?: number;
64
+ /**
65
+ * Which fraction `resets` follows. Default `"providerPrefix"` (the wire prefix). The runner
66
+ * passes its own `assertOn` so fixture resets stay on the selected metric without a second pass.
67
+ */
68
+ readonly assertOn?: "providerPrefix" | "cacheablePrefix";
69
+ }
70
+ /** One reset: index plus both fractions. Numbers only — no message text. */
71
+ export interface PrefixStabilityResetDetail {
72
+ /** 1-based index of the later request in the pair. */
73
+ readonly request: number;
74
+ /** Fraction of the metric `assertOn` selected for the pair. Default: provider-visible. */
75
+ readonly fraction: number;
76
+ /** The same pair with tail segments removed. */
77
+ readonly cacheableFraction: number;
78
+ }
79
+ export interface PrefixStabilitySample {
80
+ readonly minContinuity: number;
81
+ readonly cacheableContinuity: number;
82
+ readonly resets: readonly number[];
83
+ readonly resetDetails: readonly PrefixStabilityResetDetail[];
46
84
  }
85
+ /**
86
+ * Score an already-captured request list. One pass, no session, no provider call.
87
+ * A wrong `tailSegments` map yields a wrong number, not a throw.
88
+ */
89
+ export declare function scorePrefixStability(requests: readonly ProviderRequest[], options?: ScorePrefixStabilityOptions): PrefixStabilitySample;
47
90
  /**
48
91
  * Drive a real session through two staggered skill loads and assert that each
49
92
  * provider request keeps a byte-identical leading prefix (messages **and** tool