@vincemakes/kiso-runtime 0.39.2 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.d.ts CHANGED
@@ -27,6 +27,13 @@ export interface PermissionPolicy {
27
27
  export interface AgentDefinition {
28
28
  readonly model: string;
29
29
  readonly systemPrompt?: string;
30
+ /** 0.40.0: the root a NEW session starts in, recorded once in its
31
+ * profile (revision 1) — the resume picker scopes by it. Never read
32
+ * for an existing session: where it started is history. */
33
+ readonly workspace?: string;
34
+ /** 0.40.0: the config profile that named this binding, recorded per
35
+ * revision for display. Never part of drift. */
36
+ readonly profileName?: string;
30
37
  /** `Tool<any>` like the registry: typed tools register without casts. */
31
38
  readonly tools: readonly Tool<any>[];
32
39
  readonly store: SessionStore;
@@ -79,17 +86,29 @@ export interface AgentDefinition {
79
86
  export declare class AgentRuntime {
80
87
  #private;
81
88
  constructor(definition: AgentDefinition);
89
+ /** The ids alone, from the directory — no log is read (store.ids). */
82
90
  sessionIds(): string[];
83
91
  /** Session metadata for listings (`kiso sessions`). */
84
92
  sessions(): import("./store.js").SessionMeta[];
85
93
  /** Release every held fd and writer lock (E group: the CLI closes on exit). */
86
94
  close(): void;
87
- /** Load an existing session from disk, or create a fresh one. */
95
+ /** Load an existing session from disk, or create a fresh one.
96
+ *
97
+ * `acceptDrift` is accepted and INERT — the CLI's historical
98
+ * `--accept-drift`, kept so the flag keeps parsing. Since the owner's
99
+ * ruling of 2026-09-21 a changed binding never needs an
100
+ * acknowledgement: the current configuration wins and is recorded. */
88
101
  session(options: {
89
102
  id: string;
90
103
  acceptDrift?: boolean;
91
104
  }): Promise<AgentSession>;
92
105
  }
106
+ /** 0.40.0 — what an acknowledged drift replaced: the reasons it was
107
+ * material, and the reasoning setting the acknowledgement reset. */
108
+ export interface DriftAcknowledgement {
109
+ readonly reasons: readonly string[];
110
+ readonly reasoningReset: import("./provider/metadata.js").ReasoningSetting;
111
+ }
93
112
  /** The one-liner the README promises. */
94
113
  export declare function createAgent(definition: AgentDefinition): AgentRuntime;
95
114
  /**
package/dist/agent.js CHANGED
@@ -42,8 +42,9 @@ export class AgentRuntime {
42
42
  }
43
43
  this.#adapterPromise = resolveAdapter(definition);
44
44
  }
45
+ /** The ids alone, from the directory — no log is read (store.ids). */
45
46
  sessionIds() {
46
- return this.#definition.store.list().map((m) => m.id);
47
+ return this.#definition.store.ids();
47
48
  }
48
49
  /** Session metadata for listings (`kiso sessions`). */
49
50
  sessions() {
@@ -53,7 +54,12 @@ export class AgentRuntime {
53
54
  close() {
54
55
  this.#definition.store.closeAll();
55
56
  }
56
- /** Load an existing session from disk, or create a fresh one. */
57
+ /** Load an existing session from disk, or create a fresh one.
58
+ *
59
+ * `acceptDrift` is accepted and INERT — the CLI's historical
60
+ * `--accept-drift`, kept so the flag keeps parsing. Since the owner's
61
+ * ruling of 2026-09-21 a changed binding never needs an
62
+ * acknowledgement: the current configuration wins and is recorded. */
57
63
  async session(options) {
58
64
  const store = this.#definition.store;
59
65
  const records = store.load(options.id);
@@ -74,28 +80,47 @@ export class AgentRuntime {
74
80
  revision: 0,
75
81
  modelId: this.#definition.model,
76
82
  provider: startupScope ?? null,
83
+ profileName: this.#definition.profileName ?? null,
84
+ workspace: this.#definition.workspace ?? null,
77
85
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
78
86
  registry: this.#registry,
79
87
  });
80
88
  let restored = null;
81
89
  let profilePending = false;
90
+ let driftAcknowledgement = null;
82
91
  if (meta.kind === "ok") {
83
92
  const drift = assessProfileDrift(meta.profile, {
84
93
  provider: startupScope ?? null,
94
+ modelId: this.#definition.model,
85
95
  systemPromptDigest: candidate.systemPromptDigest,
86
96
  tools: candidate.tools,
87
97
  });
88
- if (drift.kind === "material" && options.acceptDrift !== true) {
89
- const listed = drift.reasons.map((r) => `- ${r}`).join("\n");
90
- throw new Error(`the recorded execution profile no longer matches this process:\n${listed}\nre-open with acceptDrift (the CLI's --accept-drift flag) to proceed under the CURRENT configuration — the acknowledgement is recorded as a new revision; silently rebuilding is forbidden`);
91
- }
92
98
  if (drift.kind === "material") {
93
- // acknowledged: the current configuration wins, DURABLY.
99
+ // 0.40.1 (the owner's ruling of 2026-09-21): a DIFFERENT BINDING
100
+ // never blocks. A person may simply have switched models, so the
101
+ // CURRENT configuration wins — DURABLY, as the next revision —
102
+ // and the session says so. It used to refuse without an explicit
103
+ // `acceptDrift`, which turned "I moved to another provider"
104
+ // into an error the resume could not pass at all.
105
+ //
106
+ // Nothing provider-specific crosses the change: every adapter
107
+ // withholds foreign reasoning/continuation (MG-1 A5), so a new
108
+ // binding loses cache state, never correctness. The reasoning
109
+ // resets to defaults (owner-ruled: the recorded effort was a
110
+ // choice for the model that no longer answers) — and the session
111
+ // says so through `driftAcknowledgement`. The field's NAME is
112
+ // history: nothing is acknowledged any more, the change is
113
+ // recorded and stated.
114
+ driftAcknowledgement = { reasons: drift.reasons, reasoningReset: meta.profile.reasoning };
94
115
  writeProfile(store.root, options.id, {
95
116
  ...buildProfile({
96
117
  revision: meta.profile.revision + 1,
97
118
  modelId: this.#definition.model,
98
119
  provider: startupScope ?? null,
120
+ profileName: this.#definition.profileName ?? null,
121
+ // history: where the session started is not where the
122
+ // acknowledging process happens to be
123
+ workspace: meta.profile.workspace,
99
124
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
100
125
  registry: this.#registry,
101
126
  }),
@@ -129,6 +154,8 @@ export class AgentRuntime {
129
154
  : {}),
130
155
  ...(restored !== null ? { reasoning: restored.reasoning } : {}),
131
156
  ...(profilePending ? { profilePending: true } : {}),
157
+ ...(this.#definition.profileName !== undefined ? { profileName: this.#definition.profileName } : {}),
158
+ ...(driftAcknowledgement !== null ? { driftAcknowledgement } : {}),
132
159
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
133
160
  registry: this.#registry,
134
161
  ...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
@@ -148,7 +175,16 @@ export class AgentRuntime {
148
175
  ...(this.#definition.streamIdleMs !== undefined ? { streamIdleMs: this.#definition.streamIdleMs } : {}),
149
176
  ...(this.#definition.extensions !== undefined ? { extensions: this.#definition.extensions } : {}),
150
177
  };
151
- return new AgentSession(options.id, log, store, adapter, config);
178
+ // 0.40.0: the last bill's time rides the RECORD, not the event.
179
+ let lastUsageAt;
180
+ for (let i = records.length - 1; i >= 0; i--) {
181
+ const e = records[i].event;
182
+ if (e.type === "usage" && e.known) {
183
+ lastUsageAt = records[i].ts;
184
+ break;
185
+ }
186
+ }
187
+ return new AgentSession(options.id, log, store, adapter, config, lastUsageAt);
152
188
  }
153
189
  }
154
190
  /** The one-liner the README promises. */
@@ -0,0 +1,82 @@
1
+ /**
2
+ * ADR-0055 Amendment 1 (A1b) — the in-run compaction policy, as data and
3
+ * pure decisions. The kernel only asks, before every request, what to
4
+ * append (LoopConfig.compact); this module answers the WHEN, and the
5
+ * session supplies the summary itself (session.ts compactionPoint).
6
+ *
7
+ * Every number here is a stated position, not a measurement, and the ADR
8
+ * says so: the tiers are the owner's ruling, the phase detector is a
9
+ * heuristic over round shapes that is reported and never proven, and the
10
+ * prune's break-even is arithmetic on one price table.
11
+ */
12
+ import { type Event } from "@vincemakes/kiso-core";
13
+ /** A1: soft makes compaction eligible (it waits for a phase end), hard
14
+ * fires at the next settled round, emergency before the next request.
15
+ * Emergency is `max(window − reserve, hard)` (the lead, 2026-09-18), so
16
+ * soft ≤ hard ≤ emergency always holds: a small window never goes
17
+ * negative, and DeepSeek's 384K max output on a 1M window puts emergency
18
+ * at hard (700K) — an output over 300K is left to the one overflow
19
+ * recovery. A5: the raw tail stays v1's `min(0.1·window, 100K)`. */
20
+ export interface Tiers {
21
+ readonly soft: number;
22
+ readonly hard: number;
23
+ readonly emergency: number;
24
+ readonly tail: number;
25
+ }
26
+ export declare function tiersFor(windowTokens: number, reserve: number): Tiers;
27
+ /**
28
+ * A1 — which tier a measured context is past, the most urgent first; the
29
+ * soft tier asks the phase detector. Null: no tier is crossed. Emergency
30
+ * only where it stands STRICTLY above hard (the lead, A1B-M1): at the
31
+ * clamp the two coincide, and that fire is hard — not prune-eligible.
32
+ */
33
+ export declare function tierReason(used: number, t: Tiers, why: "request" | "overflow", phase: () => string | null): string | null;
34
+ /**
35
+ * A3 rule 1's runners — the lead's table, matched on a command's first
36
+ * words once leading `VAR=value` assignments and the plain wrappers are
37
+ * stripped. A shell call not on it is not a test run. The CLI puts the
38
+ * user's configured `checks` in front of it.
39
+ */
40
+ export declare const TEST_RUNNERS: readonly string[];
41
+ /** Whether one shell command line runs a check: any `&&`/`;`/`|` segment
42
+ * whose leading words, past assignments and wrappers, start a runner. */
43
+ export declare function runsACheck(command: string, extra?: readonly string[]): boolean;
44
+ /**
45
+ * A3 — does the LAST settled round end a phase? The rounds are the model
46
+ * turns since the last boundary, read off their tool calls. A new user
47
+ * turn since the last round is a phase end of its own. Returns the rule
48
+ * that fired, for the record, or null.
49
+ */
50
+ export declare function phaseEnd(events: readonly Event[], sinceSeq: number, isCheck: (command: string) => boolean): string | null;
51
+ /** The default number of the newest compactable results a prune keeps. */
52
+ export declare const KEEP_COMPACTABLE_RESULTS = 4;
53
+ /**
54
+ * The prune primitive's boundary (moved unchanged from the kernel, where
55
+ * the standing trigger that used it was removed — A4): drawn by
56
+ * compactable-result recentness, never user turns. The newest
57
+ * `keepResults` still-visible compactable results stay; the boundary is
58
+ * the (K+1)th-newest. A do-not-compact result is uncountable, exactly as
59
+ * the projection refuses to clear it. Undefined when nothing would clear.
60
+ */
61
+ export declare function microcompactBoundarySeq(events: readonly Event[], keepResults?: number): number | undefined;
62
+ /** A4(c): the requests a run is assumed to have left when nothing better
63
+ * is known — a stated position, not a prediction. */
64
+ export declare const REQUESTS_ASSUMED_LEFT = 50;
65
+ /**
66
+ * A4(c) — a prune that drops `dropped` tokens and re-sends `keptAfter`
67
+ * tokens cold (everything after the first cleared result) pays only if at
68
+ * least `factor · keptAfter / dropped` requests follow. `factor` is
69
+ * (miss − hit) / hit from the model's price table: 49 at DeepSeek V4.1
70
+ * Flash off-peak (0.15 and 0.003 per million, 2026-09-19).
71
+ */
72
+ export declare function pruneBreaksEven(dropped: number, keptAfter: number, factor: number, requestsLeft?: number): boolean;
73
+ /** The factor for a price table: (miss − hit) / hit, or 49 (the DeepSeek
74
+ * V4.1 Flash off-peak figure) when the table cannot say. */
75
+ export declare function breakEvenFactor(missPerMillion: number | undefined, hitPerMillion: number | undefined): number;
76
+ /**
77
+ * A4(b) with its (c) guard — the emergency prune, only where it pays. D is
78
+ * what the clear drops (the clearable results up to the boundary), S what
79
+ * is re-sent cold (everything from the first cleared result on, minus D).
80
+ * Undefined: nothing to clear, or too few requests left to earn it back.
81
+ */
82
+ export declare function guardedPruneSeq(events: readonly Event[], factor: number, requestsLeft?: number, keepResults?: number): number | undefined;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ADR-0055 Amendment 1 (A1b) — the in-run compaction policy, as data and
3
+ * pure decisions. The kernel only asks, before every request, what to
4
+ * append (LoopConfig.compact); this module answers the WHEN, and the
5
+ * session supplies the summary itself (session.ts compactionPoint).
6
+ *
7
+ * Every number here is a stated position, not a measurement, and the ADR
8
+ * says so: the tiers are the owner's ruling, the phase detector is a
9
+ * heuristic over round shapes that is reported and never proven, and the
10
+ * prune's break-even is arithmetic on one price table.
11
+ */
12
+ import { DO_NOT_COMPACT, MICROCOMPACTABLE } from "@vincemakes/kiso-core";
13
+ import { estimateEventTokens } from "./summarize.js";
14
+ export function tiersFor(windowTokens, reserve) {
15
+ return {
16
+ soft: Math.min(0.5 * windowTokens, 400_000),
17
+ hard: Math.min(0.8 * windowTokens, 700_000),
18
+ emergency: Math.max(windowTokens - reserve, Math.min(0.8 * windowTokens, 700_000)),
19
+ tail: Math.min(0.1 * windowTokens, 100_000),
20
+ };
21
+ }
22
+ /**
23
+ * A1 — which tier a measured context is past, the most urgent first; the
24
+ * soft tier asks the phase detector. Null: no tier is crossed. Emergency
25
+ * only where it stands STRICTLY above hard (the lead, A1B-M1): at the
26
+ * clamp the two coincide, and that fire is hard — not prune-eligible.
27
+ */
28
+ export function tierReason(used, t, why, phase) {
29
+ if (why === "overflow")
30
+ return "overflow";
31
+ if (used > t.emergency && t.emergency > t.hard)
32
+ return "emergency";
33
+ if (used > t.hard)
34
+ return "hard";
35
+ if (used > t.soft)
36
+ return phase();
37
+ return null;
38
+ }
39
+ /**
40
+ * A3 rule 1's runners — the lead's table, matched on a command's first
41
+ * words once leading `VAR=value` assignments and the plain wrappers are
42
+ * stripped. A shell call not on it is not a test run. The CLI puts the
43
+ * user's configured `checks` in front of it.
44
+ */
45
+ export const TEST_RUNNERS = [
46
+ "npm test", "npm run test", "pnpm test", "yarn test", "bun test", "npx vitest", "npx jest", "npx mocha", "node --test",
47
+ "pytest", "python -m pytest", "go test", "cargo test", "make test", "mvn test", "gradle test", "dotnet test", "rspec",
48
+ "phpunit", "mix test", "swift test",
49
+ ];
50
+ const WRAPPERS = new Set(["env", "time", "nice", "nohup", "command", "exec", "sudo"]);
51
+ /** Whether one shell command line runs a check: any `&&`/`;`/`|` segment
52
+ * whose leading words, past assignments and wrappers, start a runner. */
53
+ export function runsACheck(command, extra = []) {
54
+ const runners = [...extra, ...TEST_RUNNERS];
55
+ return command.split(/&&|\|\||;|\|/).some((segment) => {
56
+ const words = segment.trim().split(/\s+/);
57
+ while (words.length > 0 && (WRAPPERS.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])))
58
+ words.shift();
59
+ if (words[0] === "timeout")
60
+ words.splice(0, 2);
61
+ const line = words.join(" ");
62
+ return runners.some((r) => line === r || line.startsWith(`${r} `));
63
+ });
64
+ }
65
+ const EDIT_TOOLS = new Set(["write_file", "edit_file"]);
66
+ const READ_TOOLS = new Set(["read_file", "list_dir", "search_text", "read_skill"]);
67
+ /**
68
+ * A3 — does the LAST settled round end a phase? The rounds are the model
69
+ * turns since the last boundary, read off their tool calls. A new user
70
+ * turn since the last round is a phase end of its own. Returns the rule
71
+ * that fired, for the record, or null.
72
+ */
73
+ export function phaseEnd(events, sinceSeq, isCheck) {
74
+ const rounds = [];
75
+ let calls = [];
76
+ let userAfterLastRound = false;
77
+ for (const e of events) {
78
+ if (e.seq <= sinceSeq)
79
+ continue;
80
+ if (e.type === "tool_call_end")
81
+ calls.push(e);
82
+ else if (e.type === "user_input")
83
+ userAfterLastRound = true;
84
+ else if (e.type === "stop" && calls.length > 0) {
85
+ rounds.push({
86
+ edits: calls.some((c) => EDIT_TOOLS.has(c.name)),
87
+ readsOnly: calls.every((c) => READ_TOOLS.has(c.name)),
88
+ check: calls.some((c) => (c.name === "shell" && typeof c.input?.command === "string" && isCheck(c.input.command)) || (c.name === "delegate" && c.input?.check !== undefined)),
89
+ });
90
+ calls = [];
91
+ userAfterLastRound = false;
92
+ }
93
+ }
94
+ if (userAfterLastRound)
95
+ return "phase:turn";
96
+ const last = rounds.at(-1);
97
+ const prev = rounds.at(-2);
98
+ if (last === undefined)
99
+ return null;
100
+ if (last.check)
101
+ return "phase:check";
102
+ if (prev !== undefined && prev.edits && !last.edits)
103
+ return "phase:edits-done";
104
+ if (prev !== undefined && prev.readsOnly && last.edits)
105
+ return "phase:reads-done";
106
+ return null;
107
+ }
108
+ /** The default number of the newest compactable results a prune keeps. */
109
+ export const KEEP_COMPACTABLE_RESULTS = 4;
110
+ /**
111
+ * The prune primitive's boundary (moved unchanged from the kernel, where
112
+ * the standing trigger that used it was removed — A4): drawn by
113
+ * compactable-result recentness, never user turns. The newest
114
+ * `keepResults` still-visible compactable results stay; the boundary is
115
+ * the (K+1)th-newest. A do-not-compact result is uncountable, exactly as
116
+ * the projection refuses to clear it. Undefined when nothing would clear.
117
+ */
118
+ export function microcompactBoundarySeq(events, keepResults = KEEP_COMPACTABLE_RESULTS) {
119
+ const callName = new Map();
120
+ let lastCleared = -1;
121
+ for (const ev of events) {
122
+ if (ev.type === "tool_call_end")
123
+ callName.set(ev.callId, ev.name);
124
+ if (ev.type === "microcompacted" && ev.beforeSeq > lastCleared)
125
+ lastCleared = ev.beforeSeq;
126
+ }
127
+ const visible = [];
128
+ for (const ev of events) {
129
+ if (ev.type !== "tool_result" || ev.seq <= lastCleared)
130
+ continue;
131
+ const name = callName.get(ev.callId);
132
+ if (name !== undefined && MICROCOMPACTABLE.has(name) && !(ev.tags ?? []).includes(DO_NOT_COMPACT))
133
+ visible.push(ev.seq);
134
+ }
135
+ if (visible.length <= keepResults)
136
+ return undefined;
137
+ return visible[visible.length - keepResults - 1];
138
+ }
139
+ /** A4(c): the requests a run is assumed to have left when nothing better
140
+ * is known — a stated position, not a prediction. */
141
+ export const REQUESTS_ASSUMED_LEFT = 50;
142
+ /**
143
+ * A4(c) — a prune that drops `dropped` tokens and re-sends `keptAfter`
144
+ * tokens cold (everything after the first cleared result) pays only if at
145
+ * least `factor · keptAfter / dropped` requests follow. `factor` is
146
+ * (miss − hit) / hit from the model's price table: 49 at DeepSeek V4.1
147
+ * Flash off-peak (0.15 and 0.003 per million, 2026-09-19).
148
+ */
149
+ export function pruneBreaksEven(dropped, keptAfter, factor, requestsLeft = REQUESTS_ASSUMED_LEFT) {
150
+ if (dropped <= 0)
151
+ return false;
152
+ return requestsLeft >= (factor * keptAfter) / dropped;
153
+ }
154
+ /** The factor for a price table: (miss − hit) / hit, or 49 (the DeepSeek
155
+ * V4.1 Flash off-peak figure) when the table cannot say. */
156
+ export function breakEvenFactor(missPerMillion, hitPerMillion) {
157
+ if (missPerMillion === undefined || hitPerMillion === undefined || hitPerMillion <= 0)
158
+ return 49;
159
+ return (missPerMillion - hitPerMillion) / hitPerMillion;
160
+ }
161
+ /**
162
+ * A4(b) with its (c) guard — the emergency prune, only where it pays. D is
163
+ * what the clear drops (the clearable results up to the boundary), S what
164
+ * is re-sent cold (everything from the first cleared result on, minus D).
165
+ * Undefined: nothing to clear, or too few requests left to earn it back.
166
+ */
167
+ export function guardedPruneSeq(events, factor, requestsLeft = REQUESTS_ASSUMED_LEFT, keepResults = KEEP_COMPACTABLE_RESULTS) {
168
+ const beforeSeq = microcompactBoundarySeq(events, keepResults);
169
+ if (beforeSeq === undefined)
170
+ return undefined;
171
+ const callName = new Map();
172
+ let lastCleared = -1;
173
+ for (const ev of events) {
174
+ if (ev.type === "tool_call_end")
175
+ callName.set(ev.callId, ev.name);
176
+ if (ev.type === "microcompacted" && ev.beforeSeq > lastCleared)
177
+ lastCleared = ev.beforeSeq;
178
+ }
179
+ let dropped = 0;
180
+ let first;
181
+ for (const ev of events) {
182
+ if (ev.type !== "tool_result" || ev.seq <= lastCleared || ev.seq > beforeSeq)
183
+ continue;
184
+ const name = callName.get(ev.callId);
185
+ if (name === undefined || !MICROCOMPACTABLE.has(name) || (ev.tags ?? []).includes(DO_NOT_COMPACT))
186
+ continue;
187
+ dropped += estimateEventTokens(ev);
188
+ first ??= ev.seq;
189
+ }
190
+ if (first === undefined)
191
+ return undefined;
192
+ let after = 0;
193
+ for (const ev of events)
194
+ if (ev.seq >= first)
195
+ after += estimateEventTokens(ev);
196
+ return pruneBreaksEven(dropped, after - dropped, factor, requestsLeft) ? beforeSeq : undefined;
197
+ }
package/dist/compose.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import type { ApprovalChain, Event, HookHost, KisoExtension, ToolRegistry } from "@vincemakes/kiso-core";
7
7
  import type { SessionConfig } from "./session.js";
8
+ /** The base a run's system prompt is composed on: the session's prompt,
9
+ * then the tool table (generated machinery sits BETWEEN the base and the
10
+ * extension appends). One function, so an in-band summary outside a run
11
+ * (ADR-0055 A2, /compact) sends the exact prefix a run sends. */
12
+ export declare function runBasePrompt(systemPrompt: string | undefined, registry: ToolRegistry): string | undefined;
8
13
  /** The table, or "" when the registry is empty (no vocabulary, no tools). */
9
14
  export declare function composeToolTable(registry: ToolRegistry): string;
10
15
  /**
package/dist/compose.js CHANGED
@@ -18,6 +18,14 @@ const TOOL_RULES = [
18
18
  { tool: "list_dir", line: "list with list_dir, never ls" },
19
19
  { tool: "shell", line: "shell for what the file tools cannot do: commands, git, the network, the system" },
20
20
  ];
21
+ /** The base a run's system prompt is composed on: the session's prompt,
22
+ * then the tool table (generated machinery sits BETWEEN the base and the
23
+ * extension appends). One function, so an in-band summary outside a run
24
+ * (ADR-0055 A2, /compact) sends the exact prefix a run sends. */
25
+ export function runBasePrompt(systemPrompt, registry) {
26
+ const toolTable = composeToolTable(registry);
27
+ return toolTable === "" ? systemPrompt : systemPrompt === undefined ? toolTable : `${systemPrompt}\n\n${toolTable}`;
28
+ }
21
29
  /** The table, or "" when the registry is empty (no vocabulary, no tools). */
22
30
  export function composeToolTable(registry) {
23
31
  const tools = registry.list();
@@ -73,7 +81,10 @@ export function composeHooks(existing, extensions) {
73
81
  await h(payload, ctx);
74
82
  };
75
83
  };
76
- for (const key of ["onPreLlm", "onEvent", "onPause", "onStop"]) {
84
+ // onRetry (ADR-0005 Amendment 2) is an observer like the rest: every
85
+ // source hears every retry, in order — the CLI's status row and an
86
+ // extension's logger must not be made to take turns.
87
+ for (const key of ["onPreLlm", "onEvent", "onPause", "onStop", "onRetry"]) {
77
88
  const handler = observers((h) => h[key]);
78
89
  if (handler !== undefined)
79
90
  out[key] = handler;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 0.40.0 (the owner's session) — what the context holds, anchored on the
3
+ * last BILLED request.
4
+ *
5
+ * The estimate alone read a Chinese-heavy context as ~470k while the
6
+ * provider billed 732,448 input tokens on a 1M window: the ctx row said
7
+ * "~53% left" and the microcompact trigger (window/2) never fired. The
8
+ * provider's own count of the last request is the truth; only what the log
9
+ * appended after it is estimated.
10
+ *
11
+ * The anchor is refused — the caller falls back to the estimate — when no
12
+ * bill describes the context any more: no usage yet, a usage the provider
13
+ * did not report (`known: false`), a usage at or before `floorSeq` (the
14
+ * session switched models since, and a different tokenizer counts
15
+ * differently), or one of the STALE_AFTER events after it, each of which
16
+ * changes what the next request carries.
17
+ */
18
+ import { type Event, type Usage } from "@vincemakes/kiso-core";
19
+ /**
20
+ * `usageTotal` turns one usage event into the tokens that request put in
21
+ * the context: its canonical prompt (fresh + cache read + cache write — the
22
+ * raw `inputTokens` means different things per route) plus its output.
23
+ * Returns undefined when the anchor is refused.
24
+ */
25
+ export declare function contextAnchor(events: readonly Event[], usageTotal: (u: Usage) => number, floorSeq?: number): number | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 0.40.0 (the owner's session) — what the context holds, anchored on the
3
+ * last BILLED request.
4
+ *
5
+ * The estimate alone read a Chinese-heavy context as ~470k while the
6
+ * provider billed 732,448 input tokens on a 1M window: the ctx row said
7
+ * "~53% left" and the microcompact trigger (window/2) never fired. The
8
+ * provider's own count of the last request is the truth; only what the log
9
+ * appended after it is estimated.
10
+ *
11
+ * The anchor is refused — the caller falls back to the estimate — when no
12
+ * bill describes the context any more: no usage yet, a usage the provider
13
+ * did not report (`known: false`), a usage at or before `floorSeq` (the
14
+ * session switched models since, and a different tokenizer counts
15
+ * differently), or one of the STALE_AFTER events after it, each of which
16
+ * changes what the next request carries.
17
+ */
18
+ import { estimateTokens } from "@vincemakes/kiso-core";
19
+ const STALE_AFTER = new Set([
20
+ "compacted",
21
+ "microcompacted",
22
+ "summarized",
23
+ "user_input_replaced",
24
+ "model_output_abandoned",
25
+ ]);
26
+ /**
27
+ * `usageTotal` turns one usage event into the tokens that request put in
28
+ * the context: its canonical prompt (fresh + cache read + cache write — the
29
+ * raw `inputTokens` means different things per route) plus its output.
30
+ * Returns undefined when the anchor is refused.
31
+ */
32
+ export function contextAnchor(events, usageTotal, floorSeq = -1) {
33
+ let appended = 0;
34
+ for (let i = events.length - 1; i >= 0; i--) {
35
+ const e = events[i];
36
+ if (e.type === "usage")
37
+ return e.known && e.seq > floorSeq ? usageTotal(e) + appended : undefined;
38
+ if (STALE_AFTER.has(e.type))
39
+ return undefined;
40
+ if (e.type === "tool_result")
41
+ appended += estimateTokens([{ role: "tool", callId: e.callId, content: e.content, isError: e.isError }]);
42
+ else if (e.type === "user_input")
43
+ appended += estimateTokens([{ role: "user", content: e.content }]);
44
+ }
45
+ return undefined;
46
+ }
@@ -17,6 +17,7 @@ export * from "./session.js";
17
17
  export * from "./run.js";
18
18
  export * from "./recovery.js";
19
19
  export * from "./compose.js";
20
+ export * from "./compaction-policy.js";
20
21
  export * from "./summarize.js";
21
22
  export * from "./store.js";
22
23
  export * from "./lock-adapter.js";
@@ -28,3 +29,5 @@ export { canonicalizeUsageForModel } from "./usage/canonical.js";
28
29
  export { BUILTIN_MANIFESTS, resolveContinuationScope, type ModelRef, type ProviderManifest } from "./provider/manifest.js";
29
30
  export * from "./checkpoint.js";
30
31
  export * from "./request-budget.js";
32
+ export { readProfile, readSummary, writeSummary, type ExecutionProfile, type ProfileReadResult } from "./profile.js";
33
+ export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending, type SessionListing, type SessionSummary } from "./session-summary.js";
package/dist/internal.js CHANGED
@@ -17,6 +17,7 @@ export * from "./session.js";
17
17
  export * from "./run.js";
18
18
  export * from "./recovery.js";
19
19
  export * from "./compose.js";
20
+ export * from "./compaction-policy.js";
20
21
  export * from "./summarize.js";
21
22
  export * from "./store.js";
22
23
  export * from "./lock-adapter.js";
@@ -36,3 +37,6 @@ export { BUILTIN_MANIFESTS, resolveContinuationScope } from "./provider/manifest
36
37
  // and the request-budget accounting the CLI's status line reads.
37
38
  export * from "./checkpoint.js";
38
39
  export * from "./request-budget.js";
40
+ // 0.40.0: the listing reads the recorded workspace and profile name.
41
+ export { readProfile, readSummary, writeSummary } from "./profile.js";
42
+ export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending } from "./session-summary.js";
package/dist/profile.d.ts CHANGED
@@ -48,6 +48,11 @@ export interface ExecutionProfile {
48
48
  /** the config profile NAME — the credential reference is at most the
49
49
  * env-var name the config carries; never the secret. */
50
50
  readonly profileName: string | null;
51
+ /** 0.40.0: the realpath of the root the session STARTED in — history,
52
+ * not configuration. Set at revision 1 and carried by every later
53
+ * revision; null when the start is unknown (a legacy session's first
54
+ * revision, or a sidecar written before the field existed). */
55
+ readonly workspace: string | null;
51
56
  readonly reasoning: ReasoningSetting;
52
57
  readonly systemPromptDigest: string;
53
58
  /** sorted by name; the digest below is DERIVED from this inventory. */
@@ -59,7 +64,10 @@ export declare function buildProfile(input: {
59
64
  readonly revision: number;
60
65
  readonly modelId: string;
61
66
  readonly provider: ProfileModelRef | null;
62
- readonly profileName?: string;
67
+ readonly profileName?: string | null;
68
+ /** An INPUT, never derived here: revision 1 passes the starting root,
69
+ * every later writer passes the prior revision's value. */
70
+ readonly workspace?: string | null;
63
71
  readonly reasoning?: ReasoningSetting;
64
72
  readonly systemPrompt?: string;
65
73
  readonly registry: ToolRegistry;
@@ -67,8 +75,14 @@ export declare function buildProfile(input: {
67
75
  export declare function profilePath(root: string, sessionId: string): string;
68
76
  /** Atomic, fail-closed write: a reader sees the previous revision or the
69
77
  * new one, never a torn file — and the RENAME itself is made durable by
70
- * the parent-directory fsync. */
78
+ * the parent-directory fsync. 0.40.0 dogfood: the file holds two tenants
79
+ * (`profile`, `summary`); writing one preserves the other. */
71
80
  export declare function writeProfile(root: string, sessionId: string, profile: ExecutionProfile): void;
81
+ /** The 0.40.0 dogfood's `summary` tenant — what the session list reads
82
+ * instead of the log. Preserves the `profile` tenant byte for byte. */
83
+ export declare function writeSummary(root: string, sessionId: string, summary: import("./session-summary.js").SessionSummary): void;
84
+ /** The summary tenant, or null (no sidecar, no summary, or unreadable). */
85
+ export declare function readSummary(root: string, sessionId: string): import("./session-summary.js").SessionSummary | null;
72
86
  export type ProfileReadResult = {
73
87
  readonly kind: "ok";
74
88
  readonly profile: ExecutionProfile;
@@ -95,8 +109,9 @@ export type ProfileDrift = {
95
109
  readonly kind: "compatible-additions";
96
110
  readonly added: readonly string[];
97
111
  }
98
- /** WHO ANSWERS changed — the one class that blocks without an
99
- * explicit acknowledgement. */
112
+ /** WHO ANSWERS changed — NAMED, and recorded as the next revision under
113
+ * the CURRENT configuration. Owner-ruled 2026-09-21: a changed binding
114
+ * never blocks a resume — the person may simply have switched models. */
100
115
  | {
101
116
  readonly kind: "material";
102
117
  readonly reasons: readonly string[];
@@ -105,10 +120,14 @@ export type ProfileDrift = {
105
120
  * never from digest inequality alone: every recorded tool present with
106
121
  * identical hashes plus new names = compatible additions (a one-line
107
122
  * notice); a missing name, a changed hash, a provider/model divergence,
108
- * or a system-prompt divergence = MATERIAL (explicit resolution; a
109
- * digest mismatch is never presented as restoration). */
123
+ * or a system-prompt divergence = MATERIAL (the change is NAMED and
124
+ * recorded as the next revision under the CURRENT configuration — it
125
+ * blocks nothing; a digest mismatch is never presented as restoration). */
110
126
  export declare function assessProfileDrift(recorded: ExecutionProfile, current: {
111
127
  readonly provider: ProfileModelRef | null;
128
+ /** The model THIS process would run — needed because an unscoped binding
129
+ * has no provider ref to carry it. */
130
+ readonly modelId: string;
112
131
  readonly systemPromptDigest: string;
113
132
  readonly tools: readonly ProfileToolRecord[];
114
133
  }): ProfileDrift;