@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/session.js CHANGED
@@ -35,15 +35,19 @@ import { assessTasks } from "./task-assessment.js";
35
35
  * verification surface. Override per call for custom evidence tools. */
36
36
  const DEFAULT_EVIDENCE_TOOLS = new Set(["shell"]);
37
37
  import { denialResult } from "@vincemakes/kiso-core";
38
- import { buildProfile, readProfile, writeProfile } from "./profile.js";
39
- import { resolveReasoning } from "./provider/metadata.js";
40
- import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, MANUAL_SUMMARY_BUDGET, SUMMARY_MAX_OUTPUT, summarizeConversation, summaryBoundarySeq, } from "./summarize.js";
38
+ import { buildProfile, readProfile, writeProfile, writeSummary } from "./profile.js";
39
+ import { summarizeEvents } from "./session-summary.js";
40
+ import { lookupModelMetadata, resolveReasoning } from "./provider/metadata.js";
41
+ import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, MANUAL_SUMMARY_BUDGET, SUMMARY_MAX_OUTPUT, summarizeConversation, SummaryBudgetExhausted, summaryBoundarySeq, } from "./summarize.js";
41
42
  import { canonicalizeUsageForModel } from "./usage/canonical.js";
43
+ import { contextAnchor } from "./context-anchor.js";
42
44
  import { appendFileSync, mkdirSync } from "node:fs";
43
45
  import { join } from "node:path";
44
46
  import { estimateTokens } from "@vincemakes/kiso-core";
45
47
  import { StaleWriterError } from "./store.js";
46
- import { composeHooks } from "./compose.js";
48
+ import { composeHooks, composeSystemPrompt, microcompactFor, runBasePrompt } from "./compose.js";
49
+ import { checkpointBoundarySeq } from "./checkpoint.js";
50
+ import { breakEvenFactor, guardedPruneSeq, KEEP_COMPACTABLE_RESULTS, microcompactBoundarySeq, phaseEnd, runsACheck, tierReason, tiersFor } from "./compaction-policy.js";
47
51
  import { Run } from "./run.js";
48
52
  // TUI2-R3v2 ③ — the side query rides the SAME tracer the runs ride; that
49
53
  // sameness is the whole point (one ledger, one shape, no second path).
