@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 +14 -0
- package/dist/agent.js +26 -3
- package/dist/compaction-policy.d.ts +82 -0
- package/dist/compaction-policy.js +197 -0
- package/dist/compose.d.ts +5 -0
- package/dist/compose.js +12 -1
- package/dist/context-anchor.d.ts +25 -0
- package/dist/context-anchor.js +46 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +4 -0
- package/dist/profile.d.ts +16 -2
- package/dist/profile.js +42 -3
- package/dist/run.d.ts +1 -1
- package/dist/run.js +35 -9
- package/dist/session-summary.d.ts +87 -0
- package/dist/session-summary.js +166 -0
- package/dist/session.d.ts +77 -2
- package/dist/session.js +258 -22
- package/dist/store.d.ts +8 -0
- package/dist/store.js +27 -2
- package/dist/summarize.d.ts +65 -7
- package/dist/summarize.js +98 -31
- package/package.json +6 -6
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/internal.d.ts
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";
|
|
@@ -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(
|
|
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
|
-
|
|
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) };
|