@vincemakes/kiso-runtime 0.39.2 → 0.40.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/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,6 +86,7 @@ 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[];
@@ -90,6 +98,12 @@ export declare class AgentRuntime {
90
98
  acceptDrift?: boolean;
91
99
  }): Promise<AgentSession>;
92
100
  }
101
+ /** 0.40.0 — what an acknowledged drift replaced: the reasons it was
102
+ * material, and the reasoning setting the acknowledgement reset. */
103
+ export interface DriftAcknowledgement {
104
+ readonly reasons: readonly string[];
105
+ readonly reasoningReset: import("./provider/metadata.js").ReasoningSetting;
106
+ }
93
107
  /** The one-liner the README promises. */
94
108
  export declare function createAgent(definition: AgentDefinition): AgentRuntime;
95
109
  /**
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() {
@@ -74,11 +75,14 @@ export class AgentRuntime {
74
75
  revision: 0,
75
76
  modelId: this.#definition.model,
76
77
  provider: startupScope ?? null,
78
+ profileName: this.#definition.profileName ?? null,
79
+ workspace: this.#definition.workspace ?? null,
77
80
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
78
81
  registry: this.#registry,
79
82
  });
80
83
  let restored = null;
81
84
  let profilePending = false;
85
+ let driftAcknowledgement = null;
82
86
  if (meta.kind === "ok") {
83
87
  const drift = assessProfileDrift(meta.profile, {
84
88
  provider: startupScope ?? null,
@@ -90,12 +94,20 @@ export class AgentRuntime {
90
94
  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
95
  }
92
96
  if (drift.kind === "material") {
93
- // acknowledged: the current configuration wins, DURABLY.
97
+ // acknowledged: the current configuration wins, DURABLY. The
98
+ // reasoning resets to defaults (owner-ruled: the recorded effort
99
+ // was a choice for the model that no longer answers) — and the
100
+ // session says so, through `driftAcknowledgement`.
101
+ driftAcknowledgement = { reasons: drift.reasons, reasoningReset: meta.profile.reasoning };
94
102
  writeProfile(store.root, options.id, {
95
103
  ...buildProfile({
96
104
  revision: meta.profile.revision + 1,
97
105
  modelId: this.#definition.model,
98
106
  provider: startupScope ?? null,
107
+ profileName: this.#definition.profileName ?? null,
108
+ // history: where the session started is not where the
109
+ // acknowledging process happens to be
110
+ workspace: meta.profile.workspace,
99
111
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
100
112
  registry: this.#registry,
101
113
  }),
@@ -129,6 +141,8 @@ export class AgentRuntime {
129
141
  : {}),
130
142
  ...(restored !== null ? { reasoning: restored.reasoning } : {}),
131
143
  ...(profilePending ? { profilePending: true } : {}),
144
+ ...(this.#definition.profileName !== undefined ? { profileName: this.#definition.profileName } : {}),
145
+ ...(driftAcknowledgement !== null ? { driftAcknowledgement } : {}),
132
146
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
133
147
  registry: this.#registry,
134
148
  ...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
@@ -148,7 +162,16 @@ export class AgentRuntime {
148
162
  ...(this.#definition.streamIdleMs !== undefined ? { streamIdleMs: this.#definition.streamIdleMs } : {}),
149
163
  ...(this.#definition.extensions !== undefined ? { extensions: this.#definition.extensions } : {}),
150
164
  };
151
- return new AgentSession(options.id, log, store, adapter, config);
165
+ // 0.40.0: the last bill's time rides the RECORD, not the event.
166
+ let lastUsageAt;
167
+ for (let i = records.length - 1; i >= 0; i--) {
168
+ const e = records[i].event;
169
+ if (e.type === "usage" && e.known) {
170
+ lastUsageAt = records[i].ts;
171
+ break;
172
+ }
173
+ }
174
+ return new AgentSession(options.id, log, store, adapter, config, lastUsageAt);
152
175
  }
153
176
  }
154
177
  /** 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;
package/dist/profile.js CHANGED
@@ -40,6 +40,7 @@ export function buildProfile(input) {
40
40
  modelId: input.modelId,
41
41
  provider: input.provider,
42
42
  profileName: input.profileName ?? null,
43
+ workspace: input.workspace ?? null,
43
44
  reasoning: input.reasoning ?? { thinking: "default", effort: "default" },
44
45
  systemPromptDigest: sha(input.systemPrompt ?? ""),
45
46
  tools,
@@ -49,10 +50,37 @@ export function buildProfile(input) {
49
50
  export function profilePath(root, sessionId) {
50
51
  return join(root, `${sessionId}.meta.json`);
51
52
  }
53
+ /** The sidecar's tenants as they are on disk NOW — the writer of one
54
+ * tenant carries the other's bytes through untouched. An unreadable file
55
+ * yields {}: the writer that follows replaces it (the profile path's own
56
+ * fail-closed read is readProfile, which a writer never bypasses). */
57
+ function currentTenants(root, sessionId) {
58
+ try {
59
+ const parsed = JSON.parse(readFileSync(profilePath(root, sessionId), "utf8"));
60
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ }
52
66
  /** Atomic, fail-closed write: a reader sees the previous revision or the
53
67
  * new one, never a torn file — and the RENAME itself is made durable by
54
- * the parent-directory fsync. */
68
+ * the parent-directory fsync. 0.40.0 dogfood: the file holds two tenants
69
+ * (`profile`, `summary`); writing one preserves the other. */
55
70
  export function writeProfile(root, sessionId, profile) {
71
+ writeTenants(root, sessionId, { ...currentTenants(root, sessionId), profile });
72
+ }
73
+ /** The 0.40.0 dogfood's `summary` tenant — what the session list reads
74
+ * instead of the log. Preserves the `profile` tenant byte for byte. */
75
+ export function writeSummary(root, sessionId, summary) {
76
+ writeTenants(root, sessionId, { ...currentTenants(root, sessionId), summary });
77
+ }
78
+ /** The summary tenant, or null (no sidecar, no summary, or unreadable). */
79
+ export function readSummary(root, sessionId) {
80
+ const t = currentTenants(root, sessionId).summary;
81
+ return t !== undefined && typeof t.updatedAt === "number" ? t : null;
82
+ }
83
+ function writeTenants(root, sessionId, tenants) {
56
84
  const path = profilePath(root, sessionId);
57
85
  const tmpDir = mkdtempSync(join(root, ".meta-"));
58
86
  const tmp = join(tmpDir, "meta.json");
@@ -61,7 +89,7 @@ export function writeProfile(root, sessionId, profile) {
61
89
  // because rename preserves it, and rename is the only path by which
62
90
  // this file comes into existence. Existing files are not migrated —
63
91
  // R6's choice, kept.
64
- writeFileSync(tmp, `${JSON.stringify({ profile }, null, "\t")}\n`, { mode: 0o600 });
92
+ writeFileSync(tmp, `${JSON.stringify(tenants, null, "\t")}\n`, { mode: 0o600 });
65
93
  const fd = openSync(tmp, "r");
66
94
  try {
67
95
  fsyncSync(fd);
@@ -95,6 +123,11 @@ export function readProfile(root, sessionId) {
95
123
  }
96
124
  try {
97
125
  const parsed = JSON.parse(raw);
126
+ // 0.40.0 dogfood: a sidecar that holds ONLY the summary tenant (a legacy
127
+ // session the list summarised) has no profile — absent, never corrupt:
128
+ // "corrupt" BLOCKS the session from opening, and nothing is wrong
129
+ if (parsed !== null && typeof parsed === "object" && !("profile" in parsed))
130
+ return { kind: "absent" };
98
131
  const p = parsed.profile;
99
132
  if (p === undefined ||
100
133
  typeof p.revision !== "number" ||
@@ -105,7 +138,13 @@ export function readProfile(root, sessionId) {
105
138
  !Array.isArray(p.tools)) {
106
139
  return { kind: "corrupt", error: "the profile tenant is missing or malformed" };
107
140
  }
108
- return { kind: "ok", profile: p };
141
+ // 0.40.0: a sidecar written before `workspace` existed (and one whose
142
+ // `profileName` no writer ever filled) reads as null — unknown, never
143
+ // a guess. The reader stays open: no new key is required.
144
+ return {
145
+ kind: "ok",
146
+ profile: { ...p, profileName: typeof p.profileName === "string" ? p.profileName : null, workspace: typeof p.workspace === "string" ? p.workspace : null },
147
+ };
109
148
  }
110
149
  catch (err) {
111
150
  return { kind: "corrupt", error: String(err.message ?? err) };