@@ -120,6 +124,12 @@ export class AgentSession {
120
124
  // XP-1: a legacy session records revision 1 at the next explicit
121
125
  // selection or first request — never eagerly at open.
122
126
  #profilePending;
127
+ /** 0.40.0: the config profile name of the live binding (configuration:
128
+ * each revision records the name in force when it is written). */
129
+ #profileName;
130
+ /** 0.40.0: what this open's drift acknowledgement replaced — the CLI
131
+ * says so once. Null when the session opened without material drift. */
132
+ driftAcknowledgement;
123
133
  #pendingResolvers = new Map();
124
134
  #answered = new Set();
125
135
  /** round 4 (adversarial): verdicts the human GAVE, recorded when passed to a live
@@ -140,6 +150,18 @@ export class AgentSession {
140
150
  /** E6 (h): the circuit-breaker counter — consecutive auto-policy
141
151
  * summary failures this session (a success resets it). */
142
152
  #summaryFailures = 0;
153
+ /** 0.40.0: usage at or before this seq was billed under another model — never an anchor. */
154
+ #anchorFloorSeq = -1;
155
+ /** A1b: the window the tiers are drawn from — the live binding's, once a
156
+ * switch states one (CTX-1: the threshold travels with the model). */
157
+ #tiersWindow;
158
+ #lastUsageAt;
159
+ /** 0.40.0: when the provider last billed this session (epoch ms) — the
160
+ * record's time on load, the write's time after. A provider's prompt
161
+ * cache expires with time, so an old bill means a cold prefix. */
162
+ get lastUsageAt() {
163
+ return this.#lastUsageAt;
164
+ }
143
165
  /** Permanently invalidate the session after a rejected disk write (round 1). */
144
166
  poison(reason) {
145
167
  if (this.#poisoned === null)
@@ -150,7 +172,8 @@ export class AgentSession {
150
172
  throw new PoisonedSessionError(this.#poisoned);
151
173
  }
152
174
  #activeRuns = new Set();
153
- constructor(id, log, store, adapter, config) {
175
+ constructor(id, log, store, adapter, config, lastUsageAt) {
176
+ this.#lastUsageAt = lastUsageAt;
154
177
  this.id = id;
155
178
  this.log = log;
156
179
  this.#store = store;
@@ -174,6 +197,8 @@ export class AgentSession {
174
197
  // CTX-1: starts as the startup policy, then follows the binding.
175
198
  this.#microcompact = config.microcompact;
176
199
  this.#profilePending = config.profilePending === true;
200
+ this.#profileName = config.profileName ?? null;
201
+ this.driftAcknowledgement = config.driftAcknowledgement ?? null;
177
202
  }
178
203
  /** The config a NEW run/resume/summary sees: the frozen startup config
179
204
  * with the LIVE binding fields (model, provider) substituted. Built
@@ -200,6 +225,8 @@ export class AgentSession {
200
225
  this.ensureHealthy();
201
226
  try {
202
227
  await this.#store.append(this.id, runId, event);
228
+ if (event.type === "usage" && event.known)
229
+ this.#lastUsageAt = Date.now();
203
230
  }
204
231
  catch (err) {
205
232
  // round 4: ANY rejected write poisons — not only the typed
@@ -224,6 +251,145 @@ export class AgentSession {
224
251
  projected() {
225
252
  return projectMessages(this.log.all);
226
253
  }
254
+ /** 0.40.0: the context as the last bill measured it, plus what the log
255
+ * appended since — or undefined when no bill describes it any more
256
+ * (context-anchor.ts). `events` defaults to the session's log. */
257
+ contextAnchor(events = this.log.all) {
258
+ return contextAnchor(events, (u) => {
259
+ const c = canonicalizeUsageForModel(this.#model, this.#baseUrl, this.#provider ?? "adapter", u);
260
+ return c.input + c.cacheRead + (c.cacheWrite ?? 0) + c.output;
261
+ }, this.#anchorFloorSeq);
262
+ }
263
+ /** 0.40.0: what the context holds — the anchored figure, or the estimate
264
+ * over the projection when no bill describes it. The thresholds and the
265
+ * ctx row read this, never the bare estimate. */
266
+ contextUsed() {
267
+ return this.contextAnchor() ?? estimateTokens(this.projected());
268
+ }
269
+ /**
270
+ * ADR-0055 Amendment 1 (A1b) — the kernel's compaction point for ONE run,
271
+ * or undefined when nothing in-run is armed (the kernel then never asks).
272
+ *
273
+ * With `contextPolicy.tiers`: the §1a figure against the tiers — soft
274
+ * waits for a phase end (A3), hard and emergency fire now, an overflow
275
+ * fires once. The summary is in-band (A2) with ONE fallback to the
276
+ * serialised form; if both fail, an emergency or an overflow may prune
277
+ * once (A4b), and otherwise nothing happens and the run goes on.
278
+ *
279
+ * Without tiers, an EXPLICITLY configured `microcompact` keeps its old
280
+ * standing prune here, for SDK callers — the kernel no longer has one.
281
+ */
282
+ compactionPoint(run) {
283
+ const tiersPolicy = this.#config.contextPolicy?.tiers;
284
+ const legacy = tiersPolicy === undefined ? microcompactFor(this.#effectiveConfig(), this.log.all) : undefined;
285
+ if (tiersPolicy === undefined && legacy === undefined)
286
+ return undefined;
287
+ return async (events, messages, why) => {
288
+ const used = this.contextAnchor(events) ?? estimateTokens(messages);
289
+ if (tiersPolicy === undefined) {
290
+ if (legacy === undefined || used <= legacy.thresholdTokens)
291
+ return [];
292
+ const beforeSeq = microcompactBoundarySeq(events, legacy.keepResults ?? KEEP_COMPACTABLE_RESULTS);
293
+ return beforeSeq === undefined ? [] : [{ type: "microcompacted", beforeSeq }];
294
+ }
295
+ const window = this.#tiersWindow ?? tiersPolicy.windowTokens;
296
+ const maxOutput = this.#config.maxTokens ?? lookupModelMetadata(this.#model, this.#baseUrl)?.capabilities.maxOutputTokens ?? 0;
297
+ const t = tiersFor(window, Math.max(maxOutput, MANUAL_SUMMARY_BUDGET));
298
+ const isCheck = tiersPolicy.isCheck ?? ((command) => runsACheck(command));
299
+ const reason = tierReason(used, t, why, () => phaseEnd(events, lastSummaryPoint(events), isCheck));
300
+ if (reason === null)
301
+ return [];
302
+ const urgent = reason === "overflow" || reason === "emergency";
303
+ if (!urgent && this.#summaryFailures >= (tiersPolicy.maxFailures ?? MAX_SUMMARY_FAILURES))
304
+ return [];
305
+ const summarized = await this.#summarizeInRun(events, messages, t.tail, reason, run);
306
+ if (summarized !== null)
307
+ return [summarized];
308
+ if (!urgent)
309
+ return [];
310
+ const pricing = lookupModelMetadata(this.#model, this.#baseUrl)?.pricing ?? null;
311
+ const beforeSeq = guardedPruneSeq(events, breakEvenFactor(pricing?.inputPerM, pricing?.cacheReadPerM));
312
+ return beforeSeq === undefined ? [] : [{ type: "microcompacted", beforeSeq }];
313
+ };
314
+ }
315
+ /**
316
+ * ADR-0055 A2 — one checkpoint call, both paths: IN-BAND first (the
317
+ * messages as a run sends them, under the run's system prompt and tool
318
+ * table, the instruction appended — the prefix reads at the cache-hit
319
+ * price), and ONCE the serialised covered range when that reply is
320
+ * rejected (a tool call, markup, a missing section). An
321
+ * abort is never answered with the second call. Throws when both fail.
322
+ */
323
+ async #checkpointCall(args) {
324
+ const common = {
325
+ adapter: this.#adapter,
326
+ model: this.#model,
327
+ maxOutputTokens: args.budget,
328
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
329
+ ...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
330
+ onRetry: async (info) => {
331
+ await this.#config.hooks?.onRetry?.(info, {});
332
+ },
333
+ ...(args.onProgress !== undefined ? { onProgress: args.onProgress } : {}),
334
+ };
335
+ try {
336
+ const result = await summarizeConversation({ ...common, messages: args.messages, inBand: args.inBand, ...(args.reasoning !== undefined ? { reasoning: args.reasoning } : {}) });
337
+ return { result, path: "in-band" };
338
+ }
339
+ catch (err) {
340
+ // Only a REJECTED reply earns the second call: a transport failure
341
+ // already spent its retries, and an exhausted budget would exhaust
342
+ // the serialised call the same way.
343
+ if (args.signal?.aborted === true || !(err instanceof Error) || err instanceof SummaryBudgetExhausted)
344
+ throw err;
345
+ const result = await summarizeConversation({
346
+ ...common,
347
+ messages: [{ role: "user", content: args.serialized() }],
348
+ ...(args.serializedReasoning ? summaryReasoning(this.#model, this.#baseUrl) : {}),
349
+ });
350
+ return { result, path: "serialized" };
351
+ }
352
+ }
353
+ /** A2 — one in-run summary: in-band first, the serialised form once on
354
+ * a rejection; null when neither produced a valid checkpoint. The
355
+ * summary call's usage and path ride the trace ledger, as /compact's do. */
356
+ async #summarizeInRun(events, messages, tail, reason, run) {
357
+ const boundary = checkpointBoundarySeq(events, { keepTokens: tail });
358
+ if (boundary === undefined)
359
+ return null;
360
+ let path;
361
+ let result;
362
+ try {
363
+ ({ result, path } = await this.#checkpointCall({
364
+ messages,
365
+ inBand: { ...(run.systemPrompt !== undefined ? { systemPrompt: run.systemPrompt } : {}), tools: run.tools() },
366
+ ...(run.reasoning !== undefined ? { reasoning: run.reasoning } : {}),
367
+ serialized: () => serializeCovered({ events, prevPoint: lastSummaryPoint(events), boundary }),
368
+ serializedReasoning: true,
369
+ budget: MANUAL_SUMMARY_BUDGET,
370
+ ...(run.signal !== undefined ? { signal: run.signal } : {}),
371
+ }));
372
+ }
373
+ catch {
374
+ if (run.signal?.aborted !== true)
375
+ this.#summaryFailures += 1;
376
+ return null;
377
+ }
378
+ this.#summaryFailures = 0;
379
+ // Every in-run fire is recorded — its reason and path are what the
380
+ // measurement counts — and its usage when the provider reported one.
381
+ {
382
+ try {
383
+ const canonical = result.usage === null ? null : canonicalizeUsageForModel(this.#model, this.#baseUrl, this.#provider ?? "adapter", result.usage);
384
+ mkdirSync(join(this.#store.root, "traces"), { recursive: true, mode: 0o700 });
385
+ appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), `${JSON.stringify({ kind: "summary", canonical, reason, path })}\n`, { mode: 0o600 });
386
+ }
387
+ catch (err) {
388
+ console.error(`[kiso] summary usage ledger degraded (${err instanceof Error ? err.message : String(err)}); the summary call's cost is not recorded`);
389
+ }
390
+ }
391
+ return { type: "summarized", coversToSeq: boundary, summary: result.text };
392
+ }
227
393
  /**
228
394
  * merge round B (/model): replace the adapter for SUBSEQUENT runs. The
229
395
  * kernel reads the adapter through the loop-config closure at each
@@ -246,14 +412,22 @@ export class AgentSession {
246
412
  * metadata exists — the PH-1c registry.)
247
413
  */
248
414
  setModelBinding(binding) {
415
+ // 0.40.0: another model counts tokens differently — the last bill
416
+ // stops describing the context until the new model sends one.
417
+ if (binding.model !== this.#model || binding.provider !== this.#provider || binding.baseUrl !== this.#baseUrl) {
418
+ this.#anchorFloorSeq = this.log.all.at(-1)?.seq ?? -1;
419
+ }
249
420
  this.#adapter = binding.adapter;
250
421
  this.#model = binding.model;
251
422
  this.#provider = binding.provider;
252
423
  this.#baseUrl = binding.baseUrl;
253
424
  this.#continuationScope = binding.scope;
254
425
  this.#reasoning = binding.reasoning ?? { thinking: "default", effort: "default" };
426
+ this.#profileName = binding.profileName ?? null;
255
427
  if (binding.microcompact !== undefined)
256
428
  this.#microcompact = binding.microcompact;
429
+ if (binding.contextWindow !== undefined)
430
+ this.#tiersWindow = binding.contextWindow;
257
431
  // XP-1: an explicit selection is DURABLE — the setting survives
258
432
  // /resume because a revision records it now, not at some later flush.
259
433
  this.#recordProfile();
@@ -277,6 +451,17 @@ export class AgentSession {
277
451
  setMicrocompactThreshold(thresholdTokens) {
278
452
  this.#microcompact = { thresholdTokens };
279
453
  }
454
+ /** The raw tail a manual cut at a settled round keeps: the tiers' tail
455
+ * when a window is known, else the policy's keep floor. */
456
+ #manualTailTokens() {
457
+ const window = this.#tiersWindow ?? this.#config.contextPolicy?.tiers?.windowTokens;
458
+ return window === undefined ? KEEP_TOKENS_DEFAULT : tiersFor(window, MANUAL_SUMMARY_BUDGET).tail;
459
+ }
460
+ /** A1b: the window the in-run tiers are drawn from, for a restored or
461
+ * re-bound session (the CLI's one binding step calls both). */
462
+ setContextWindow(windowTokens) {
463
+ this.#tiersWindow = windowTokens;
464
+ }
280
465
  /** E2: the adapter identity (anthropic / openai-compat / openai-responses) — the route
281
466
  * key the canonical consumer (CLI usage, the trace block) keys on. The
282
467
  * per-run tracer reads the SAME live binding; one source, one
@@ -313,6 +498,29 @@ export class AgentSession {
313
498
  get reasoning() {
314
499
  return this.#reasoning;
315
500
  }
501
+ /**
502
+ * The 0.40.0 dogfood (item 2): the session list's row, written into the
503
+ * sidecar's `summary` tenant at a run's start (`open`) and at its end.
504
+ * Computed from the IN-MEMORY log — never a re-read of the file — and
505
+ * best-effort: a summary that cannot be written leaves the list saying
506
+ * less, and never fails the run. Observation only: nothing that decides
507
+ * recovery, projection or a request reads it.
508
+ */
509
+ recordSummary(open) {
510
+ try {
511
+ const prior = readProfile(this.#store.root, this.id);
512
+ writeSummary(this.#store.root, this.id, summarizeEvents(this.log.all, {
513
+ open,
514
+ updatedAt: Date.now(),
515
+ asks: open ? 0 : this.pendingApprovals().length,
516
+ workspaceUnknown: !(prior.kind === "ok" && prior.profile.workspace !== null),
517
+ source: "run",
518
+ }));
519
+ }
520
+ catch {
521
+ // observation only — the row says less; the run is untouched
522
+ }
523
+ }
316
524
  /** XP-1: record the live binding as the next durable profile revision
317
525
  * (read-modify-write under the session's single-writer ownership). */
318
526
  #recordProfile() {
@@ -322,6 +530,11 @@ export class AgentSession {
322
530
  revision,
323
531
  modelId: this.#model,
324
532
  provider: this.#continuationScope ?? null,
533
+ profileName: this.#profileName,
534
+ // 0.40.0: history is CARRIED from the prior revision, never
535
+ // re-derived from this process. With no prior (a legacy
536
+ // session's first revision) the start is unknown: null.
537
+ workspace: prior.kind === "ok" ? prior.profile.workspace : null,
325
538
  reasoning: this.#reasoning,
326
539
  ...(this.#config.systemPrompt !== undefined ? { systemPrompt: this.#config.systemPrompt } : {}),
327
540
  registry: this.#config.registry,
@@ -420,7 +633,7 @@ export class AgentSession {
420
633
  this.ensureHealthy();
421
634
  if (this.#profilePending)
422
635
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
423
- return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, input, options?.signal, false, options?.source);
636
+ return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, input, options?.signal, false, options?.source, options?.via);
424
637
  }
425
638
  /**
426
639
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -463,7 +676,15 @@ export class AgentSession {
463
676
  if (options.signal !== undefined && options.signal.aborted)
464
677
  throw cancelled();
465
678
  const events = this.log.all;
466
- const boundary = summaryBoundarySeq(events, keepRounds, options.keepTokens);
679
+ // ADR-0055 (the owner's dogfood): a long autonomous session has few
680
+ // user turns and many tool rounds, and the user-turn cut found nothing
681
+ // in it ("fewer than 5 rounds") while the context was hundreds of
682
+ // thousands of tokens. When there is no user-turn cut, the manual
683
+ // gesture cuts where the in-run tiers do: at a settled round, the raw
684
+ // tail kept by tokens (the tiers' `min(0.1·window, 100K)` when the
685
+ // window is known, else the policy floor).
686
+ const tailTokens = Math.max(options.keepTokens ?? 0, this.#manualTailTokens());
687
+ const boundary = summaryBoundarySeq(events, keepRounds, options.keepTokens) ?? (options.manualBudget === true ? checkpointBoundarySeq(events, { keepTokens: tailTokens }) : undefined);
467
688
  if (boundary === undefined)
468
689
  return null;
469
690
  const prevPoint = lastSummaryPoint(events);
@@ -488,6 +709,7 @@ export class AgentSession {
488
709
  });
489
710
  let summary;
490
711
  let usage = null;
712
+ let summaryPath = null;
491
713
  // OR-1 (the second review): the summary is priced by the binding that
492
714
  // MADE the request. A /model switch during this call — the session's
493
715
  // longest single request — moves #model/#baseUrl/#provider under it;
@@ -502,21 +724,31 @@ export class AgentSession {
502
724
  summary = DROP_PLACEHOLDER;
503
725
  }
504
726
  else {
505
- const call = await summarizeConversation({
506
- adapter: binding.adapter,
507
- model: binding.model,
508
- // E6 (a): ONE serialized user message — the DSML bug's
509
- // raw-message array is structurally dead on this path.
510
- messages: [{ role: "user", content: serializedInput }],
511
- // E6 (g): the summary call ALWAYS carries an explicit output
512
- // budget (a wire-level truncation is caught by the (b)
513
- // required-section validation, never silently passed).
514
- // 0.39.2: measured for the manual gesture, fixed for the policy.
515
- ...(options.manualBudget === true
516
- ? { maxOutputTokens: MANUAL_SUMMARY_BUDGET, ...summaryReasoning(binding.model, binding.baseUrl) }
517
- : { maxOutputTokens: SUMMARY_MAX_OUTPUT }),
727
+ // ADR-0055 A2 (the lead's ruling on the /compact gap): the checkpoint
728
+ // is asked IN-BAND on the prefix a run sends — the session's base
729
+ // prompt, the tool table, the extension appends, the registry's
730
+ // tools, the session's reasoning — so a /compact on a warm session
731
+ // reads its context at the cache-hit price; the serialised form is
732
+ // the one fallback. The budget is unchanged: measured for the
733
+ // manual gesture, fixed for the policy (0.39.2).
734
+ const cfg = this.#effectiveConfig();
735
+ const systemPrompt = composeSystemPrompt(runBasePrompt(cfg.systemPrompt, cfg.registry), cfg.extensions ?? []);
736
+ const wire = cfg.reasoning === undefined ? undefined : resolveReasoning(binding.model, cfg.reasoning, binding.baseUrl);
737
+ const { result: call, path } = await this.#checkpointCall({
738
+ messages: this.projected(),
739
+ inBand: {
740
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
741
+ tools: cfg.registry.snapshot().specs,
742
+ ...(options.focus !== undefined ? { focus: options.focus } : {}),
743
+ },
744
+ ...(wire !== undefined && wire.ok && Object.keys(wire.wire).length > 0 ? { reasoning: wire.wire } : {}),
745
+ serialized: () => serializedInput,
746
+ serializedReasoning: options.manualBudget === true,
747
+ budget: options.manualBudget === true ? MANUAL_SUMMARY_BUDGET : SUMMARY_MAX_OUTPUT,
518
748
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
749
+ ...(options.onProgress !== undefined ? { onProgress: options.onProgress } : {}),
519
750
  });
751
+ summaryPath = path;
520
752
  summary = call.text;
521
753
  usage = call.usage;
522
754
  }
@@ -541,7 +773,7 @@ export class AgentSession {
541
773
  if (usage !== null) {
542
774
  try {
543
775
  const canonical = canonicalizeUsageForModel(binding.model, binding.baseUrl, binding.provider ?? "adapter", usage);
544
- const line = JSON.stringify({ kind: "summary", canonical }) + "\n";
776
+ const line = JSON.stringify({ kind: "summary", canonical, ...(summaryPath !== null ? { path: summaryPath } : {}) }) + "\n";
545
777
  mkdirSync(join(this.#store.root, "traces"), { recursive: true, mode: 0o700 }); // DF-0322-F1
546
778
  appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), line, { mode: 0o600 });
547
779
  }
@@ -568,6 +800,10 @@ export class AgentSession {
568
800
  const policy = this.#config.contextPolicy;
569
801
  if (policy === undefined)
570
802
  return;
803
+ // A1b: with the in-run tiers armed, the kernel's compaction point
804
+ // decides before every request, the first one included.
805
+ if (policy.tiers !== undefined)
806
+ return;
571
807
  const mode = policy.drop ?? policy.summary;
572
808
  if (mode === undefined)
573
809
  return;
@@ -587,7 +823,7 @@ export class AgentSession {
587
823
  const maxFailures = mode.maxFailures ?? MAX_SUMMARY_FAILURES;
588
824
  if (this.#summaryFailures >= maxFailures)
589
825
  return;
590
- if (estimateTokens(this.projected()) <= triggerTokens)
826
+ if (this.contextUsed() <= triggerTokens)
591
827
  return;
592
828
  try {
593
829
  await this.summarize({
package/dist/store.d.ts CHANGED
@@ -111,6 +111,14 @@ export declare class SessionStore {
111
111
  */
112
112
  load(sessionId: string): StoreRecord[];
113
113
  has(sessionId: string): boolean;
114
+ /**
115
+ * 0.40.0 dogfood (the owner: /resume still waited seconds): the session
116
+ * ids, from the directory alone — no log is opened. `list()` reads every
117
+ * log whole to title it (one owner session is 83 MB); a caller that
118
+ * needs only the ids must never pay that. An empty log is skipped, as
119
+ * list() skips it.
120
+ */
121
+ ids(): string[];
114
122
  list(): SessionMeta[];
115
123
  /** Release a session's fd and OUR writer lock. Idempotent. */
116
124
  close(sessionId: string): void;
package/dist/store.js CHANGED
@@ -42,7 +42,7 @@
42
42
  * - load is strict (A group round 1): a partial final line is the only
43
43
  * tolerated damage; everything else throws StoreCorruptionError.
44
44
  */
45
- import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, } from "node:fs";
45
+ import { appendFileSync, closeSync, existsSync, fsyncSync, fstatSync, statSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, } from "node:fs";
46
46
  import { dirname, join } from "node:path";
47
47
  import { isKisoEvent } from "@vincemakes/kiso-core";
48
48
  import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
@@ -136,7 +136,9 @@ export function sessionTitle(records) {
136
136
  const asked = records
137
137
  .map((r) => r.event)
138
138
  .filter((e) => e.type === "user_input")
139
- .map((e) => contentText(e.content).trim())
139
+ // 0.40.0: a skill turn is named by what the person TYPED — its
140
+ // content is a SKILL.md body the person never wrote.
141
+ .map((e) => (typeof e.via?.line === "string" ? e.via.line : contentText(e.content)).trim())
140
142
  .filter((t) => t !== "");
141
143
  if (asked.length === 0)
142
144
  return "(no prompt)";
@@ -385,6 +387,29 @@ export class SessionStore {
385
387
  has(sessionId) {
386
388
  return existsSync(this.pathFor(sessionId));
387
389
  }
390
+ /**
391
+ * 0.40.0 dogfood (the owner: /resume still waited seconds): the session
392
+ * ids, from the directory alone — no log is opened. `list()` reads every
393
+ * log whole to title it (one owner session is 83 MB); a caller that
394
+ * needs only the ids must never pay that. An empty log is skipped, as
395
+ * list() skips it.
396
+ */
397
+ ids() {
398
+ const out = [];
399
+ for (const entry of readdirSync(this.root)) {
400
+ if (!entry.endsWith(".jsonl"))
401
+ continue;
402
+ try {
403
+ if (statSync(join(this.root, entry)).size === 0)
404
+ continue;
405
+ }
406
+ catch {
407
+ continue;
408
+ }
409
+ out.push(entry.slice(0, -".jsonl".length));
410
+ }
411
+ return out.sort();
412
+ }
388
413
  list() {
389
414
  const metas = [];
390
415
  for (const entry of readdirSync(this.root)) {
@@ -13,7 +13,7 @@
13
13
  * unchanged ("nothing happened"). Only the generated `summarized` event
14
14
  * lands on disk; the original events stay there forever.
15
15
  */
16
- import type { AbortSignalLike, Adapter } from "@vincemakes/kiso-core";
16
+ import type { AbortSignalLike, Adapter, RetryInfo, ToolSpec } from "@vincemakes/kiso-core";
17
17
  import type { Event } from "@vincemakes/kiso-core";
18
18
  import type { Message } from "@vincemakes/kiso-core";
19
19
  import type { RawUsage } from "./usage/canonical.js";
@@ -86,6 +86,14 @@ export declare const POLICY_RESERVE: number;
86
86
  * blocks. That belongs to A1b, and the measurement above is its input.
87
87
  */
88
88
  export declare const MANUAL_SUMMARY_BUDGET = 32000;
89
+ /** Thrown by an attempt that stopped on `max_tokens`, and it NAMES the
90
+ * budget: "not a complete turn" alone told the person nothing about what
91
+ * ran out. Still an `Error`, and still carries the `max_tokens … not a
92
+ * complete turn` wording the CX-1 F3 gate matches. */
93
+ export declare class SummaryBudgetExhausted extends Error {
94
+ readonly budget: number | undefined;
95
+ constructor(budget: number | undefined);
96
+ }
89
97
  /** The reference context-window scale (the flash-family window); the
90
98
  * env overrides. The default arming point is 120,000 − 32,000 =
91
99
  * 88,000 — a post-fire projection (≥ 24k) can never re-cross it, so
@@ -131,6 +139,12 @@ export declare function serializeCovered(options: SerializeCoveredOptions): stri
131
139
  * (the loop's system prompt is the harness's business, never the kernel's).
132
140
  */
133
141
  export declare const SUMMARY_PROMPT = "Only output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it in the model's context. The next turn must be able\nto continue the work without reading the originals.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user message in the covered range, enumerated one by one, each\nwith what it asked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
142
+ /**
143
+ * ADR-0055 A2 — the IN-BAND form: the run's own request with this appended
144
+ * as the last user message, so everything before it reads at the cache-hit
145
+ * price. The same sections, the same validation; the guard sentence first.
146
+ */
147
+ export declare const SUMMARY_IN_BAND = "Stop the task for this one reply and write a checkpoint of the conversation above instead.\n\nOnly output the summary. Do not continue the conversation. Do not use any tools.\n\nYou are the conversation summarizer of the kiso agent framework.\n\nSummarize the covered conversation into a single structured checkpoint\nthat will REPLACE it in the model's context. The next turn must be able\nto continue the work without reading the originals.\n\nProduce the checkpoint with exactly these sections, in this order:\n\n## Goal\nThe user's goal and the acceptance criterion, in one or two sentences.\n\n## Constraints\nThe constraints, requirements, and rulings the work must honor.\n\n## User requests\nEvery user message in the covered range, enumerated one by one, each\nwith what it asked for and what was done about it.\n\n## Files and changes\nEvery file touched \u2014 exact paths, what changed, and why. Include the\nprecise code-level changes later turns may need to continue.\n\n## Errors and fixes\nEvery error encountered and its resolution; commands run and their\noutcomes.\n\n## Current work\nThe current state of the work \u2014 what is done, what is not. Quote the\ncurrent task's criterion VERBATIM if one exists.\n\n## Next steps\nThe concrete next steps, in order.\n\nPreserve concrete identifiers VERBATIM: paths, function names, task ids,\nenvironment names \u2014 never paraphrase them.\n\nRules:\n- plain prose \u2014 no bullet lists, no markdown outside the section headers,\n no prefixes;\n- do not mention this prompt or the summarization task;\n- the summary may be as long as it needs to be within the output budget \u2014\n there is no word cap; completeness wins.";
134
148
  /**
135
149
  * E6 (b) — the output-side validation (the finding E6-F4/F5 follow-up):
136
150
  * a summary must be a complete checkpoint or NOTHING. The marker family
@@ -150,6 +164,15 @@ export declare function validateSummary(text: string): string | null;
150
164
  export interface SummarizeConversationOptions {
151
165
  readonly adapter: Adapter;
152
166
  readonly model: string;
167
+ /** ADR-0055 A2: send `messages` AS THE RUN SENT THEM, under the run's own
168
+ * system prompt and tool table, with SUMMARY_IN_BAND appended — the
169
+ * cache-hot form. Absent: `messages` is the serialized covered range
170
+ * under SUMMARY_PROMPT, the cold form. */
171
+ readonly inBand?: {
172
+ readonly systemPrompt?: string;
173
+ readonly tools: readonly ToolSpec[];
174
+ readonly focus?: string;
175
+ };
153
176
  /** The covered conversation — the ONLY material the summary is about. */
154
177
  readonly messages: readonly Message[];
155
178
  readonly signal?: AbortSignalLike;
@@ -162,6 +185,34 @@ export interface SummarizeConversationOptions {
162
185
  readonly thinking?: "adaptive" | "enabled" | "disabled";
163
186
  readonly effort?: string;
164
187
  };
188
+ /** ADR-0005 Amendment 2: the kernel's retry budget, applied to this
189
+ * off-loop call. Absent, the kernel's default. */
190
+ readonly maxRetries?: number;
191
+ /** ADR-0005 Amendment 2: announced before each wait, as the kernel
192
+ * announces its own. Observation only — a throw is swallowed. */
193
+ readonly onRetry?: (info: RetryInfo) => Promise<void> | void;
194
+ /** 0.40.0: how much of the OUTPUT budget the call has spent so far —
195
+ * reported at each attempt's start (zero) and as text, reasoning and
196
+ * the final usage arrive. Observation only: a throw is swallowed, and
197
+ * the request is identical with or without it. */
198
+ readonly onProgress?: (progress: SummaryProgress) => void;
199
+ }
200
+ /**
201
+ * 0.40.0 — the summary call's progress against its output budget.
202
+ *
203
+ * `produced` counts streamed text AND streamed reasoning (owner's ruling —
204
+ * a bar over text alone sits low while thinking spends the budget, and the
205
+ * call then fails at "full"), by the session's chars/4 proxy, until the
206
+ * provider reports its output total, which replaces the estimate
207
+ * (`reported`). `reasoningUnseen`: the provider billed reasoning that never
208
+ * streamed, so the jump to the reported total has a named cause.
209
+ */
210
+ export interface SummaryProgress {
211
+ readonly produced: number;
212
+ /** the call's output budget; null when the call carries none */
213
+ readonly budget: number | null;
214
+ readonly reasoningUnseen: boolean;
215
+ readonly reported: boolean;
165
216
  }
166
217
  /** The summary call's result — the text PLUS the provider-reported usage
167
218
  * (E6: the honest accounting — the summary call's cost rides the trace
@@ -172,7 +223,7 @@ export interface SummarizeConversationResult {
172
223
  readonly usage: RawUsage | null;
173
224
  }
174
225
  /**
175
- * The summary call, with ONE retry.
226
+ * The summary call, retried under the KERNEL'S policy.
176
227
  *
177
228
  * 0.39.1: `/compact` had no retry at all. The kernel owns retries
178
229
  * (ADR-0005) and this path does not go through the kernel — it calls the
@@ -182,11 +233,18 @@ export interface SummarizeConversationResult {
182
233
  * (the transport-failure-after-headers class); here is the only place
183
234
  * that can act on it.
184
235
  *
185
- * ONE retry, not a loop, and not the kernel's budget: a summary re-pays
186
- * its whole input, which on the sessions that need compacting is the
187
- * most expensive request the session makes. A permanent error still
188
- * throws on the first attempt — the retry is keyed on the classification
189
- * the adapter already made, never on the fact that something failed.
236
+ * 0.39.1 gave it ONE retry after 250 ms. ADR-0005 Amendment 2 gives it the
237
+ * kernel's rule instead, because a dropped summary stream is the same
238
+ * failure as a dropped turn and meets the same gateway: the same curve,
239
+ * the same budget (the same `maxRetries` knob), Retry-After as a floor and
240
+ * a wait beyond RETRY_AFTER_MAX_MS as an explicit stop — never a retry
241
+ * that comes early — and the same announcement before each wait. Each
242
+ * retry re-pays the summary's input, exactly as each turn retry re-pays
243
+ * the turn's; the budget that bounds one bounds the other.
244
+ *
245
+ * A permanent error still throws on the first attempt — the retry is
246
+ * keyed on the classification the adapter already made, never on the
247
+ * fact that something failed.
190
248
  */
191
249
  export declare function summarizeConversation(options: SummarizeConversationOptions): Promise<SummarizeConversationResult>;
192
250
  /** E6 — the crux-experiment drop arm: the covered turns are replaced by