@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 +27 -0
- package/dist/fork.js +84 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal.d.ts +2 -1
- package/dist/internal.js +2 -1
- package/dist/profile.d.ts +19 -0
- package/dist/profile.js +14 -0
- package/dist/session.d.ts +31 -0
- package/dist/session.js +142 -3
- package/dist/store.d.ts +11 -0
- package/dist/store.js +9 -1
- package/package.json +6 -6
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
|
package/dist/internal.d.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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.
|
|
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.
|
|
28
|
+
"@vincemakes/kiso-core": "0.53.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@vincemakes/kiso-provider-anthropic": "0.
|
|
32
|
-
"@vincemakes/kiso-provider-openai": "0.
|
|
33
|
-
"@vincemakes/kiso-provider-openai-responses": "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.
|
|
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"
|