@vincemakes/kiso-runtime 0.52.0 → 0.53.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/fork.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A fork (owner 2026-11-03: one rule for the terminal, the web app and the products that embed kiso): a new session
3
+ * holding the conversation up to the end of one of the parent's runs, and nothing after it. It is the parent's local
4
+ * files under a new id: the log's lines up to that run's `terminal`, byte for byte (no line names its session and none
5
+ * chains to another), and a sidecar with the parent's execution profile, the name "… (fork)" and the fork's origin. Not
6
+ * carried: the lock and the background tasks, which belong to the parent's process, and the parent's summary, which may
7
+ * describe runs after the cut. The parent is only read; lines it appends meanwhile are after the cut. The sidecar is
8
+ * written first: the runtime reads a session's profile before its first event.
9
+ */
10
+ import { type ForkOrigin } from "./profile.js";
11
+ /** A fork the store will not make, said in words (the caller shows them). */
12
+ export declare class ForkRefusedError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ /** Fork `parent` in `root` after the run that ended at `afterSeq` (absent: the last finished run). */
16
+ export declare function forkSession(root: string, parent: string, afterSeq?: number): {
17
+ readonly id: string;
18
+ readonly origin: ForkOrigin;
19
+ };
20
+ /** Whether a text block is the fork note (the apps show the person's words, never this). */
21
+ export declare const isForkNote: (text: string) => boolean;
22
+ /**
23
+ * What a fork's model is told, once, with the fork's first input after the copied history (never in the system prompt:
24
+ * a cache is a prefix, and a note there made the fork's first request read the whole history fresh, 0% against 88%
25
+ * measured on co, plan fork rev 2): what the conversation it continues does not bring with it.
26
+ */
27
+ export declare const forkNote: (parentTitle: string) => string;
package/dist/fork.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A fork (owner 2026-11-03: one rule for the terminal, the web app and the products that embed kiso): a new session
3
+ * holding the conversation up to the end of one of the parent's runs, and nothing after it. It is the parent's local
4
+ * files under a new id: the log's lines up to that run's `terminal`, byte for byte (no line names its session and none
5
+ * chains to another), and a sidecar with the parent's execution profile, the name "… (fork)" and the fork's origin. Not
6
+ * carried: the lock and the background tasks, which belong to the parent's process, and the parent's summary, which may
7
+ * describe runs after the cut. The parent is only read; lines it appends meanwhile are after the cut. The sidecar is
8
+ * written first: the runtime reads a session's profile before its first event.
9
+ */
10
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { readSessionName, tenantsOf, writeForkSidecar } from "./profile.js";
13
+ /** A fork the store will not make, said in words (the caller shows them). */
14
+ export class ForkRefusedError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "ForkRefusedError";
18
+ }
19
+ }
20
+ /** The id a fork is given: the time to the second and four random characters, the way a session's id is made. */
21
+ const newId = (now = new Date()) => `${now.toISOString().slice(0, 19).replace(/:/g, "-")}-${Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0")}`;
22
+ /** Write a file whole or not at all: a temp file, its bytes synced, then renamed over the name. */
23
+ function durable(path, bytes) {
24
+ const tmp = `${path}.fork-${process.pid}`;
25
+ const fd = openSync(tmp, "w", 0o600);
26
+ try {
27
+ writeSync(fd, bytes);
28
+ fsyncSync(fd);
29
+ }
30
+ finally {
31
+ closeSync(fd);
32
+ }
33
+ renameSync(tmp, path);
34
+ }
35
+ /** Fork `parent` in `root` after the run that ended at `afterSeq` (absent: the last finished run). */
36
+ export function forkSession(root, parent, afterSeq) {
37
+ let log;
38
+ try {
39
+ log = readFileSync(join(root, `${parent}.jsonl`));
40
+ }
41
+ catch {
42
+ throw new ForkRefusedError(`no session ${parent} to fork`);
43
+ }
44
+ // the lines, with where each ends, and the event each holds
45
+ const lines = [];
46
+ for (let from = 0; from < log.length;) {
47
+ const nl = log.indexOf(10, from);
48
+ if (nl < 0)
49
+ break; // a line still being written is after any cut
50
+ const event = JSON.parse(log.subarray(from, nl).toString("utf8")).event;
51
+ lines.push({ end: nl + 1, seq: event["seq"], type: event["type"], event });
52
+ from = nl + 1;
53
+ }
54
+ const cut = afterSeq === undefined ? [...lines].reverse().find((l) => l.type === "terminal") : lines.find((l) => l.seq === afterSeq);
55
+ if (cut === undefined)
56
+ throw new ForkRefusedError(afterSeq === undefined ? "this session has no finished run to fork from yet" : `no event ${afterSeq} in this session`);
57
+ if (cut.type !== "terminal")
58
+ throw new ForkRefusedError("a fork starts at the end of a run: pick a reply that finished");
59
+ const kept = lines.filter((l) => l.end <= cut.end);
60
+ // an execution still waiting for a verdict belongs to the world the parent ran in: settle it there first
61
+ const resolved = new Set(kept.filter((l) => l.type === "tool_execution_resolved").map((l) => l.event["executionId"]));
62
+ if (kept.some((l) => l.type === "uncertain_pending" && !resolved.has(l.event["executionId"])))
63
+ throw new ForkRefusedError("an action before this point is still waiting for your verdict in the original session: settle it there, then fork");
64
+ // its name, else its first line (only this log is read)
65
+ const first = kept.find((l) => l.type === "user_input")?.event;
66
+ const said = typeof first?.via?.line === "string" ? first.via.line : typeof first?.content === "string" ? first.content : Array.isArray(first?.content) ? first.content.map((p) => (typeof p.text === "string" ? p.text : "")).join(" ") : "";
67
+ const title = readSessionName(root, parent) ?? said.replace(/\s+/g, " ").trim().slice(0, 60);
68
+ const id = newId();
69
+ const origin = { session: parent, afterSeq: cut.seq, at: Date.now(), title };
70
+ writeForkSidecar(root, id, { profile: tenantsOf(root, parent)["profile"], name: `${(title || "Session").slice(0, 110)} (fork)`, forkedFrom: origin });
71
+ durable(join(root, `${id}.jsonl`), log.subarray(0, cut.end));
72
+ return { id, origin };
73
+ }
74
+ const FORK_NOTE_HEAD = 'This session was forked from the session "';
75
+ /** Whether a text block is the fork note (the apps show the person's words, never this). */
76
+ export const isForkNote = (text) => text.startsWith(FORK_NOTE_HEAD);
77
+ /**
78
+ * What a fork's model is told, once, with the fork's first input after the copied history (never in the system prompt:
79
+ * a cache is a prefix, and a note there made the fork's first request read the whole history fresh, 0% against 88%
80
+ * measured on co, plan fork rev 2): what the conversation it continues does not bring with it.
81
+ */
82
+ export const forkNote = (parentTitle) => `${FORK_NOTE_HEAD}${parentTitle}" at the end of one of its runs. The conversation above it is copied; two things are not. ` +
83
+ "Background tasks started there are not running here (a task id from that conversation names nothing in this session). " +
84
+ "The folder is shared with it: its files are as they are on disk now, not as they were when the run above ended, and the original session may still change them.";
package/dist/index.d.ts CHANGED
@@ -21,13 +21,15 @@
21
21
  export { AgentRuntime, AgentRuntime as Agent, createAgent } from "./agent.js";
