@vincemakes/kiso-runtime 0.39.1 → 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,14 +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 { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, 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";
40
42
  import { canonicalizeUsageForModel } from "./usage/canonical.js";
43
+ import { contextAnchor } from "./context-anchor.js";
41
44
  import { appendFileSync, mkdirSync } from "node:fs";
42
45
  import { join } from "node:path";
43
46
  import { estimateTokens } from "@vincemakes/kiso-core";
44
47
  import { StaleWriterError } from "./store.js";
45
- 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";
46
51
  import { Run } from "./run.js";
47
52
  // TUI2-R3v2 ③ — the side query rides the SAME tracer the runs ride; that
48
53
  // sameness is the whole point (one ledger, one shape, no second path).
@@ -69,6 +74,24 @@ export class ResumeBlockedError extends Error {
69
74
  }
70
75
  }
71
76
  /** @deprecated the canonical name is `Session` (root export, 1.1.0); this alias is removed in the next major. */
77
+ /**
78
+ * 0.39.2 — thinking OFF for a manual summary, where it can be turned off.
79
+ *
80
+ * The summary call sent no reasoning at all, so it ran at the provider's
81
+ * default, and for a model whose thinking defaults ON the reasoning tokens
82
+ * came out of the same output budget as the checkpoint. Resolved through
83
+ * `resolveReasoning` — the one authority the `/model` path uses — so it
84
+ * only ever sends a NATIVE value for a model the registry knows. For a
85
+ * model it does not know (66 of the 73 profiles on the machine this was
86
+ * found on, the failing one among them) it sends nothing, exactly as
87
+ * before: a raw field to an unknown model can be a 400. That is why this
88
+ * is an optimisation and the scaled budget is the fix — the budget
89
+ * reaches every model, this reaches the ones we can vouch for.
90
+ */
91
+ function summaryReasoning(model, baseUrl) {
92
+ const r = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
93
+ return r.ok && r.wire.thinking !== undefined ? { reasoning: r.wire } : {};
94
+ }
72
95
  export class AgentSession {
73
96
  id;
74
97
  log;
@@ -101,6 +124,12 @@ export class AgentSession {
101
124
  // XP-1: a legacy session records revision 1 at the next explicit
102
125
  // selection or first request — never eagerly at open.
103
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;
104
133
  #pendingResolvers = new Map();
105
134
  #answered = new Set();
106
135
  /** round 4 (adversarial): verdicts the human GAVE, recorded when passed to a live
@@ -121,6 +150,18 @@ export class AgentSession {
121
150
  /** E6 (h): the circuit-breaker counter — consecutive auto-policy
122
151
  * summary failures this session (a success resets it). */
123
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
+ }
124
165
  /** Permanently invalidate the session after a rejected disk write (round 1). */
125
166
  poison(reason) {
126
167
  if (this.#poisoned === null)
@@ -131,7 +172,8 @@ export class AgentSession {
131
172
  throw new PoisonedSessionError(this.#poisoned);
132
173
  }
133
174
  #activeRuns = new Set();
134
- constructor(id, log, store, adapter, config) {
175
+ constructor(id, log, store, adapter, config, lastUsageAt) {
176
+ this.#lastUsageAt = lastUsageAt;
135
177
  this.id = id;
136
178
  this.log = log;
137
179
  this.#store = store;
@@ -155,6 +197,8 @@ export class AgentSession {
155
197
  // CTX-1: starts as the startup policy, then follows the binding.
156
198
  this.#microcompact = config.microcompact;
157
199
  this.#profilePending = config.profilePending === true;
200
+ this.#profileName = config.profileName ?? null;
201
+ this.driftAcknowledgement = config.driftAcknowledgement ?? null;
158
202
  }
159
203
  /** The config a NEW run/resume/summary sees: the frozen startup config
160
204
  * with the LIVE binding fields (model, provider) substituted. Built
@@ -181,6 +225,8 @@ export class AgentSession {
181
225
  this.ensureHealthy();
182
226
  try {
183
227
  await this.#store.append(this.id, runId, event);
228
+ if (event.type === "usage" && event.known)
229
+ this.#lastUsageAt = Date.now();
184
230
  }
185
231
  catch (err) {
186
232
  // round 4: ANY rejected write poisons — not only the typed
@@ -205,6 +251,145 @@ export class AgentSession {
205
251
  projected() {
206
252
  return projectMessages(this.log.all);
207
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
+ }
208
393
  /**
209
394
  * merge round B (/model): replace the adapter for SUBSEQUENT runs. The
210
395
  * kernel reads the adapter through the loop-config closure at each
@@ -227,14 +412,22 @@ export class AgentSession {
227
412
  * metadata exists — the PH-1c registry.)
228
413
  */
229
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
+ }
230
420
  this.#adapter = binding.adapter;
231
421
  this.#model = binding.model;
232
422
  this.#provider = binding.provider;
233
423
  this.#baseUrl = binding.baseUrl;
234
424
  this.#continuationScope = binding.scope;
235
425
  this.#reasoning = binding.reasoning ?? { thinking: "default", effort: "default" };
426
+ this.#profileName = binding.profileName ?? null;
236
427
  if (binding.microcompact !== undefined)
237
428
  this.#microcompact = binding.microcompact;
429
+ if (binding.contextWindow !== undefined)
430
+ this.#tiersWindow = binding.contextWindow;
238
431
  // XP-1: an explicit selection is DURABLE — the setting survives
239
432
  // /resume because a revision records it now, not at some later flush.
240
433
  this.#recordProfile();
@@ -258,6 +451,17 @@ export class AgentSession {
258
451
  setMicrocompactThreshold(thresholdTokens) {
259
452
  this.#microcompact = { thresholdTokens };
260
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
+ }
261
465
  /** E2: the adapter identity (anthropic / openai-compat / openai-responses) — the route
262
466
  * key the canonical consumer (CLI usage, the trace block) keys on. The
263
467
  * per-run tracer reads the SAME live binding; one source, one
@@ -294,6 +498,29 @@ export class AgentSession {
294
498
  get reasoning() {
295
499
  return this.#reasoning;
296
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
+ }
297
524
  /** XP-1: record the live binding as the next durable profile revision
298
525
  * (read-modify-write under the session's single-writer ownership). */
299
526
  #recordProfile() {
@@ -303,6 +530,11 @@ export class AgentSession {
303
530
  revision,
304
531
  modelId: this.#model,
305
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,
306
538
  reasoning: this.#reasoning,
307
539
  ...(this.#config.systemPrompt !== undefined ? { systemPrompt: this.#config.systemPrompt } : {}),
308
540
  registry: this.#config.registry,
@@ -401,7 +633,7 @@ export class AgentSession {
401
633
  this.ensureHealthy();
402
634
  if (this.#profilePending)
403
635
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
404
- 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);
405
637
  }
406
638
  /**
407
639
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -428,6 +660,11 @@ export class AgentSession {
428
660
  // R3a: `focus` — an optional steer for the summary call ("keep the
429
661
  // auth details"). Rides the serialized input as ONE instruction
430
662
  // line; absent = byte-identical to the pre-round call.
663
+ // 0.39.2: `manualBudget` — the MANUAL `/compact` gesture's summary
664
+ // call: the measured output budget, and thinking off where the
665
+ // registry says the model can turn it off. Absent — the auto
666
+ // policy — the call is byte-identical to before; see
667
+ // `MANUAL_SUMMARY_BUDGET` for the measurement and for why.
431
668
  options = {}) {
432
669
  this.ensureHealthy();
433
670
  const keepRounds = options.keepRounds ?? KEEP_RECENT_ROUNDS;
@@ -439,7 +676,15 @@ export class AgentSession {
439
676
  if (options.signal !== undefined && options.signal.aborted)
440
677
  throw cancelled();
441
678
  const events = this.log.all;
442
- 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);
443
688
  if (boundary === undefined)
444
689
  return null;
445
690
  const prevPoint = lastSummaryPoint(events);
@@ -456,13 +701,15 @@ export class AgentSession {
456
701
  // call with no fraction (kiso never invents a percentage here).
457
702
  if (options.signal !== undefined && options.signal.aborted)
458
703
  throw cancelled();
704
+ const coveredTokens = estimateTokens(covered);
459
705
  options.onStart?.({
460
706
  coversToSeq: boundary,
461
707
  rounds: events.filter((e) => e.type === "user_input" && e.seq > prevPoint && e.seq <= boundary).length,
462
- tokens: estimateTokens(covered),
708
+ tokens: coveredTokens,
463
709
  });
464
710
  let summary;
465
711
  let usage = null;
712
+ let summaryPath = null;
466
713
  // OR-1 (the second review): the summary is priced by the binding that
467
714
  // MADE the request. A /model switch during this call — the session's
468
715
  // longest single request — moves #model/#baseUrl/#provider under it;
@@ -477,19 +724,31 @@ export class AgentSession {
477
724
  summary = DROP_PLACEHOLDER;
478
725
  }
479
726
  else {
480
- const call = await summarizeConversation({
481
- adapter: binding.adapter,
482
- model: binding.model,
483
- // E6 (a): ONE serialized user message — the DSML bug's
484
- // raw-message array is structurally dead on this path.
485
- messages: [{ role: "user", content: serializedInput }],
486
- // E6 (g): the summary call ALWAYS carries the explicit
487
- // output budget — 4,000 adapter maxTokens (a wire-level
488
- // truncation is caught by the (b) required-section
489
- // validation, never silently passed).
490
- 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,
491
748
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
749
+ ...(options.onProgress !== undefined ? { onProgress: options.onProgress } : {}),
492
750
  });
751
+ summaryPath = path;
493
752
  summary = call.text;
494
753
  usage = call.usage;
495
754
  }
@@ -514,7 +773,7 @@ export class AgentSession {
514
773
  if (usage !== null) {
515
774
  try {
516
775
  const canonical = canonicalizeUsageForModel(binding.model, binding.baseUrl, binding.provider ?? "adapter", usage);
517
- const line = JSON.stringify({ kind: "summary", canonical }) + "\n";
776
+ const line = JSON.stringify({ kind: "summary", canonical, ...(summaryPath !== null ? { path: summaryPath } : {}) }) + "\n";
518
777
  mkdirSync(join(this.#store.root, "traces"), { recursive: true, mode: 0o700 }); // DF-0322-F1
519
778
  appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), line, { mode: 0o600 });
520
779
  }
@@ -541,6 +800,10 @@ export class AgentSession {
541
800
  const policy = this.#config.contextPolicy;
542
801
  if (policy === undefined)
543
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;
544
807
  const mode = policy.drop ?? policy.summary;
545
808
  if (mode === undefined)
546
809
  return;
@@ -560,7 +823,7 @@ export class AgentSession {
560
823
  const maxFailures = mode.maxFailures ?? MAX_SUMMARY_FAILURES;
561
824
  if (this.#summaryFailures >= maxFailures)
562
825
  return;
563
- if (estimateTokens(this.projected()) <= triggerTokens)
826
+ if (this.contextUsed() <= triggerTokens)
564
827
  return;
565
828
  try {
566
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)) {