opencode-orchestrator-uni 0.4.0 → 0.5.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/README.md +149 -14
- package/README.ru.md +151 -14
- package/agents/en/orchestrator.md +51 -5
- package/agents/ru/orchestrator.md +51 -5
- package/docs/en/call-mechanics.md +6 -4
- package/docs/en/instruction-other-session.md +6 -6
- package/docs/en/root-runbook.md +3 -1
- package/docs/en/session-discipline.md +230 -0
- package/docs/ru/call-mechanics.md +6 -4
- package/docs/ru/instruction-other-session.md +6 -6
- package/docs/ru/root-runbook.md +3 -1
- package/docs/ru/session-discipline.md +225 -0
- package/index.ts +44 -12
- package/lib/commands.ts +62 -6
- package/lib/session-budget.ts +1236 -0
- package/lib/tools.ts +145 -7
- package/lib/write-guard.ts +283 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-budget — the kit's session protocol as runtime behaviour.
|
|
3
|
+
*
|
|
4
|
+
* The process documents describe how to keep a session healthy: a session is one
|
|
5
|
+
* slice with a measurable DoD, the prompt budget is bounded, a compaction is a
|
|
6
|
+
* stop signal, tool output belongs in a file rather than in context, and a
|
|
7
|
+
* handoff is written before the next session starts. Prose alone does not hold:
|
|
8
|
+
* a long-running root drifts past the budget silently. This module makes the
|
|
9
|
+
* protocol observable and, where it is safe, enforced:
|
|
10
|
+
*
|
|
11
|
+
* - `session.context` — estimate the prompt (system + messages), keep counters,
|
|
12
|
+
* and, when the estimate crosses a budget band or a compaction just happened,
|
|
13
|
+
* append one short reminder to the request's system parts. The reminder text is
|
|
14
|
+
* constant within a band, so prefix caching is not broken on every turn;
|
|
15
|
+
* - compaction events — the `session.compaction` hook is registered on every host
|
|
16
|
+
* and counts attempts (`compactionAttempts`) plus a tentative note that names no
|
|
17
|
+
* compaction number and claims no limit; a compaction is confirmed only by
|
|
18
|
+
* `session.compaction.ended` (deduplicated), which is what raises the limit.
|
|
19
|
+
* When the event stream dies, the guard flips `compactionSource` to `hook` and
|
|
20
|
+
* reports the failure in `guard.streamFailures`; on a host without an event
|
|
21
|
+
* stream attempts grow but no confirmed compaction or stop signal appears, so a
|
|
22
|
+
* failed attempt is never mistaken for a lossy summary;
|
|
23
|
+
* - `tool.execute.after` — cap the model-facing content of configured tools.
|
|
24
|
+
* The full output is written to a temp file; the model gets a head plus the
|
|
25
|
+
* path. Only tools that produce read-only text (shell/bash/webfetch by
|
|
26
|
+
* default) are capped — `read`, `execute` (Code Mode), edit tools and the
|
|
27
|
+
* kit's own tools keep their contract.
|
|
28
|
+
*
|
|
29
|
+
* Everything here is fail-open: a hook error leaves the request/tool result
|
|
30
|
+
* untouched and increments `errors`, because a broken guard must never break the
|
|
31
|
+
* session it tries to protect. Counters are in-memory and scoped to one plugin
|
|
32
|
+
* instance; `orch_uni_session_stats` exposes them to the orchestrator.
|
|
33
|
+
*/
|
|
34
|
+
import { randomUUID } from "node:crypto"
|
|
35
|
+
import { lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises"
|
|
36
|
+
import { tmpdir } from "node:os"
|
|
37
|
+
import path from "node:path"
|
|
38
|
+
import type { Plugin } from "@opencode/plugin"
|
|
39
|
+
import type { Locale } from "../index"
|
|
40
|
+
|
|
41
|
+
/** Session guard defaults: 150k estimated prompt tokens, at most one compaction. */
|
|
42
|
+
export const DEFAULT_SESSION_GUARD = {
|
|
43
|
+
enabled: true,
|
|
44
|
+
promptTokensSoft: 150_000,
|
|
45
|
+
maxCompactions: 1,
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
/** Tool-output guard defaults: read-only text tools, 20k chars, 4k head kept. */
|
|
49
|
+
export const DEFAULT_TOOL_OUTPUT_GUARD = {
|
|
50
|
+
enabled: true,
|
|
51
|
+
maxChars: 20_000,
|
|
52
|
+
headChars: 4_000,
|
|
53
|
+
tools: ["shell", "bash", "webfetch"],
|
|
54
|
+
} as const
|
|
55
|
+
|
|
56
|
+
export interface SessionGuardConfig {
|
|
57
|
+
readonly enabled: boolean
|
|
58
|
+
readonly promptTokensSoft: number
|
|
59
|
+
readonly maxCompactions: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ToolOutputGuardConfig {
|
|
63
|
+
readonly enabled: boolean
|
|
64
|
+
readonly maxChars: number
|
|
65
|
+
readonly headChars: number
|
|
66
|
+
readonly tools: readonly string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SessionBudgetConfig {
|
|
70
|
+
readonly session: SessionGuardConfig
|
|
71
|
+
readonly toolOutput: ToolOutputGuardConfig
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where compaction counts come from: the event stream, or the legacy hook fallback. */
|
|
75
|
+
export type CompactionSource = "event" | "hook"
|
|
76
|
+
|
|
77
|
+
/** Counters of one session, as exposed by `orch_uni_session_stats`. */
|
|
78
|
+
export interface SessionBudgetCounters {
|
|
79
|
+
readonly sessionID: string
|
|
80
|
+
readonly requests: number
|
|
81
|
+
readonly promptTokensLast: number
|
|
82
|
+
readonly promptTokensMax: number
|
|
83
|
+
readonly promptTokensMedian: number
|
|
84
|
+
readonly band: number
|
|
85
|
+
/** Confirmed compactions only: `session.compaction.ended` events on an event host. */
|
|
86
|
+
readonly compactions: number
|
|
87
|
+
/** Compaction attempts observed through the always-registered hook. */
|
|
88
|
+
readonly compactionAttempts: number
|
|
89
|
+
readonly compactionFailures: number
|
|
90
|
+
readonly compactionSource: CompactionSource
|
|
91
|
+
readonly reminders: number
|
|
92
|
+
readonly toolCalls: number
|
|
93
|
+
readonly outputsCapped: number
|
|
94
|
+
readonly outputCharsSaved: number
|
|
95
|
+
/** Hook errors observed while handling this session; a dead event stream is in `guard.streamFailures`. */
|
|
96
|
+
readonly errors: number
|
|
97
|
+
/** All tool calls by normalized tool name (namespace stripped, lower-case). */
|
|
98
|
+
readonly toolCallsByTool: Readonly<Record<string, number>>
|
|
99
|
+
/** Capped outputs by normalized tool name; every called tool has an entry (possibly 0). */
|
|
100
|
+
readonly toolOutputsCappedByTool: Readonly<Record<string, number>>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface MutableCounters {
|
|
104
|
+
sessionID: string
|
|
105
|
+
requests: number
|
|
106
|
+
promptTokensLast: number
|
|
107
|
+
promptTokensMax: number
|
|
108
|
+
samples: number[]
|
|
109
|
+
band: number
|
|
110
|
+
compactions: number
|
|
111
|
+
compactionAttempts: number
|
|
112
|
+
compactionFailures: number
|
|
113
|
+
/** Confirmed compactions whose lossy note was not delivered yet. */
|
|
114
|
+
pendingConfirmed: number
|
|
115
|
+
/** An attempt is in flight: a terminal event has not arrived yet. */
|
|
116
|
+
tentative: boolean
|
|
117
|
+
reminders: number
|
|
118
|
+
toolCalls: number
|
|
119
|
+
outputsCapped: number
|
|
120
|
+
outputCharsSaved: number
|
|
121
|
+
errors: number
|
|
122
|
+
toolCallsByTool: Record<string, number>
|
|
123
|
+
toolOutputsCappedByTool: Record<string, number>
|
|
124
|
+
/** Deduplication window for compaction events (`event.id` / durable position). */
|
|
125
|
+
seenCompactionEvents: Set<string>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The guard's live wiring state, as exposed by `orch_uni_session_stats`. */
|
|
129
|
+
export interface SessionBudgetGuard {
|
|
130
|
+
/** `false` when this host has no hook API or the registration failed. */
|
|
131
|
+
readonly available: boolean
|
|
132
|
+
readonly hooksRegistered: boolean
|
|
133
|
+
readonly sessionEnabled: boolean
|
|
134
|
+
/**
|
|
135
|
+
* `true` only when capping can actually fire: the private root exists **and**
|
|
136
|
+
* the effective tool list is not empty.
|
|
137
|
+
*/
|
|
138
|
+
readonly toolOutputEnabled: boolean
|
|
139
|
+
/** The effective (normalized, protected names removed) list of capped tools. */
|
|
140
|
+
readonly toolOutputTools: readonly string[]
|
|
141
|
+
readonly compactionSource: CompactionSource
|
|
142
|
+
/** `true` while the compaction event stream is subscribed and consuming. */
|
|
143
|
+
readonly eventLive: boolean
|
|
144
|
+
/** Number of event-stream failures (0 or 1); kept out of the per-session `errors`. */
|
|
145
|
+
readonly streamFailures: number
|
|
146
|
+
/** Hook calls without a usable `sessionID`; they are dropped, not bucketed under `""`. */
|
|
147
|
+
readonly unkeyedCalls: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read-only view handed to the tools; keeps the hook state private. */
|
|
151
|
+
export interface SessionBudgetReport {
|
|
152
|
+
readonly available: boolean
|
|
153
|
+
readonly config: SessionBudgetConfig
|
|
154
|
+
readonly stats: (sessionID: string) => SessionBudgetCounters
|
|
155
|
+
/** `true` once any event or hook observed this session; `session.deleted` forgets it. */
|
|
156
|
+
readonly tracked: (sessionID: string) => boolean
|
|
157
|
+
readonly guard: () => SessionBudgetGuard
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface SessionBudget extends SessionBudgetReport {
|
|
161
|
+
readonly cleanup: Plugin.Cleanup
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Test seam for the private output root: a failing factory simulates an unusable
|
|
166
|
+
* temp directory (`mkdtemp` throws), and a factory returning a known path lets a
|
|
167
|
+
* test inspect modes and cleanup. Production always uses the default.
|
|
168
|
+
*/
|
|
169
|
+
export interface SessionBudgetRuntime {
|
|
170
|
+
readonly createOutputRoot?: () => Promise<string>
|
|
171
|
+
/**
|
|
172
|
+
* Test seam between `mkdir` of the session directory and the link/realpath
|
|
173
|
+
* check: the containment test swaps the directory for a junction to prove
|
|
174
|
+
* that a link planted there is refused. Production never sets it.
|
|
175
|
+
*/
|
|
176
|
+
readonly afterSessionDirectoryCreated?: (directory: string) => Promise<void> | void
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const DEFAULT_RUNTIME: SessionBudgetRuntime = {}
|
|
180
|
+
|
|
181
|
+
const DEFAULT_CONFIG: SessionBudgetConfig = {
|
|
182
|
+
session: DEFAULT_SESSION_GUARD,
|
|
183
|
+
toolOutput: DEFAULT_TOOL_OUTPUT_GUARD,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
187
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
188
|
+
? (value as Record<string, unknown>)
|
|
189
|
+
: undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function errorMessage(error: unknown): string {
|
|
193
|
+
return error instanceof Error ? error.message : String(error)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `JSON.stringify` throws on BigInt and cyclic values; `String` throws on a
|
|
198
|
+
* hostile `toString`/`toJSON`. A warning formatter must never be the reason a
|
|
199
|
+
* guard fails to parse its options, so the total form has a last-resort marker.
|
|
200
|
+
*/
|
|
201
|
+
export function safeJson(value: unknown): string {
|
|
202
|
+
try {
|
|
203
|
+
const text = JSON.stringify(value)
|
|
204
|
+
if (typeof text === "string") return text
|
|
205
|
+
} catch {
|
|
206
|
+
// BigInt, a cycle or a throwing toJSON: try the plain string form.
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return String(value)
|
|
210
|
+
} catch {
|
|
211
|
+
return "[unserializable]"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function finiteNumber(value: unknown, fallback: number, min: number, where: string, warn: Warn): number {
|
|
216
|
+
if (value === undefined) return fallback
|
|
217
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
|
|
218
|
+
warn(`${where}: expected a number >= ${min}, got ${safeJson(value)}, using ${fallback}`)
|
|
219
|
+
return fallback
|
|
220
|
+
}
|
|
221
|
+
return value
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function booleanOption(value: unknown, fallback: boolean, where: string, warn: Warn): boolean {
|
|
225
|
+
if (value === undefined) return fallback
|
|
226
|
+
if (typeof value !== "boolean") {
|
|
227
|
+
warn(`${where}: expected a boolean, got ${safeJson(value)}, using ${fallback}`)
|
|
228
|
+
return fallback
|
|
229
|
+
}
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type Warn = (message: string) => void
|
|
234
|
+
|
|
235
|
+
/** Tools whose full output is part of their contract; capping them breaks work. */
|
|
236
|
+
const PROTECTED_TOOLS: ReadonlySet<string> = new Set([
|
|
237
|
+
"read",
|
|
238
|
+
"write",
|
|
239
|
+
"edit",
|
|
240
|
+
"patch",
|
|
241
|
+
"execute",
|
|
242
|
+
"subagent",
|
|
243
|
+
"question",
|
|
244
|
+
])
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The kit's own tools are never capped. Runtime names are namespaced
|
|
248
|
+
* (`orch_uni.session_stats`) and the `orch_uni` prefix is caught by
|
|
249
|
+
* `isProtectedTool`, but a config entry may name the bare segment
|
|
250
|
+
* (`session_stats`); those are refused by segment so the protection cannot be
|
|
251
|
+
* configured away.
|
|
252
|
+
*/
|
|
253
|
+
const KIT_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
254
|
+
"session_stats",
|
|
255
|
+
"sessions",
|
|
256
|
+
"snapshot",
|
|
257
|
+
"context_lint",
|
|
258
|
+
"validate_report",
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Runtime tool names may carry a namespace (`functions.shell`); the guard cares
|
|
263
|
+
* about the tool itself. Normalization strips the namespace, trims and
|
|
264
|
+
* lower-cases, and is applied to both the config list and the runtime name.
|
|
265
|
+
*/
|
|
266
|
+
function normalizeToolName(value: string): string {
|
|
267
|
+
const lower = value.trim().toLowerCase()
|
|
268
|
+
const dot = lower.lastIndexOf(".")
|
|
269
|
+
return dot === -1 ? lower : lower.slice(dot + 1)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isProtectedTool(name: string): boolean {
|
|
273
|
+
return PROTECTED_TOOLS.has(name) || name.startsWith("orch_uni")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Parse `options.sessionGuard` / `options.toolOutputGuard` from the plugin
|
|
278
|
+
* entry. Invalid fields are reported and replaced by the defaults; the guard
|
|
279
|
+
* never fails setup because of a typo in the config.
|
|
280
|
+
*/
|
|
281
|
+
export function readSessionBudgetOptions(options: unknown, warn: Warn): SessionBudgetConfig {
|
|
282
|
+
const root = asRecord(options)
|
|
283
|
+
const sessionRaw = asRecord(root?.sessionGuard)
|
|
284
|
+
const outputRaw = asRecord(root?.toolOutputGuard)
|
|
285
|
+
if (root?.sessionGuard !== undefined && sessionRaw === undefined) {
|
|
286
|
+
warn("options.sessionGuard: expected an object, ignored")
|
|
287
|
+
}
|
|
288
|
+
if (root?.toolOutputGuard !== undefined && outputRaw === undefined) {
|
|
289
|
+
warn("options.toolOutputGuard: expected an object, ignored")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const session: SessionGuardConfig = {
|
|
293
|
+
enabled: booleanOption(sessionRaw?.enabled, DEFAULT_SESSION_GUARD.enabled, "options.sessionGuard.enabled", warn),
|
|
294
|
+
promptTokensSoft: finiteNumber(
|
|
295
|
+
sessionRaw?.promptTokensSoft,
|
|
296
|
+
DEFAULT_SESSION_GUARD.promptTokensSoft,
|
|
297
|
+
1000,
|
|
298
|
+
"options.sessionGuard.promptTokensSoft",
|
|
299
|
+
warn,
|
|
300
|
+
),
|
|
301
|
+
maxCompactions: Math.max(
|
|
302
|
+
0,
|
|
303
|
+
Math.floor(
|
|
304
|
+
finiteNumber(
|
|
305
|
+
sessionRaw?.maxCompactions,
|
|
306
|
+
DEFAULT_SESSION_GUARD.maxCompactions,
|
|
307
|
+
0,
|
|
308
|
+
"options.sessionGuard.maxCompactions",
|
|
309
|
+
warn,
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let tools: readonly string[] = DEFAULT_TOOL_OUTPUT_GUARD.tools
|
|
316
|
+
if (outputRaw?.tools !== undefined) {
|
|
317
|
+
if (Array.isArray(outputRaw.tools) && outputRaw.tools.every((item) => typeof item === "string")) {
|
|
318
|
+
const normalized: string[] = []
|
|
319
|
+
for (const item of outputRaw.tools) {
|
|
320
|
+
const full = item.trim().toLowerCase()
|
|
321
|
+
const name = normalizeToolName(item)
|
|
322
|
+
if (name === "") continue
|
|
323
|
+
if (isProtectedTool(full) || isProtectedTool(name)) {
|
|
324
|
+
warn(`options.toolOutputGuard.tools: "${full}" is a protected tool and cannot be capped, ignored`)
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (KIT_TOOL_NAMES.has(name)) {
|
|
328
|
+
warn(`options.toolOutputGuard.tools: "${full}" is a kit tool and cannot be capped, ignored`)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
if (!normalized.includes(name)) normalized.push(name)
|
|
332
|
+
}
|
|
333
|
+
if (normalized.length === 0) {
|
|
334
|
+
warn(
|
|
335
|
+
outputRaw.tools.length === 0
|
|
336
|
+
? "options.toolOutputGuard.tools: the list is empty, tool output capping is disabled"
|
|
337
|
+
: "options.toolOutputGuard.tools: every entry was empty or protected, tool output capping is disabled",
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
tools = normalized
|
|
341
|
+
} else {
|
|
342
|
+
warn(
|
|
343
|
+
`options.toolOutputGuard.tools: expected an array of strings, got ${safeJson(outputRaw.tools)}, using ${DEFAULT_TOOL_OUTPUT_GUARD.tools.join("|")}`,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const maxChars = finiteNumber(
|
|
349
|
+
outputRaw?.maxChars,
|
|
350
|
+
DEFAULT_TOOL_OUTPUT_GUARD.maxChars,
|
|
351
|
+
100,
|
|
352
|
+
"options.toolOutputGuard.maxChars",
|
|
353
|
+
warn,
|
|
354
|
+
)
|
|
355
|
+
const headChars = finiteNumber(
|
|
356
|
+
outputRaw?.headChars,
|
|
357
|
+
DEFAULT_TOOL_OUTPUT_GUARD.headChars,
|
|
358
|
+
0,
|
|
359
|
+
"options.toolOutputGuard.headChars",
|
|
360
|
+
warn,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
session,
|
|
365
|
+
toolOutput: {
|
|
366
|
+
enabled: booleanOption(
|
|
367
|
+
outputRaw?.enabled,
|
|
368
|
+
DEFAULT_TOOL_OUTPUT_GUARD.enabled,
|
|
369
|
+
"options.toolOutputGuard.enabled",
|
|
370
|
+
warn,
|
|
371
|
+
),
|
|
372
|
+
maxChars,
|
|
373
|
+
headChars: Math.min(headChars, maxChars),
|
|
374
|
+
tools,
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Rough token estimate of any JSON-ish value: text characters / 3.3. */
|
|
380
|
+
export function estimateTokens(value: unknown): number {
|
|
381
|
+
return Math.round(countChars(value, new Set()) / 3.3)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function countChars(value: unknown, seen: Set<object>): number {
|
|
385
|
+
if (typeof value === "string") return value.length
|
|
386
|
+
if (typeof value !== "object" || value === null) return 0
|
|
387
|
+
if (ArrayBuffer.isView(value)) return value.byteLength
|
|
388
|
+
if (seen.has(value)) return 0
|
|
389
|
+
seen.add(value)
|
|
390
|
+
if (Array.isArray(value)) {
|
|
391
|
+
let total = 0
|
|
392
|
+
for (const item of value) total += countChars(item, seen)
|
|
393
|
+
return total
|
|
394
|
+
}
|
|
395
|
+
let total = 0
|
|
396
|
+
for (const item of Object.values(value as Record<string, unknown>)) total += countChars(item, seen)
|
|
397
|
+
return total
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Threshold label without rounding: 1000 → `1k`, 1500 → `1.5k`, 150000 → `150k`. */
|
|
401
|
+
export function formatThreshold(value: number): string {
|
|
402
|
+
if (value < 1000) return String(value)
|
|
403
|
+
return `${value / 1000}k`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** `maxCompactions: 0` disables the limit; otherwise the comparison is inclusive. */
|
|
407
|
+
export function compactionLimitReached(config: SessionBudgetConfig, count: number): boolean {
|
|
408
|
+
return config.session.maxCompactions > 0 && count >= config.session.maxCompactions
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function reminderText(locale: Locale, band: number, config: SessionBudgetConfig): string {
|
|
412
|
+
const threshold = formatThreshold(config.session.promptTokensSoft)
|
|
413
|
+
const max = config.toolOutput.maxChars
|
|
414
|
+
if (locale === "ru") {
|
|
415
|
+
return (
|
|
416
|
+
`[Гигиена сессии] Оценка контекста выше бюджета (уровень ${band}×${threshold} токенов). ` +
|
|
417
|
+
"Закрой срез сейчас: handoff по session-discipline.md → коммит → новая сессия. " +
|
|
418
|
+
`Батчинг: ≥3 независимых команд за ход; вывод больше ${max} символов — в файл.`
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
return (
|
|
422
|
+
`[Session hygiene] Estimated context is above the budget (level ${band}×${threshold} tokens). ` +
|
|
423
|
+
"Close the slice now: handoff per session-discipline.md → commit → new session. " +
|
|
424
|
+
`Batch: ≥3 independent commands per turn; output over ${max} chars → file.`
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function compactionText(locale: Locale, count: number, config: SessionBudgetConfig): string {
|
|
429
|
+
const max = config.session.maxCompactions
|
|
430
|
+
const over = max > 0 && count >= max
|
|
431
|
+
if (locale === "ru") {
|
|
432
|
+
return (
|
|
433
|
+
`[Гигиена сессии] Компакция #${count} (${max > 0 ? `лимит ${max}` : "лимит отключён"}). Компакция lossy: ` +
|
|
434
|
+
`зафиксируй решения, риски и следующий шаг в handoff; ` +
|
|
435
|
+
`не полагайся на пересказ.${over ? " Лимит исчерпан — новая сессия обязательна." : ""}`
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
return (
|
|
439
|
+
`[Session hygiene] Compaction #${count} (${max > 0 ? `limit ${max}` : "limit disabled"}). Compaction is lossy: ` +
|
|
440
|
+
`pin decisions, risks and the next step ` +
|
|
441
|
+
`to the handoff; do not trust the summary.${over ? " Limit reached — start a new session." : ""}`
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A compaction attempt was observed through the always-registered hook, but no
|
|
447
|
+
* terminal event has confirmed or failed it yet. The text deliberately names no
|
|
448
|
+
* compaction number and no limit: only a confirmed compaction may do that.
|
|
449
|
+
*/
|
|
450
|
+
function tentativeCompactionText(locale: Locale): string {
|
|
451
|
+
if (locale === "ru") {
|
|
452
|
+
return (
|
|
453
|
+
"[Гигиена сессии] Попытка компакции, подтверждение ожидается. " +
|
|
454
|
+
"Зафиксируй решения, риски и следующий шаг в handoff — не полагайся на пересказ."
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
return (
|
|
458
|
+
"[Session hygiene] Compaction attempt in progress, confirmation pending. " +
|
|
459
|
+
"Pin decisions, risks and the next step to the handoff; do not trust the summary."
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
interface ToolContentText {
|
|
464
|
+
readonly type: "text"
|
|
465
|
+
readonly text: string
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
interface ToolContentFile {
|
|
469
|
+
readonly type: "file"
|
|
470
|
+
readonly uri: string
|
|
471
|
+
readonly mime: string
|
|
472
|
+
readonly name?: string
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
type ToolContent = ToolContentText | ToolContentFile
|
|
476
|
+
|
|
477
|
+
/** Model-facing text of a tool result: a string or the text parts of an array. */
|
|
478
|
+
export function toolResultText(content: string | ReadonlyArray<ToolContent> | undefined): string {
|
|
479
|
+
if (typeof content === "string") return content
|
|
480
|
+
if (!Array.isArray(content)) return ""
|
|
481
|
+
return content
|
|
482
|
+
.filter((part): part is ToolContentText => part.type === "text")
|
|
483
|
+
.map((part) => part.text)
|
|
484
|
+
.join("\n")
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Replace the model-facing text, keeping file parts (images, attachments) intact. */
|
|
488
|
+
function cappedContent(
|
|
489
|
+
content: string | ReadonlyArray<ToolContent>,
|
|
490
|
+
stub: string,
|
|
491
|
+
): string | ReadonlyArray<ToolContent> {
|
|
492
|
+
if (typeof content === "string") return stub
|
|
493
|
+
const files = content.filter((part): part is ToolContentFile => part.type === "file")
|
|
494
|
+
return [{ type: "text", text: stub }, ...files]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Prefix of the per-process private root; `mkdtemp` appends its own random suffix. */
|
|
498
|
+
const OUTPUT_ROOT_PREFIX = "opencode-tool-output-"
|
|
499
|
+
|
|
500
|
+
/** One path inside the root? `path.relative` also catches `..` escapes. */
|
|
501
|
+
function isInside(root: string, target: string): boolean {
|
|
502
|
+
const relative = path.relative(root, target)
|
|
503
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function emptyCounters(sessionID: string, source: CompactionSource): SessionBudgetCounters {
|
|
507
|
+
return {
|
|
508
|
+
sessionID,
|
|
509
|
+
requests: 0,
|
|
510
|
+
promptTokensLast: 0,
|
|
511
|
+
promptTokensMax: 0,
|
|
512
|
+
promptTokensMedian: 0,
|
|
513
|
+
band: 0,
|
|
514
|
+
compactions: 0,
|
|
515
|
+
compactionAttempts: 0,
|
|
516
|
+
compactionFailures: 0,
|
|
517
|
+
compactionSource: source,
|
|
518
|
+
reminders: 0,
|
|
519
|
+
toolCalls: 0,
|
|
520
|
+
outputsCapped: 0,
|
|
521
|
+
outputCharsSaved: 0,
|
|
522
|
+
errors: 0,
|
|
523
|
+
toolCallsByTool: {},
|
|
524
|
+
toolOutputsCappedByTool: {},
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Lone surrogates (a JSON string escaped as `\ud800`, a sliced UTF-16 buffer) are
|
|
530
|
+
* not encodable as UTF-8: `writeFile` substitutes U+FFFD, so the file would not
|
|
531
|
+
* match the text whose length the stub reports. Normalize once, up front, and use
|
|
532
|
+
* the same string for the file, the head and the counters.
|
|
533
|
+
*/
|
|
534
|
+
function wellFormed(text: string): string {
|
|
535
|
+
return text.isWellFormed() ? text : text.toWellFormed()
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** `headChars` code units, extended by one when the cut would split a surrogate pair. */
|
|
539
|
+
function headOf(text: string, headChars: number): string {
|
|
540
|
+
if (headChars <= 0) return ""
|
|
541
|
+
if (headChars >= text.length) return text
|
|
542
|
+
const last = text.charCodeAt(headChars - 1)
|
|
543
|
+
const end = last >= 0xd800 && last <= 0xdbff ? headChars + 1 : headChars
|
|
544
|
+
return text.slice(0, end)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function cappedStub(head: string, text: string, file: string): string {
|
|
548
|
+
return `${head}\n\n[... ${text.length} chars total; showing first ${head.length}; full output: ${file}]\n`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Reject a symlink or an NTFS junction planted at a path the guard writes through. */
|
|
552
|
+
async function assertNotLink(target: string, what: string): Promise<void> {
|
|
553
|
+
const entry = await lstat(target)
|
|
554
|
+
if (entry.isSymbolicLink()) {
|
|
555
|
+
throw new Error(`refusing to write a capped tool output: ${what} ${target} is a symlink/junction`)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Create the per-process private root: a fresh directory under `os.tmpdir()`
|
|
561
|
+
* with a random name (`mkdtemp` appends its own suffix, mode 0700 on POSIX).
|
|
562
|
+
* A pre-created `opencode-tool-output` directory or junction is never looked at.
|
|
563
|
+
*/
|
|
564
|
+
async function createPrivateOutputRoot(): Promise<string> {
|
|
565
|
+
return await mkdtemp(path.join(tmpdir(), OUTPUT_ROOT_PREFIX))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* POSIX: the private root must belong to this user and be closed to group and
|
|
570
|
+
* other. A root that fails the check is never used — the caller disables the
|
|
571
|
+
* tool-output guard instead of writing through a shared directory. Windows has
|
|
572
|
+
* no POSIX ownership/mode bits here, so the check is skipped there.
|
|
573
|
+
*/
|
|
574
|
+
async function assertPrivateRoot(root: string): Promise<void> {
|
|
575
|
+
if (process.platform === "win32") return
|
|
576
|
+
const entry = await stat(root)
|
|
577
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
|
|
578
|
+
if (uid !== undefined && entry.uid !== uid) {
|
|
579
|
+
throw new Error(`output root ${root} is owned by uid ${entry.uid}, expected ${uid}`)
|
|
580
|
+
}
|
|
581
|
+
if ((entry.mode & 0o077) !== 0) {
|
|
582
|
+
throw new Error(`output root ${root} mode ${(entry.mode & 0o777).toString(8)} grants group/other access`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Reuse the session directory from the cache or create one random 0700 directory
|
|
588
|
+
* inside the root. The directory is `lstat`ed (a symlink/junction is refused) and
|
|
589
|
+
* its `realpath` must stay inside the real root. The returned path is the written
|
|
590
|
+
* one. Fail-open at the call site: a throw leaves the tool result untouched.
|
|
591
|
+
*/
|
|
592
|
+
async function sessionOutputDirectory(
|
|
593
|
+
root: string,
|
|
594
|
+
sessionID: string,
|
|
595
|
+
cache: Map<string, string>,
|
|
596
|
+
runtime: SessionBudgetRuntime,
|
|
597
|
+
): Promise<string> {
|
|
598
|
+
const cached = cache.get(sessionID)
|
|
599
|
+
if (cached !== undefined) return cached
|
|
600
|
+
const directory = path.join(root, `s-${randomUUID()}`)
|
|
601
|
+
await mkdir(directory, { mode: 0o700 })
|
|
602
|
+
await runtime.afterSessionDirectoryCreated?.(directory)
|
|
603
|
+
await assertNotLink(directory, "session directory")
|
|
604
|
+
const realDirectory = await realpath(directory)
|
|
605
|
+
if (!isInside(root, realDirectory)) {
|
|
606
|
+
throw new Error(`refusing to write a capped tool output outside ${root} (got ${realDirectory})`)
|
|
607
|
+
}
|
|
608
|
+
cache.set(sessionID, realDirectory)
|
|
609
|
+
return realDirectory
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Best effort: a leftover temp file must never break the tool result. */
|
|
613
|
+
async function removeFileQuietly(file: string): Promise<void> {
|
|
614
|
+
try {
|
|
615
|
+
await rm(file, { force: true })
|
|
616
|
+
} catch {
|
|
617
|
+
// best effort: nothing else to do with an orphaned temp file
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Write the full output under a random `c-<uuid>.txt` name with `wx` and mode
|
|
623
|
+
* 0600. A name collision is practically impossible; one retry with a fresh random
|
|
624
|
+
* name is kept as a fallback.
|
|
625
|
+
*/
|
|
626
|
+
async function writeCappedOutput(directory: string, text: string): Promise<string> {
|
|
627
|
+
const attempt = async (): Promise<string> => {
|
|
628
|
+
const target = path.join(directory, `c-${randomUUID()}.txt`)
|
|
629
|
+
await writeFile(target, text, { encoding: "utf8", mode: 0o600, flag: "wx" })
|
|
630
|
+
return target
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
return await attempt()
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
636
|
+
}
|
|
637
|
+
return await attempt()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Median of a small sample window; `0` for an empty window. */
|
|
641
|
+
function median(values: readonly number[]): number {
|
|
642
|
+
if (values.length === 0) return 0
|
|
643
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
644
|
+
const middle = Math.floor(sorted.length / 2)
|
|
645
|
+
return sorted.length % 2 === 0 ? Math.round((sorted[middle - 1]! + sorted[middle]!) / 2) : sorted[middle]!
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
interface SessionDurableEnvelopeLike {
|
|
649
|
+
readonly aggregateID?: unknown
|
|
650
|
+
readonly seq?: unknown
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
interface SessionEventLike {
|
|
654
|
+
readonly id?: unknown
|
|
655
|
+
readonly type?: unknown
|
|
656
|
+
readonly data?: { readonly sessionID?: unknown }
|
|
657
|
+
readonly durable?: SessionDurableEnvelopeLike
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** How many compaction event keys one session remembers for deduplication. */
|
|
661
|
+
const SEEN_COMPACTION_EVENTS = 256
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Stable identity of a compaction event: the event id when the host sends one,
|
|
665
|
+
* otherwise the durable log position. Without either the event cannot be told
|
|
666
|
+
* apart from a repeat, so it is processed (fail-open in the direction of counting).
|
|
667
|
+
*/
|
|
668
|
+
function compactionEventKey(event: SessionEventLike): string | undefined {
|
|
669
|
+
if (typeof event.id === "string" && event.id !== "") return `id:${event.id}`
|
|
670
|
+
const durable = event.durable
|
|
671
|
+
if (
|
|
672
|
+
durable !== undefined &&
|
|
673
|
+
typeof durable.aggregateID === "string" &&
|
|
674
|
+
durable.aggregateID !== "" &&
|
|
675
|
+
(typeof durable.seq === "number" || typeof durable.seq === "string")
|
|
676
|
+
) {
|
|
677
|
+
return `durable:${durable.aggregateID}:${String(durable.seq)}`
|
|
678
|
+
}
|
|
679
|
+
return undefined
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Remember one event key; `false` means it was already processed. */
|
|
683
|
+
function firstSighting(state: MutableCounters, key: string): boolean {
|
|
684
|
+
if (state.seenCompactionEvents.has(key)) return false
|
|
685
|
+
state.seenCompactionEvents.add(key)
|
|
686
|
+
if (state.seenCompactionEvents.size > SEEN_COMPACTION_EVENTS) {
|
|
687
|
+
const oldest = state.seenCompactionEvents.values().next().value
|
|
688
|
+
if (oldest !== undefined) state.seenCompactionEvents.delete(oldest)
|
|
689
|
+
}
|
|
690
|
+
return true
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
type EventSubscribe = (options: { signal: AbortSignal }) => AsyncIterable<unknown>
|
|
694
|
+
|
|
695
|
+
/** `ctx.event.subscribe` is optional at runtime even though the type declares it. */
|
|
696
|
+
function eventSubscriber(ctx: Plugin.Context): EventSubscribe | undefined {
|
|
697
|
+
const event = (ctx as { event?: unknown }).event
|
|
698
|
+
if (typeof event !== "object" || event === null) return undefined
|
|
699
|
+
const subscribe = (event as Record<string, unknown>).subscribe
|
|
700
|
+
return typeof subscribe === "function" ? (subscribe.bind(event) as EventSubscribe) : undefined
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The host contract says `event.subscribe` returns an `AsyncIterable`. A version
|
|
705
|
+
* skew can return `undefined`, a `Promise`, a plain object or a factory that
|
|
706
|
+
* yields garbage; the guard treats all of that as a dead stream (hook fallback)
|
|
707
|
+
* instead of throwing during setup.
|
|
708
|
+
*
|
|
709
|
+
* Exported for the test seam: this is the first of the two independent lines that
|
|
710
|
+
* keep a broken host from leaving the guard lying (the second is the `.catch`
|
|
711
|
+
* barrier on the subscription task). A single mutation of either line still
|
|
712
|
+
* degrades correctly, so the suite pins each line on its own here and in
|
|
713
|
+
* `closeIteratorQuietly`.
|
|
714
|
+
*/
|
|
715
|
+
export function asyncIteratorOf(stream: unknown): AsyncIterator<unknown> {
|
|
716
|
+
const factory = (stream as { [Symbol.asyncIterator]?: unknown } | null | undefined)?.[Symbol.asyncIterator]
|
|
717
|
+
if (typeof factory !== "function") {
|
|
718
|
+
throw new TypeError("event.subscribe did not return an async iterable")
|
|
719
|
+
}
|
|
720
|
+
const iterator = (factory as () => unknown).call(stream)
|
|
721
|
+
if (typeof iterator !== "object" || iterator === null) {
|
|
722
|
+
throw new TypeError("event.subscribe returned an async iterable without an iterator")
|
|
723
|
+
}
|
|
724
|
+
return iterator as AsyncIterator<unknown>
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Best effort: close a host iterator so its own resources (socket, timer) are
|
|
729
|
+
* released. The whole body is guarded: a hostile host can expose a throwing
|
|
730
|
+
* `return` getter, and this helper runs inside the subscription task's `finally`,
|
|
731
|
+
* which must stay total.
|
|
732
|
+
*
|
|
733
|
+
* Exported for the test seam (see `asyncIteratorOf`).
|
|
734
|
+
*/
|
|
735
|
+
export function closeIteratorQuietly(iterator: AsyncIterator<unknown> | null | undefined): void {
|
|
736
|
+
try {
|
|
737
|
+
const close = iterator?.return
|
|
738
|
+
if (typeof close !== "function") return
|
|
739
|
+
void Promise.resolve(close.call(iterator)).catch(() => {})
|
|
740
|
+
} catch {
|
|
741
|
+
// best effort: a failing close must not affect the guard state
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Register the hooks. Every host gets the `session.compaction` hook (attempts and
|
|
747
|
+
* a tentative note that names no count and no limit); on a host with an event
|
|
748
|
+
* stream `session.compaction.ended` confirms a compaction and
|
|
749
|
+
* `session.compaction.failed` drops the tentative note. If the stream ends or
|
|
750
|
+
* throws, `compactionSource` flips to `hook` and `guard` exposes `eventLive: false`
|
|
751
|
+
* plus `streamFailures: 1`, so a dead stream is visible instead of silently
|
|
752
|
+
* freezing the count. The tool-output guard writes into one private root per
|
|
753
|
+
* plugin instance (`mkdtemp(os.tmpdir()/opencode-tool-output-)`), never into a
|
|
754
|
+
* fixed, guessable name; a root that cannot be created or is not private (POSIX
|
|
755
|
+
* owner/mode) disables that guard with a warning instead of failing calls. The
|
|
756
|
+
* returned `stats` reports one session's counters; `cleanup` aborts the
|
|
757
|
+
* subscription, disposes every registration (safe to call twice) and removes the
|
|
758
|
+
* private root best-effort. A host without the V2 hook domains degrades to
|
|
759
|
+
* counters only: the guard is a policy layer, it must never be the reason a
|
|
760
|
+
* plugin fails to load.
|
|
761
|
+
*/
|
|
762
|
+
export async function createSessionBudget(
|
|
763
|
+
ctx: Plugin.Context,
|
|
764
|
+
config: SessionBudgetConfig = DEFAULT_CONFIG,
|
|
765
|
+
locale: Locale = "en",
|
|
766
|
+
warn: (message: string) => void = () => {},
|
|
767
|
+
runtime: SessionBudgetRuntime = DEFAULT_RUNTIME,
|
|
768
|
+
): Promise<SessionBudget> {
|
|
769
|
+
const sessionHook = ctx.session?.hook
|
|
770
|
+
const toolHook = ctx.tool?.hook
|
|
771
|
+
if (typeof sessionHook !== "function" || typeof toolHook !== "function") {
|
|
772
|
+
warn("session guard disabled: this OpenCode build exposes no session/tool hook API")
|
|
773
|
+
return {
|
|
774
|
+
available: false,
|
|
775
|
+
config,
|
|
776
|
+
cleanup: async () => {},
|
|
777
|
+
stats: (sessionID) => emptyCounters(sessionID, "hook"),
|
|
778
|
+
tracked: () => false,
|
|
779
|
+
guard: () => ({
|
|
780
|
+
available: false,
|
|
781
|
+
hooksRegistered: false,
|
|
782
|
+
sessionEnabled: config.session.enabled,
|
|
783
|
+
// No hooks, no private root: capping cannot fire here at all.
|
|
784
|
+
toolOutputEnabled: false,
|
|
785
|
+
toolOutputTools: config.toolOutput.tools,
|
|
786
|
+
compactionSource: "hook",
|
|
787
|
+
eventLive: false,
|
|
788
|
+
streamFailures: 0,
|
|
789
|
+
unkeyedCalls: 0,
|
|
790
|
+
}),
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const workspace = (ctx as { location?: { directory?: unknown } }).location?.directory
|
|
795
|
+
if (typeof workspace === "string" && workspace !== "") {
|
|
796
|
+
const temp = path.resolve(tmpdir())
|
|
797
|
+
if (isInside(path.resolve(workspace), temp)) {
|
|
798
|
+
warn(
|
|
799
|
+
`session guard: os.tmpdir() (${temp}) is inside the workspace (${path.resolve(workspace)}); ` +
|
|
800
|
+
"capped tool outputs will be written there, the private root is removed when the plugin unloads",
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// One private root per plugin instance, created eagerly: a temp directory that
|
|
806
|
+
// cannot be created or is not private (POSIX owner/mode) disables the
|
|
807
|
+
// tool-output guard once, with a warning, instead of failing every capped call.
|
|
808
|
+
// A failure never breaks the session: the tool result is left untouched.
|
|
809
|
+
let outputRoot: string | undefined
|
|
810
|
+
if (config.toolOutput.enabled) {
|
|
811
|
+
try {
|
|
812
|
+
const created = await (runtime.createOutputRoot ?? createPrivateOutputRoot)()
|
|
813
|
+
await assertPrivateRoot(created)
|
|
814
|
+
outputRoot = await realpath(created)
|
|
815
|
+
} catch (error) {
|
|
816
|
+
warn(
|
|
817
|
+
`tool output guard disabled: no private temp root (${errorMessage(error)}); ` +
|
|
818
|
+
"tool results are left untouched",
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// Capping needs both a private root and at least one tool to cap: a root with an
|
|
823
|
+
// empty effective list would report an enabled guard that can never fire.
|
|
824
|
+
const outputGuardEnabled = outputRoot !== undefined && config.toolOutput.tools.length > 0
|
|
825
|
+
|
|
826
|
+
const states = new Map<string, MutableCounters>()
|
|
827
|
+
/** `sessionID → realpath` of its private directory, evicted with `session.deleted`. */
|
|
828
|
+
const sessionDirectories = new Map<string, string>()
|
|
829
|
+
/** Ids whose `session.deleted` was observed: late events/hooks must not resurrect them. */
|
|
830
|
+
const deletedSessions = new Set<string>()
|
|
831
|
+
let compactionSource: CompactionSource = "hook"
|
|
832
|
+
let eventLive = false
|
|
833
|
+
let streamFailures = 0
|
|
834
|
+
/** Hook calls whose `sessionID` was missing or empty: dropped, never bucketed under `""`. */
|
|
835
|
+
let unkeyedCalls = 0
|
|
836
|
+
const stateFor = (sessionID: string): MutableCounters => {
|
|
837
|
+
let state = states.get(sessionID)
|
|
838
|
+
if (state === undefined) {
|
|
839
|
+
state = {
|
|
840
|
+
sessionID,
|
|
841
|
+
requests: 0,
|
|
842
|
+
promptTokensLast: 0,
|
|
843
|
+
promptTokensMax: 0,
|
|
844
|
+
samples: [],
|
|
845
|
+
band: 0,
|
|
846
|
+
compactions: 0,
|
|
847
|
+
compactionAttempts: 0,
|
|
848
|
+
compactionFailures: 0,
|
|
849
|
+
pendingConfirmed: 0,
|
|
850
|
+
tentative: false,
|
|
851
|
+
reminders: 0,
|
|
852
|
+
toolCalls: 0,
|
|
853
|
+
outputsCapped: 0,
|
|
854
|
+
outputCharsSaved: 0,
|
|
855
|
+
errors: 0,
|
|
856
|
+
// Null-prototype maps: a tool literally named `constructor` or `__proto__`
|
|
857
|
+
// must not collide with `Object.prototype`.
|
|
858
|
+
toolCallsByTool: Object.create(null) as Record<string, number>,
|
|
859
|
+
toolOutputsCappedByTool: Object.create(null) as Record<string, number>,
|
|
860
|
+
seenCompactionEvents: new Set<string>(),
|
|
861
|
+
}
|
|
862
|
+
states.set(sessionID, state)
|
|
863
|
+
}
|
|
864
|
+
return state
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const registrations: { dispose: () => Promise<void> | void }[] = []
|
|
868
|
+
const compactionSubscription = new AbortController()
|
|
869
|
+
let available = true
|
|
870
|
+
let hooksRegistered = false
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
const subscribe = eventSubscriber(ctx)
|
|
874
|
+
if (subscribe !== undefined) {
|
|
875
|
+
try {
|
|
876
|
+
const stream = subscribe({ signal: compactionSubscription.signal })
|
|
877
|
+
compactionSource = "event"
|
|
878
|
+
eventLive = true
|
|
879
|
+
void (async () => {
|
|
880
|
+
// Race every read against the abort signal: a host that ignores the
|
|
881
|
+
// signal (or a `next()` that never settles) must not keep this loop and
|
|
882
|
+
// its closures alive after `cleanup()`.
|
|
883
|
+
const aborted = new Promise<"aborted">((resolve) => {
|
|
884
|
+
const signal = compactionSubscription.signal
|
|
885
|
+
if (signal.aborted) {
|
|
886
|
+
resolve("aborted")
|
|
887
|
+
return
|
|
888
|
+
}
|
|
889
|
+
signal.addEventListener("abort", () => resolve("aborted"), { once: true })
|
|
890
|
+
})
|
|
891
|
+
// Two independent lines keep a broken host from leaving the guard lying:
|
|
892
|
+
// the iterator is built inside this try, and the task carries a `.catch`
|
|
893
|
+
// barrier (below). A single mutation of either line still degrades
|
|
894
|
+
// correctly on purpose; each line has its own witness in the unit tests.
|
|
895
|
+
let iterator: AsyncIterator<unknown> | undefined
|
|
896
|
+
try {
|
|
897
|
+
iterator = asyncIteratorOf(stream)
|
|
898
|
+
while (true) {
|
|
899
|
+
const step = await Promise.race([iterator.next(), aborted])
|
|
900
|
+
if (step === "aborted") break
|
|
901
|
+
if (step.done === true) break
|
|
902
|
+
const record = step.value as SessionEventLike | null | undefined
|
|
903
|
+
if (record === null || record === undefined) continue
|
|
904
|
+
const sessionID = record.data?.sessionID
|
|
905
|
+
if (typeof sessionID !== "string" || sessionID === "") continue
|
|
906
|
+
const type = record.type
|
|
907
|
+
// `session.deleted` forgets the session and plants a tombstone: counters
|
|
908
|
+
// and the dedup window go away, so a reused id starts clean, and late
|
|
909
|
+
// events for the deleted id are ignored instead of resurrecting it.
|
|
910
|
+
// `session.created` is the only event allowed to lift the tombstone.
|
|
911
|
+
if (type === "session.deleted") {
|
|
912
|
+
states.delete(sessionID)
|
|
913
|
+
sessionDirectories.delete(sessionID)
|
|
914
|
+
deletedSessions.add(sessionID)
|
|
915
|
+
continue
|
|
916
|
+
}
|
|
917
|
+
if (deletedSessions.has(sessionID)) {
|
|
918
|
+
if (type !== "session.created") continue
|
|
919
|
+
deletedSessions.delete(sessionID)
|
|
920
|
+
}
|
|
921
|
+
const state = stateFor(sessionID)
|
|
922
|
+
try {
|
|
923
|
+
if (type === "session.compaction.ended") {
|
|
924
|
+
const key = compactionEventKey(record)
|
|
925
|
+
if (key !== undefined && !firstSighting(state, key)) continue
|
|
926
|
+
state.compactions += 1
|
|
927
|
+
state.pendingConfirmed = state.compactions
|
|
928
|
+
state.tentative = false
|
|
929
|
+
} else if (type === "session.compaction.failed") {
|
|
930
|
+
const key = compactionEventKey(record)
|
|
931
|
+
if (key !== undefined && !firstSighting(state, key)) continue
|
|
932
|
+
state.compactionFailures += 1
|
|
933
|
+
// A failed attempt produces no lossy summary: drop only the tentative
|
|
934
|
+
// slot; a confirmed compaction still waiting for delivery is kept.
|
|
935
|
+
state.tentative = false
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
state.errors += 1
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} catch {
|
|
942
|
+
// Subscription ended or the server went away; the finally block flips
|
|
943
|
+
// the source to the hook, which is registered either way.
|
|
944
|
+
} finally {
|
|
945
|
+
// Best effort, whatever the exit path (done or abort): the host may hold
|
|
946
|
+
// a socket, a timer or an unsubscribe callback in `return()`.
|
|
947
|
+
closeIteratorQuietly(iterator)
|
|
948
|
+
eventLive = false
|
|
949
|
+
compactionSource = "hook"
|
|
950
|
+
if (!compactionSubscription.signal.aborted) streamFailures = 1
|
|
951
|
+
}
|
|
952
|
+
})().catch(() => {
|
|
953
|
+
// Last barrier: no path of this task may surface as an unhandled
|
|
954
|
+
// rejection, and a task that died must never leave the guard lying.
|
|
955
|
+
eventLive = false
|
|
956
|
+
compactionSource = "hook"
|
|
957
|
+
if (!compactionSubscription.signal.aborted) streamFailures = 1
|
|
958
|
+
})
|
|
959
|
+
} catch {
|
|
960
|
+
compactionSource = "hook"
|
|
961
|
+
eventLive = false
|
|
962
|
+
streamFailures = 1
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
registrations.push(
|
|
967
|
+
await ctx.session.hook("context", (input) => {
|
|
968
|
+
const payload = input as { sessionID?: unknown } | null | undefined
|
|
969
|
+
if (payload === null || payload === undefined) return
|
|
970
|
+
const sessionID = payload.sessionID
|
|
971
|
+
if (typeof sessionID !== "string" || sessionID === "") {
|
|
972
|
+
unkeyedCalls += 1
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
if (deletedSessions.has(sessionID)) return
|
|
976
|
+
let state: MutableCounters | undefined
|
|
977
|
+
try {
|
|
978
|
+
state = stateFor(sessionID)
|
|
979
|
+
state.requests += 1
|
|
980
|
+
const tokens = estimateTokens(input.system) + estimateTokens(input.messages)
|
|
981
|
+
state.promptTokensLast = tokens
|
|
982
|
+
if (tokens > state.promptTokensMax) state.promptTokensMax = tokens
|
|
983
|
+
state.samples.push(tokens)
|
|
984
|
+
if (state.samples.length > 256) state.samples.shift()
|
|
985
|
+
if (!config.session.enabled) return
|
|
986
|
+
|
|
987
|
+
const parts: string[] = []
|
|
988
|
+
const band = Math.floor((tokens - 1) / config.session.promptTokensSoft)
|
|
989
|
+
const level = band > state.band ? band : state.band
|
|
990
|
+
if (band > state.band) {
|
|
991
|
+
parts.push(reminderText(locale, band, config))
|
|
992
|
+
}
|
|
993
|
+
// Confirmed note first: only `session.compaction.ended` raises
|
|
994
|
+
// `pendingConfirmed`, so the number and the limit are facts even if the
|
|
995
|
+
// event stream died after the event arrived. Otherwise an attempt note
|
|
996
|
+
// goes out without a number and without a limit claim.
|
|
997
|
+
const confirmedNote = state.pendingConfirmed > 0
|
|
998
|
+
const tentativeNote = !confirmedNote && state.tentative
|
|
999
|
+
if (confirmedNote) {
|
|
1000
|
+
parts.push(compactionText(locale, state.pendingConfirmed, config))
|
|
1001
|
+
} else if (tentativeNote) {
|
|
1002
|
+
parts.push(tentativeCompactionText(locale))
|
|
1003
|
+
}
|
|
1004
|
+
if (parts.length === 0) return
|
|
1005
|
+
input.system.push(...parts.map((text) => ({ type: "text" as const, text })))
|
|
1006
|
+
// Marks move only after the reminder was actually delivered to the
|
|
1007
|
+
// request: a host whose `system` array rejects the push (frozen/foreign
|
|
1008
|
+
// array) must not lose the note for this level forever.
|
|
1009
|
+
state.band = level
|
|
1010
|
+
if (confirmedNote) state.pendingConfirmed = 0
|
|
1011
|
+
if (confirmedNote || tentativeNote) state.tentative = false
|
|
1012
|
+
state.reminders += parts.length
|
|
1013
|
+
} catch {
|
|
1014
|
+
if (state !== undefined) state.errors += 1
|
|
1015
|
+
}
|
|
1016
|
+
}),
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
// `session.compaction` is registered on every host: the hook sees every attempt,
|
|
1020
|
+
// so the counters keep moving even when the event stream is absent or dies. The
|
|
1021
|
+
// hook only records a tentative attempt — `compactions` grows on
|
|
1022
|
+
// `session.compaction.ended` (event hosts), and only that count raises the limit.
|
|
1023
|
+
registrations.push(
|
|
1024
|
+
await ctx.session.hook("compaction", (input) => {
|
|
1025
|
+
const payload = input as { sessionID?: unknown } | null | undefined
|
|
1026
|
+
if (payload === null || payload === undefined) return
|
|
1027
|
+
const sessionID = payload.sessionID
|
|
1028
|
+
if (typeof sessionID !== "string" || sessionID === "") {
|
|
1029
|
+
unkeyedCalls += 1
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
if (deletedSessions.has(sessionID)) return
|
|
1033
|
+
let state: MutableCounters | undefined
|
|
1034
|
+
try {
|
|
1035
|
+
state = stateFor(sessionID)
|
|
1036
|
+
state.compactionAttempts += 1
|
|
1037
|
+
// Tentative: the nearest primary after an attempt gets an attempt note even
|
|
1038
|
+
// when the terminal event is delayed. Never touches `compactions` or a
|
|
1039
|
+
// confirmed note that is still waiting for delivery.
|
|
1040
|
+
state.tentative = true
|
|
1041
|
+
} catch {
|
|
1042
|
+
if (state !== undefined) state.errors += 1
|
|
1043
|
+
}
|
|
1044
|
+
}),
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
registrations.push(
|
|
1048
|
+
await ctx.tool.hook("execute.after", async (input) => {
|
|
1049
|
+
const payload = input as { sessionID?: unknown } | null | undefined
|
|
1050
|
+
if (payload === null || payload === undefined) return
|
|
1051
|
+
const sessionID = payload.sessionID
|
|
1052
|
+
if (typeof sessionID !== "string" || sessionID === "") {
|
|
1053
|
+
unkeyedCalls += 1
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
if (deletedSessions.has(sessionID)) return
|
|
1057
|
+
let state: MutableCounters | undefined
|
|
1058
|
+
try {
|
|
1059
|
+
state = stateFor(sessionID)
|
|
1060
|
+
state.toolCalls += 1
|
|
1061
|
+
const name = normalizeToolName(String(input.tool))
|
|
1062
|
+
state.toolCallsByTool[name] = (state.toolCallsByTool[name] ?? 0) + 1
|
|
1063
|
+
state.toolOutputsCappedByTool[name] ??= 0
|
|
1064
|
+
if (input.status !== "completed") return
|
|
1065
|
+
if (!outputGuardEnabled || outputRoot === undefined) return
|
|
1066
|
+
// The kit's own tools are checked on the raw name too: a namespace-less
|
|
1067
|
+
// config entry (`session_stats`) must not turn one into a capped tool, and
|
|
1068
|
+
// the segment list is enforced here as well so a hand-built config cannot
|
|
1069
|
+
// skip the parser.
|
|
1070
|
+
if (
|
|
1071
|
+
isProtectedTool(String(input.tool).trim().toLowerCase()) ||
|
|
1072
|
+
isProtectedTool(name) ||
|
|
1073
|
+
KIT_TOOL_NAMES.has(name)
|
|
1074
|
+
) {
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
if (!config.toolOutput.tools.includes(name)) return
|
|
1078
|
+
|
|
1079
|
+
const result = input.result as {
|
|
1080
|
+
content?: string | ReadonlyArray<ToolContent>
|
|
1081
|
+
metadata?: Record<string, unknown>
|
|
1082
|
+
}
|
|
1083
|
+
// One normalized string for the file, the head and the counters: a lone
|
|
1084
|
+
// surrogate would otherwise be replaced by U+FFFD on disk only.
|
|
1085
|
+
const text = wellFormed(toolResultText(result.content))
|
|
1086
|
+
if (text.length <= config.toolOutput.maxChars) return
|
|
1087
|
+
|
|
1088
|
+
const head = headOf(text, config.toolOutput.headChars)
|
|
1089
|
+
const root = outputRoot
|
|
1090
|
+
const writeOnce = async (): Promise<string> => {
|
|
1091
|
+
const directory = await sessionOutputDirectory(root, sessionID, sessionDirectories, runtime)
|
|
1092
|
+
return await writeCappedOutput(directory, text)
|
|
1093
|
+
}
|
|
1094
|
+
let written: string
|
|
1095
|
+
try {
|
|
1096
|
+
written = await writeOnce()
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
1099
|
+
state.errors += 1
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
// The cached session directory (or the root itself) was removed outside
|
|
1103
|
+
// the plugin (`%TEMP%` cleanup): drop the stale path and retry once.
|
|
1104
|
+
sessionDirectories.delete(sessionID)
|
|
1105
|
+
try {
|
|
1106
|
+
written = await writeOnce()
|
|
1107
|
+
} catch {
|
|
1108
|
+
state.errors += 1
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const stub = cappedStub(head, text, written)
|
|
1113
|
+
// Capping must shrink the context: a stub at least as long as the text
|
|
1114
|
+
// would make the guard itself the problem, so the file is removed and the
|
|
1115
|
+
// result stays untouched.
|
|
1116
|
+
if (stub.length >= text.length) {
|
|
1117
|
+
await removeFileQuietly(written)
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
// Assign, then verify: a host may hand out a frozen/readonly result. An
|
|
1121
|
+
// unapplied stub leaves an orphaned file, so it is removed and the result
|
|
1122
|
+
// is left exactly as it came.
|
|
1123
|
+
const original = result.content
|
|
1124
|
+
const replacement = cappedContent(original as string | ReadonlyArray<ToolContent>, stub)
|
|
1125
|
+
try {
|
|
1126
|
+
result.content = replacement
|
|
1127
|
+
if (result.content !== replacement) throw new Error("tool result content is readonly")
|
|
1128
|
+
result.metadata = {
|
|
1129
|
+
...(result.metadata ?? {}),
|
|
1130
|
+
toolOutputGuard: { chars: text.length, path: written },
|
|
1131
|
+
}
|
|
1132
|
+
} catch {
|
|
1133
|
+
try {
|
|
1134
|
+
result.content = original
|
|
1135
|
+
} catch {
|
|
1136
|
+
// frozen object: there is nothing to restore
|
|
1137
|
+
}
|
|
1138
|
+
await removeFileQuietly(written)
|
|
1139
|
+
state.errors += 1
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
state.outputsCapped += 1
|
|
1143
|
+
state.toolOutputsCappedByTool[name] = (state.toolOutputsCappedByTool[name] ?? 0) + 1
|
|
1144
|
+
state.outputCharsSaved += Math.max(0, text.length - stub.length)
|
|
1145
|
+
} catch {
|
|
1146
|
+
if (state !== undefined) state.errors += 1
|
|
1147
|
+
}
|
|
1148
|
+
}),
|
|
1149
|
+
)
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
available = false
|
|
1152
|
+
hooksRegistered = false
|
|
1153
|
+
compactionSubscription.abort()
|
|
1154
|
+
warn(`session guard disabled: hook registration failed (${errorMessage(error)})`)
|
|
1155
|
+
for (const registration of registrations.reverse()) {
|
|
1156
|
+
try {
|
|
1157
|
+
await registration.dispose()
|
|
1158
|
+
} catch {
|
|
1159
|
+
// best effort: a failed disposal must not mask the original failure
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
registrations.length = 0
|
|
1163
|
+
}
|
|
1164
|
+
if (available) hooksRegistered = true
|
|
1165
|
+
|
|
1166
|
+
let disposed = false
|
|
1167
|
+
const cleanup: Plugin.Cleanup = async () => {
|
|
1168
|
+
if (disposed) return
|
|
1169
|
+
disposed = true
|
|
1170
|
+
// Terminal state first: a host that ignores the abort signal must not leave
|
|
1171
|
+
// the guard reporting a live subscription after unload.
|
|
1172
|
+
eventLive = false
|
|
1173
|
+
compactionSource = "hook"
|
|
1174
|
+
compactionSubscription.abort()
|
|
1175
|
+
for (const registration of registrations.reverse()) {
|
|
1176
|
+
try {
|
|
1177
|
+
await registration.dispose()
|
|
1178
|
+
} catch {
|
|
1179
|
+
// A failing disposal must not prevent the other hooks from being removed.
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
sessionDirectories.clear()
|
|
1183
|
+
if (outputRoot !== undefined) {
|
|
1184
|
+
try {
|
|
1185
|
+
await rm(outputRoot, { recursive: true, force: true })
|
|
1186
|
+
} catch {
|
|
1187
|
+
// best effort: a leftover temp root must not break plugin unload
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const guard = (): SessionBudgetGuard => ({
|
|
1193
|
+
available,
|
|
1194
|
+
hooksRegistered,
|
|
1195
|
+
sessionEnabled: config.session.enabled,
|
|
1196
|
+
toolOutputEnabled: outputGuardEnabled,
|
|
1197
|
+
toolOutputTools: config.toolOutput.tools,
|
|
1198
|
+
compactionSource,
|
|
1199
|
+
eventLive,
|
|
1200
|
+
streamFailures,
|
|
1201
|
+
unkeyedCalls,
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
available,
|
|
1206
|
+
config,
|
|
1207
|
+
cleanup,
|
|
1208
|
+
guard,
|
|
1209
|
+
tracked: (sessionID) => states.has(sessionID),
|
|
1210
|
+
stats: (sessionID: string): SessionBudgetCounters => {
|
|
1211
|
+
const state = states.get(sessionID)
|
|
1212
|
+
if (state === undefined) {
|
|
1213
|
+
return emptyCounters(sessionID, compactionSource)
|
|
1214
|
+
}
|
|
1215
|
+
return {
|
|
1216
|
+
sessionID: state.sessionID,
|
|
1217
|
+
requests: state.requests,
|
|
1218
|
+
promptTokensLast: state.promptTokensLast,
|
|
1219
|
+
promptTokensMax: state.promptTokensMax,
|
|
1220
|
+
promptTokensMedian: median(state.samples),
|
|
1221
|
+
band: state.band,
|
|
1222
|
+
compactions: state.compactions,
|
|
1223
|
+
compactionAttempts: state.compactionAttempts,
|
|
1224
|
+
compactionFailures: state.compactionFailures,
|
|
1225
|
+
compactionSource,
|
|
1226
|
+
reminders: state.reminders,
|
|
1227
|
+
toolCalls: state.toolCalls,
|
|
1228
|
+
outputsCapped: state.outputsCapped,
|
|
1229
|
+
outputCharsSaved: state.outputCharsSaved,
|
|
1230
|
+
errors: state.errors,
|
|
1231
|
+
toolCallsByTool: { ...state.toolCallsByTool },
|
|
1232
|
+
toolOutputsCappedByTool: { ...state.toolOutputsCappedByTool },
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
}
|
|
1236
|
+
}
|