22
22
  export type { AgentDefinition, PermissionPolicy, PermissionRule } from "./agent.js";
23
23
  export { AgentSession, AgentSession as Session } from "./session.js";
24
- export type { SideQueryOptions } from "./session.js";
24
+ export type { FollowUpOptions, SideQueryOptions } from "./session.js";
25
25
  export { PoisonedSessionError, ResumeBlockedError } from "./session.js";
26
26
  export type { ApprovalRequest, CompactInfo, ContextPolicy, SessionConfig, SummarizeResult } from "./session.js";
27
27
  export { DEFAULT_CHECKPOINT_SECTIONS } from "./summarize.js";
28
28
  export type { CheckpointSection } from "./summarize.js";
29
29
  export { Run, RunClosedError } from "./run.js";
30
30
  export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
31
+ export { ForkRefusedError } from "./fork.js";
32
+ export type { ForkOrigin } from "./profile.js";
31
33
  export type { Event, SessionMeta, StoreRecord } from "./store.js";
32
34
  export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
33
35
  export type { KisoExtension } from "./extensions.js";
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export { DEFAULT_CHECKPOINT_SECTIONS } from "./summarize.js";
29
29
  export { Run, RunClosedError } from "./run.js";
30
30
  // store
31
31
  export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js";
32
+ export { ForkRefusedError } from "./fork.js";
32
33
  // extensions
33
34
  export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
34
35
  // tasks (ADR-0058): a host that wires background tasks needs the manager
@@ -29,7 +29,8 @@ export { canonicalizeUsageForModel } from "./usage/canonical.js";
29
29
  export { BUILTIN_MANIFESTS, resolveContinuationScope, type ModelRef, type ProviderManifest } from "./provider/manifest.js";
30
30
  export * from "./checkpoint.js";
31
31
  export * from "./request-budget.js";
32
- export { readProfile, readSessionName, readSummary, writeSessionName, writeSummary, type ExecutionProfile, type ProfileReadResult } from "./profile.js";
32
+ export { isForkNote } from "./fork.js";
33
+ export { readForkOrigin, readProfile, readSessionName, readSummary, writeSessionName, writeSummary, type ExecutionProfile, type ProfileReadResult } from "./profile.js";
33
34
  export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending, type SessionListing, type SessionSummary } from "./session-summary.js";
34
35
  export * from "./tasks/journal.js";
35
36
  export * from "./tasks/manager.js";
package/dist/internal.js CHANGED
@@ -38,7 +38,8 @@ export { BUILTIN_MANIFESTS, resolveContinuationScope } from "./provider/manifest
38
38
  export * from "./checkpoint.js";
39
39
  export * from "./request-budget.js";
40
40
  // 0.40.0: the listing reads the recorded workspace and profile name.
41
- export { readProfile, readSessionName, readSummary, writeSessionName, writeSummary } from "./profile.js";
41
+ export { isForkNote } from "./fork.js";
42
+ export { readForkOrigin, readProfile, readSessionName, readSummary, writeSessionName, writeSummary } from "./profile.js";
42
43
  export { SUMMARY_MIGRATION_MARKER, listSessionSidecars, migrateSummaries, sessionSummary, summarizeEvents, summaryMigrationPending } from "./session-summary.js";
43
44
  // ADR-0058 §6: the task journal and the TaskManager (internal until the
44
45
  // public shape is proven by the shell and delegate steps)
package/dist/profile.d.ts CHANGED
@@ -94,6 +94,25 @@ export declare function readSessionName(root: string, sessionId: string): string
94
94
  /** Name the session, or clear its name (`null`). Atomic and fail-closed
95
95
  * like every tenant's write; the others' bytes ride through. */
96
96
  export declare function writeSessionName(root: string, sessionId: string, name: string | null): void;
97
+ /** Where a fork came from (SessionStore.fork): the parent session, the seq of the parent's `terminal` it was cut after,
98
+ * when, and the parent's title then. The sidecar's fourth tenant; lineage, not authority. */
99
+ export interface ForkOrigin {
100
+ readonly session: string;
101
+ readonly afterSeq: number;
102
+ readonly at: number;
103
+ readonly title: string;
104
+ }
105
+ /** The fork's origin, or null (not a fork, no sidecar, unreadable). */
106
+ export declare function readForkOrigin(root: string, sessionId: string): ForkOrigin | null;
107
+ /** A new fork's sidecar, whole: the parent's execution profile, the fork's name, and its origin. The parent's summary is
108
+ * not carried: it may describe runs after the cut, and the fork writes its own. */
109
+ export declare function writeForkSidecar(root: string, sessionId: string, tenants: {
110
+ readonly profile?: unknown;
111
+ readonly name: string;
112
+ readonly forkedFrom: ForkOrigin;
113
+ }): void;
114
+ /** The parent's tenants, read for a fork. */
115
+ export declare function tenantsOf(root: string, sessionId: string): Record<string, unknown>;
97
116
  export type ProfileReadResult = {
98
117
  readonly kind: "ok";
99
118
  readonly profile: ExecutionProfile;
package/dist/profile.js CHANGED
@@ -98,6 +98,20 @@ export function writeSessionName(root, sessionId, name) {
98
98
  delete tenants.name;
99
99
  writeTenants(root, sessionId, name === null ? tenants : { ...tenants, name });
100
100
  }
101
+ /** The fork's origin, or null (not a fork, no sidecar, unreadable). */
102
+ export function readForkOrigin(root, sessionId) {
103
+ const f = currentTenants(root, sessionId).forkedFrom;
104
+ return f !== undefined && typeof f.session === "string" && typeof f.afterSeq === "number" && typeof f.at === "number" && typeof f.title === "string" ? f : null;
105
+ }
106
+ /** A new fork's sidecar, whole: the parent's execution profile, the fork's name, and its origin. The parent's summary is
107
+ * not carried: it may describe runs after the cut, and the fork writes its own. */
108
+ export function writeForkSidecar(root, sessionId, tenants) {
109
+ writeTenants(root, sessionId, { ...(tenants.profile !== undefined ? { profile: tenants.profile } : {}), name: tenants.name, forkedFrom: tenants.forkedFrom });
110
+ }
111
+ /** The parent's tenants, read for a fork. */
112
+ export function tenantsOf(root, sessionId) {
113
+ return currentTenants(root, sessionId);
114
+ }
101
115
  function writeTenants(root, sessionId, tenants) {
102
116
  const path = profilePath(root, sessionId);
103
117
  const tmpDir = mkdtempSync(join(root, ".meta-"));
package/dist/session.d.ts CHANGED
@@ -51,6 +51,13 @@ export interface SideQueryOptions {
51
51
  /** cancels the request — the CLI wires esc to it. */
52
52
  readonly signal?: AbortSignalLike;
53
53
  }
54
+ export interface FollowUpOptions {
55
+ /** what is asked after the run's last reply, as the person's next message; absent: the built-in ask for the line the
56
+ * person may send next (FOLLOW_UP_ASK), and the answer comes back as that one line (cleaned as the apps show it) */
57
+ readonly ask?: string;
58
+ readonly maxTokens?: number;
59
+ readonly signal?: AbortSignalLike;
60
+ }
54
61
  /** A session whose disk write was rejected (stale handle) is PERMANENTLY
55
62
  * poisoned: its in-memory log no longer matches the disk, so no further
56
63
  * run may proceed — reload the session (round 1). */
@@ -92,6 +99,17 @@ export interface CompactInfo {
92
99
  /** The covered content's estimated tokens (the chars/4 proxy). */
93
100
  readonly tokens: number;
94
101
  }
102
+ /** The follow-up's built-in ask (Session.followUp with no `ask`): the line the person may send next. No language is
103
+ * named: without the clause, Chinese conversations still gave Chinese lines (measured, owner 2026-11-03). */
104
+ export declare const FOLLOW_UP_ASK = "Write the one message the person is most likely to send you next, as they would type it, and nothing else. If the work is done and nothing naturally follows, answer with nothing.";
105
+ /** The built-in follow-up's answer as a line: its first non-empty line, without quotes around it, at most 200
106
+ * characters; "" for none. */
107
+ export declare function followUpLine(text: string): string;
108
+ /** The session title's ask (Session.title): no language named, the model follows the message's own. */
109
+ export declare const TITLE_PROMPT = "Write a short title (2 to 8 words) for a conversation that opens with the user's message. Only the title: no quotes, no punctuation at the end.";
110
+ /** A model's title as a name: its first non-empty line, without quotes around it or punctuation at its end, its spaces
111
+ * collapsed, at most 60 characters; "" when no letter or digit is left. */
112
+ export declare function cleanTitle(text: string): string;
95
113
  /** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
96
114
  * is this old — a command about to end on its own is left to end. */
97
115
  export declare const AUTO_DETACH_MIN_AGE_MS = 2000;
@@ -320,6 +338,18 @@ export declare class AgentSession {
320
338
  * CLI's honest degradation.
321
339
  */
322
340
  sideQuery(options: SideQueryOptions): Promise<string>;
341
+ /**
342
+ * A title for the conversation `firstMessage` opens (owner 2026-11-03: the person's first line is not a title): one
343
+ * side query with its own short prompt and that message (its first 500 characters), no tools, no history, purpose
344
+ * "title" in the trace. It asks for no thinking where the route allows it (measured on co: 6-10 tokens out instead of
345
+ * 100 cut short): thinking disabled, else effort `none`, resolved through the model table as the manual summary is;
346
+ * for a model the table does not know it sends no reasoning field and allows more output. The answer is cleaned to
347
+ * one line (no quotes, no ending punctuation, at most 60 characters); "" when nothing is left. It writes nothing: the
348
+ * caller keeps the title where it keeps names, and only for a session the person has not named.
349
+ */
350
+ title(firstMessage: string, options?: {
351
+ readonly signal?: AbortSignalLike;
352
+ }): Promise<string>;
323
353
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
324
354
  /** REL-0152-D11: `input` is text, or the content blocks a turn with an
325
355
  * attachment carries. The durable event, the projection and both
@@ -341,6 +371,7 @@ export declare class AgentSession {
341
371
  * Yields nothing when the session already completed.
342
372
  */
343
373
  resume(): Run;
374
+ followUp(options: FollowUpOptions): Promise<string>;
344
375
  /**
345
376
  * /compact (ADR-0044): compress the older conversation with a model
346
377
  * summary. Covers the range (previous summary point, boundary] —
package/dist/session.js CHANGED
@@ -32,7 +32,8 @@ import { executionLedger } from "./ledger.js";
32
32
  import { overflowBelt } from "./overflow-belt.js";
33
33
  import { windowLearner } from "./window-learner.js";
34
34
  import { denialResult } from "@vincemakes/kiso-core";
35
- import { buildProfile, readProfile, writeProfile, writeSummary } from "./profile.js";
35
+ import { buildProfile, readForkOrigin, readProfile, writeProfile, writeSummary } from "./profile.js";
36
+ import { forkNote } from "./fork.js";
36
37
  import { summarizeEvents } from "./session-summary.js";
37
38
  import { lookupModelMetadata, resolveReasoning } from "./provider/metadata.js";
38
39
  import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, MANUAL_SUMMARY_BUDGET, SUMMARY_MAX_OUTPUT, resolveCheckpoint, snapshotCheckpointSections, summarizeConversation, SummaryBudgetExhausted, summaryBoundarySeq, } from "./summarize.js";
@@ -91,6 +92,39 @@ function summaryReasoning(model, baseUrl) {
91
92
  const r = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
92
93
  return r.ok && r.wire.thinking !== undefined ? { reasoning: r.wire } : {};
93
94
  }
95
+ /** The follow-up's built-in ask (Session.followUp with no `ask`): the line the person may send next. No language is
96
+ * named: without the clause, Chinese conversations still gave Chinese lines (measured, owner 2026-11-03). */
97
+ export const FOLLOW_UP_ASK = "Write the one message the person is most likely to send you next, as they would type it, and nothing else. If the work is done and nothing naturally follows, answer with nothing.";
98
+ /** The built-in follow-up's answer as a line: its first non-empty line, without quotes around it, at most 200
99
+ * characters; "" for none. */
100
+ export function followUpLine(text) {
101
+ const first = text.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
102
+ const bare = first.replace(/^["'`\u201c\u2018\u300c]+|["'`\u201d\u2019\u300d]+$/g, "").trim();
103
+ return [...bare].length > 200 ? `${[...bare].slice(0, 199).join("")}\u2026` : bare;
104
+ }
105
+ /** The session title's ask (Session.title): no language named, the model follows the message's own. */
106
+ export const TITLE_PROMPT = "Write a short title (2 to 8 words) for a conversation that opens with the user's message. Only the title: no quotes, no punctuation at the end.";
107
+ /** No thinking for a side answer, where the model table says how: thinking disabled, else effort `none`; null when the
108
+ * table knows neither (then nothing is sent: a raw field to an unknown model can be a 400). */
109
+ function quietReasoning(model, baseUrl) {
110
+ const off = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
111
+ if (off.ok && off.wire.thinking !== undefined)
112
+ return off.wire;
113
+ const none = resolveReasoning(model, { thinking: "default", effort: "none" }, baseUrl);
114
+ return none.ok && none.wire.effort !== undefined ? none.wire : null;
115
+ }
116
+ /** A model's title as a name: its first non-empty line, without quotes around it or punctuation at its end, its spaces
117
+ * collapsed, at most 60 characters; "" when no letter or digit is left. */
118
+ export function cleanTitle(text) {
119
+ const line = text.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
120
+ // quotes and ending punctuation can wrap each other ("Title." / Title"."): strip both until neither is left
121
+ let bare = line;
122
+ for (let was = ""; was !== bare;)
123
+ (was = bare), (bare = bare.replace(/^["'`\u201c\u2018\u300c]+|["'`\u201d\u2019\u300d]+$/g, "").replace(/[.!?;:,\u3002\uff01\uff1f\uff1b\uff1a\uff0c]+$/u, "").trim());
124
+ bare = bare.replace(/\s+/g, " ");
125
+ const cut = [...bare].length > 60 ? `${[...bare].slice(0, 59).join("")}…` : bare;
126
+ return /[\p{L}\p{N}]/u.test(cut) ? cut : "";
127
+ }
94
128
  /** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
95
129
  * is this old — a command about to end on its own is left to end. */
96
130
  export const AUTO_DETACH_MIN_AGE_MS = 2_000;
@@ -101,6 +135,8 @@ export class AgentSession {
101
135
  // NOT readonly since 0.1.23: /model replaces it between runs (the
102
136
  // constructor, setAdapter, and setModelBinding are the only writers).
103
137
  #adapter;
138
+ /** the last request a run sent, as the provider adapter got it (in memory only): what followUp sends again */
139
+ #lastRequest = null;
104
140
  #config;
105
141
  /** 0.51.0: the checkpoint every summary call is built from — resolved ONCE at construction from a snapshot of `contextPolicy.checkpointSections`; the raw host array is never read again. */
106
142
  #checkpoint;
@@ -778,6 +814,10 @@ export class AgentSession {
778
814
  * CLI's honest degradation.
779
815
  */
780
816
  async sideQuery(options) {
817
+ return this.#sideQuery(options);
818
+ }
819
+ /** sideQuery's body; `reasoning` (resolved, native) is the title's way to ask for no thinking, not a public option. */
820
+ async #sideQuery(options) {
781
821
  this.ensureHealthy();
782
822
  // a fresh id: this request is not the paused run's work, and giving
783
823
  // it the run's id would put on-demand rent inside a run's total.
@@ -810,6 +850,7 @@ export class AgentSession {
810
850
  messages: [{ role: "user", content: options.prompt }],
811
851
  systemPrompt: options.systemPrompt,
812
852
  ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
853
+ ...(options.reasoning !== undefined ? { reasoning: options.reasoning } : {}),
813
854
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
814
855
  })) {
815
856
  if (ev.type === "text_delta")
@@ -821,6 +862,27 @@ export class AgentSession {
821
862
  }
822
863
  return text;
823
864
  }
865
+ /**
866
+ * A title for the conversation `firstMessage` opens (owner 2026-11-03: the person's first line is not a title): one
867
+ * side query with its own short prompt and that message (its first 500 characters), no tools, no history, purpose
868
+ * "title" in the trace. It asks for no thinking where the route allows it (measured on co: 6-10 tokens out instead of
869
+ * 100 cut short): thinking disabled, else effort `none`, resolved through the model table as the manual summary is;
870
+ * for a model the table does not know it sends no reasoning field and allows more output. The answer is cleaned to
871
+ * one line (no quotes, no ending punctuation, at most 60 characters); "" when nothing is left. It writes nothing: the
872
+ * caller keeps the title where it keeps names, and only for a session the person has not named.
873
+ */
874
+ async title(firstMessage, options) {
875
+ const quiet = quietReasoning(this.#model, this.#baseUrl);
876
+ const text = await this.#sideQuery({
877
+ purpose: "title",
878
+ systemPrompt: TITLE_PROMPT,
879
+ prompt: firstMessage.slice(0, 500),
880
+ maxTokens: quiet === null ? 400 : 100,
881
+ ...(quiet !== null ? { reasoning: quiet } : {}),
882
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
883
+ });
884
+ return cleanTitle(text);
885
+ }
824
886
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
825
887
  /** REL-0152-D11: `input` is text, or the content blocks a turn with an
826
888
  * attachment carries. The durable event, the projection and both
@@ -837,7 +899,7 @@ export class AgentSession {
837
899
  if (this.#profilePending)
838
900
  this.#recordProfile(); // XP-1: legacy revision 1, before the first request
839
901
  const config = options?.maxTurns !== undefined ? { ...this.#effectiveConfig(), maxTurns: options.maxTurns } : this.#effectiveConfig();
840
- return new Run(this.#store, this.#learning(this.#adapter), config, this, input, options?.signal, false, options?.source, options?.via);
902
+ return new Run(this.#store, this.#remembering(this.#learning(this.#adapter)), config, this, this.#withForkNote(input, options?.source), options?.signal, false, options?.source, options?.via);
841
903
  }
842
904
  /**
843
905
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -847,7 +909,84 @@ export class AgentSession {
847
909
  */
848
910
  resume() {
849
911
  this.ensureHealthy();
850
- return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, undefined, undefined, true);
912
+ return new Run(this.#store, this.#remembering(this.#learning(this.#adapter)), this.#effectiveConfig(), this, undefined, undefined, true);
913
+ }
914
+ /** A fork's first input carries the fork note before the person's words, once (plan fork rev 2): after the copied
915
+ * history, so the request's prefix is the parent's and a caching route reads it from the cache. "First" is the log
916
+ * still ending where it was cut; a runtime notice (source "system") never carries it. */
917
+ #withForkNote(input, source) {
918
+ if (source === "system")
919
+ return input;
920
+ const origin = readForkOrigin(this.#store.root, this.id);
921
+ if (origin === null || this.log.lastSeq !== origin.afterSeq)
922
+ return input;
923
+ return [{ type: "text", text: forkNote(origin.title) }, ...(typeof input === "string" ? [{ type: "text", text: input }] : input)];
924
+ }
925
+ /** The adapter a run uses, noting each request it carries (the last one stands, without its signal) for followUp. */
926
+ #remembering(adapter) {
927
+ return {
928
+ stream: (options) => {
929
+ const { signal: _signal, ...request } = options;
930
+ this.#lastRequest = request;
931
+ return adapter.stream(options);
932
+ },
933
+ };
934
+ }
935
+ /**
936
+ * A follow-up of the last run: its last request sent again, byte for byte as the run sent it, then what came after it
937
+ * in the conversation (the run's last reply, as the projection has it, reasoning included) and `ask` as a user message,
938
+ * through the session's own adapter; the model's text comes back. The prefix is the run's own, so a route
939
+ * that caches reads nearly all of it from the cache: the web app asks it for the line the person may send next.
940
+ *
941
+ * Like sideQuery it writes nothing durable and lands one trace line with a fresh runId and purpose "follow-up". It
942
+ * sends nothing, and answers "", when no run has sent a request since this session was opened, or when the model has
943
+ * been switched since (that prefix would not be this model's). Tools stay in the request (they are part of the
944
+ * prefix); a tool call in the answer is ignored.
945
+ */
946
+ /** What the conversation holds after a request's messages: the projection's rest when it starts with them (the usual
947
+ * case), else only its last reply. */
948
+ #after(sent) {
949
+ const now = this.projected();
950
+ if (JSON.stringify(now.slice(0, sent.length)) === JSON.stringify(sent))
951
+ return now.slice(sent.length);
952
+ const last = now.at(-1);
953
+ return last?.role === "assistant" ? [last] : [];
954
+ }
955
+ async followUp(options) {
956
+ this.ensureHealthy();
957
+ const last = this.#lastRequest;
958
+ if (last === null || last.model !== this.#model)
959
+ return "";
960
+ const tracer = new RequestTracer({
961
+ root: this.#store.root,
962
+ sessionId: this.id,
963
+ runId: crypto.randomUUID(),
964
+ provider: this.#provider ?? "adapter",
965
+ model: this.#model,
966
+ ...(this.#baseUrl !== undefined ? { endpoint: this.#baseUrl } : {}),
967
+ adapterVersion: runtimeVersion(),
968
+ purpose: "follow-up",
969
+ log: [],
970
+ rentParts: { base: last.systemPrompt ?? "", appends: [] },
971
+ });
972
+ tracer.init();
973
+ const guarded = traceGuard(tracer, idleGuard(this.#adapter, this.#config.streamIdleMs ?? DEFAULT_STREAM_IDLE_MS));
974
+ let text = "";
975
+ try {
976
+ for await (const ev of guarded.stream({
977
+ ...last,
978
+ messages: [...last.messages, ...this.#after(last.messages), { role: "user", content: options.ask ?? FOLLOW_UP_ASK }],
979
+ ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
980
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
981
+ })) {
982
+ if (ev.type === "text_delta")
983
+ text += ev.text;
984
+ }
985
+ }
986
+ finally {
987
+ tracer.finishRun();
988
+ }
989
+ return options.ask === undefined ? followUpLine(text) : text;
851
990
  }
852
991
  /**
853
992
  * /compact (ADR-0044): compress the older conversation with a model
package/dist/store.d.ts CHANGED
@@ -43,6 +43,7 @@
43
43
  * tolerated damage; everything else throws StoreCorruptionError.
44
44
  */
45
45
  import { type Event } from "@vincemakes/kiso-core";
46
+ import { type ForkOrigin } from "./profile.js";
46
47
  import { type LockAdapter } from "./lock-adapter.js";
47
48
  /** History that does not parse as a contiguous kiso trajectory. */
48
49
  export declare class StoreCorruptionError extends Error {
@@ -65,6 +66,8 @@ export interface SessionMeta {
65
66
  readonly runs: number;
66
67
  readonly createdAt: number;
67
68
  readonly updatedAt: number;
69
+ /** the session this one was forked from (SessionStore.fork), when it is a fork */
70
+ readonly forkedFrom?: string;
68
71
  }
69
72
  export declare function sessionTitle(records: readonly StoreRecord[]): string;
70
73
  export declare class SessionStore {
@@ -121,6 +124,14 @@ export declare class SessionStore {
121
124
  * needs only the ids must never pay that. An empty log is skipped, as
122
125
  * list() skips it.
123
126
  */
127
+ /** Fork `parent` after the run that ended at `afterSeq` (absent: its last finished run): a new session with the
128
+ * conversation up to there and nothing after (fork.ts). Throws ForkRefusedError, in words, when it cannot. */
129
+ fork(parent: string, options?: {
130
+ readonly afterSeq?: number;
131
+ }): {
132
+ readonly id: string;
133
+ readonly origin: ForkOrigin;
134
+ };
124
135
  ids(): string[];
125
136
  list(): SessionMeta[];
126
137
  /** Release a session's fd and OUR writer lock. Idempotent. */
package/dist/store.js CHANGED
@@ -45,7 +45,8 @@
45
45
  import { appendFileSync, closeSync, constants, existsSync, fsyncSync, fstatSync, statSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, writeSync, } from "node:fs";
46
46
  import { dirname, join } from "node:path";
47
47
  import { isKisoEvent } from "@vincemakes/kiso-core";
48
- import { readSessionName } from "./profile.js";
48
+ import { readForkOrigin, readSessionName } from "./profile.js";
49
+ import { forkSession } from "./fork.js";
49
50
  import { LockedError, nativeLockAdapter, } from "./lock-adapter.js";
50
51
  /** History that does not parse as a contiguous kiso trajectory. */
51
52
  export class StoreCorruptionError extends Error {
@@ -415,6 +416,12 @@ export class SessionStore {
415
416
  * needs only the ids must never pay that. An empty log is skipped, as
416
417
  * list() skips it.
417
418
  */
419
+ /** Fork `parent` after the run that ended at `afterSeq` (absent: its last finished run): a new session with the
420
+ * conversation up to there and nothing after (fork.ts). Throws ForkRefusedError, in words, when it cannot. */
421
+ fork(parent, options) {
422
+ this.pathFor(parent); // the id is checked before any file is read
423
+ return forkSession(this.root, parent, options?.afterSeq);
424
+ }
418
425
  ids() {
419
426
  const out = [];
420
427
  for (const entry of readdirSync(this.root)) {
@@ -449,6 +456,7 @@ export class SessionStore {
449
456
  runs: new Set(records.map((r) => r.runId)).size,
450
457
  createdAt: records[0]?.ts ?? 0,
451
458
  updatedAt: records.at(-1)?.ts ?? 0,
459
+ ...((origin) => (origin === null ? {} : { forkedFrom: origin.session }))(readForkOrigin(this.root, id)),
452
460
  });
453
461
  }
454
462
  return metas.sort((a, b) => a.id.localeCompare(b.id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,12 +25,12 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.52.0"
28
+ "@vincemakes/kiso-core": "0.53.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.52.0",
32
- "@vincemakes/kiso-provider-openai": "0.52.0",
33
- "@vincemakes/kiso-provider-openai-responses": "0.52.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.53.0",
32
+ "@vincemakes/kiso-provider-openai": "0.53.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.53.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@vincemakes/kiso-provider-anthropic": {
@@ -44,7 +44,7 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@vincemakes/kiso-evals": "0.52.0",
47
+ "@vincemakes/kiso-evals": "0.53.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"