@vincemakes/kiso-runtime 0.52.0 → 0.54.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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A Work's added folders (ADR-0058 Amendment 19): one small file per session, `<id>.environment.json`.
3
+ *
4
+ * It is apart from `<id>.meta.json` on purpose: the sidecar's writers replace the whole file with no lock, and a rename in one
5
+ * process could write back a list read a moment earlier. This file has one writer, under its own short lock.
6
+ *
7
+ * The file tools read it on every call, so nothing holds a stale copy. It decides what the file tools may reach and nothing else:
8
+ * what the model has been told is the conversation's business, and authorisation never reads the conversation.
9
+ *
10
+ * A damaged file, or one of a version this kiso does not know, honours no added folder: the workspace keeps working.
11
+ */
12
+ export declare const ENVIRONMENT_NOTE_HEAD = "Environment update from kiso (a fact about this session, not a request from the person)";
13
+ /** The line a host puts before a person's next message after a change, kept out of the bubble and the title. Best effort. */
14
+ export declare const isEnvironmentNote: (text: string) => boolean;
15
+ /** A folder that cannot be added or removed, in words for the person. */
16
+ export declare class EnvironmentRefusedError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ export declare const environmentFile: (id: string) => string;
20
+ export interface EnvironmentState {
21
+ readonly added: readonly string[];
22
+ /** the file is there and cannot be honoured; `added` is empty */
23
+ readonly damaged: boolean;
24
+ }
25
+ export declare function readEnvironment(root: string, id: string): EnvironmentState;
26
+ /** Add `folder` to the Work. `primary` is where it started; `stops` are folders the new reach never enters. */
27
+ export declare function addFolder(root: string, id: string, options: {
28
+ readonly primary: string;
29
+ readonly folder: string;
30
+ readonly stops: readonly string[];
31
+ }): Promise<readonly string[]>;
32
+ export declare function removeFolder(root: string, id: string, folder: string): Promise<readonly string[]>;
33
+ /** A fork takes the parent's current list. */
34
+ export declare function copyEnvironment(root: string, from: string, to: string): void;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A Work's added folders (ADR-0058 Amendment 19): one small file per session, `<id>.environment.json`.
3
+ *
4
+ * It is apart from `<id>.meta.json` on purpose: the sidecar's writers replace the whole file with no lock, and a rename in one
5
+ * process could write back a list read a moment earlier. This file has one writer, under its own short lock.
6
+ *
7
+ * The file tools read it on every call, so nothing holds a stale copy. It decides what the file tools may reach and nothing else:
8
+ * what the model has been told is the conversation's business, and authorisation never reads the conversation.
9
+ *
10
+ * A damaged file, or one of a version this kiso does not know, honours no added folder: the workspace keeps working.
11
+ */
12
+ import { closeSync, copyFileSync, existsSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, writeSync } from "node:fs";
13
+ import { dirname, isAbsolute, join, relative } from "node:path";
14
+ import { LockedError, nativeLockAdapter } from "./lock-adapter.js";
15
+ const VERSION = 1;
16
+ const LOCK_WAIT_MS = 2_000;
17
+ export const ENVIRONMENT_NOTE_HEAD = "Environment update from kiso (a fact about this session, not a request from the person)";
18
+ /** The line a host puts before a person's next message after a change, kept out of the bubble and the title. Best effort. */
19
+ export const isEnvironmentNote = (text) => text.startsWith(ENVIRONMENT_NOTE_HEAD);
20
+ /** A folder that cannot be added or removed, in words for the person. */
21
+ export class EnvironmentRefusedError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "EnvironmentRefusedError";
25
+ }
26
+ }
27
+ export const environmentFile = (id) => `${id}.environment.json`;
28
+ export function readEnvironment(root, id) {
29
+ let raw;
30
+ try {
31
+ raw = readFileSync(join(root, environmentFile(id)), "utf8");
32
+ }
33
+ catch (err) {
34
+ return { added: [], damaged: err.code !== "ENOENT" };
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed.version !== VERSION || !Array.isArray(parsed.added) || !parsed.added.every((p) => typeof p === "string" && isAbsolute(p)))
39
+ return { added: [], damaged: true };
40
+ return { added: parsed.added, damaged: false };
41
+ }
42
+ catch {
43
+ return { added: [], damaged: true };
44
+ }
45
+ }
46
+ const within = (root, target) => {
47
+ const rel = relative(root, target);
48
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
49
+ };
50
+ /**
51
+ * The list's own lock: the session store's identity-confirmed link lock (ADR-0050), never taken from a live holder and released
52
+ * only by its owner. A dead holder is taken over by the adapter's rules; a live one is waited for briefly, then refused in words.
53
+ */
54
+ async function withLock(path, id, work) {
55
+ const deadline = Date.now() + LOCK_WAIT_MS;
56
+ let handle;
57
+ for (;;) {
58
+ try {
59
+ handle = await nativeLockAdapter.acquire(`${path}.lock`, id, () => undefined);
60
+ break;
61
+ }
62
+ catch (err) {
63
+ if (!(err instanceof LockedError) || Date.now() > deadline) {
64
+ throw err instanceof LockedError ? new EnvironmentRefusedError("this Work's folders are being changed elsewhere: try again") : err;
65
+ }
66
+ await new Promise((r) => setTimeout(r, 20));
67
+ }
68
+ }
69
+ try {
70
+ if (!handle.verify())
71
+ throw new EnvironmentRefusedError("this Work's folders are being changed elsewhere: try again");
72
+ return work();
73
+ }
74
+ finally {
75
+ handle.release();
76
+ }
77
+ }
78
+ function replace(path, added) {
79
+ const tmp = `${path}.${process.pid}.tmp`;
80
+ const fd = openSync(tmp, "w", 0o600);
81
+ try {
82
+ writeSync(fd, `${JSON.stringify({ version: VERSION, added })}\n`);
83
+ fsyncSync(fd);
84
+ }
85
+ finally {
86
+ closeSync(fd);
87
+ }
88
+ renameSync(tmp, path);
89
+ // the rename is the commit: from here a failure must not be reported as one. A platform whose directories cannot be opened or
90
+ // flushed (Windows) keeps the rename.
91
+ try {
92
+ const dir = openSync(dirname(path), "r");
93
+ try {
94
+ fsyncSync(dir);
95
+ }
96
+ finally {
97
+ closeSync(dir);
98
+ }
99
+ }
100
+ catch {
101
+ // the change stands
102
+ }
103
+ }
104
+ function update(root, id, change) {
105
+ const path = join(root, environmentFile(id));
106
+ return withLock(path, id, () => {
107
+ const now = readEnvironment(root, id);
108
+ if (now.damaged)
109
+ throw new EnvironmentRefusedError(`this Work's folder list cannot be read: remove ${path} to start it again`);
110
+ const next = change(now.added);
111
+ replace(path, next);
112
+ return next;
113
+ });
114
+ }
115
+ /** Add `folder` to the Work. `primary` is where it started; `stops` are folders the new reach never enters. */
116
+ export async function addFolder(root, id, options) {
117
+ let real;
118
+ try {
119
+ real = realpathSync(options.folder);
120
+ if (!statSync(real).isDirectory())
121
+ throw new Error("not a directory");
122
+ }
123
+ catch {
124
+ throw new EnvironmentRefusedError(`${options.folder} is not a folder on this computer`);
125
+ }
126
+ const realOrSelf = (p) => {
127
+ try {
128
+ return realpathSync(p);
129
+ }
130
+ catch {
131
+ return p;
132
+ }
133
+ };
134
+ if (options.stops.map(realOrSelf).some((stop) => within(stop, real)))
135
+ throw new EnvironmentRefusedError(`${real} can't be added: the file tools do not reach this folder`);
136
+ const primary = realOrSelf(options.primary);
137
+ if (real === primary)
138
+ throw new EnvironmentRefusedError("this is already the Work's primary folder");
139
+ return update(root, id, (added) => {
140
+ if (added.includes(real))
141
+ throw new EnvironmentRefusedError("this folder is already in the Work");
142
+ if ([primary, ...added.map(realOrSelf)].some((r) => within(r, real) || within(real, r)))
143
+ throw new EnvironmentRefusedError("a folder inside, or containing, another of the Work's folders can't be added");
144
+ return [...added, real];
145
+ });
146
+ }
147
+ export async function removeFolder(root, id, folder) {
148
+ return update(root, id, (added) => {
149
+ if (!added.includes(folder))
150
+ throw new EnvironmentRefusedError("this folder is not in the Work");
151
+ return added.filter((p) => p !== folder);
152
+ });
153
+ }
154
+ /** A fork takes the parent's current list. */
155
+ export function copyEnvironment(root, from, to) {
156
+ const source = join(root, environmentFile(from));
157
+ if (existsSync(source))
158
+ copyFileSync(source, join(root, environmentFile(to)));
159
+ }
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,86 @@
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 { copyEnvironment } from "./environment.js";
11
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { readSessionName, tenantsOf, writeForkSidecar } from "./profile.js";
14
+ /** A fork the store will not make, said in words (the caller shows them). */
15
+ export class ForkRefusedError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ForkRefusedError";
19
+ }
20
+ }
21
+ /** The id a fork is given: the time to the second and four random characters, the way a session's id is made. */
22
+ const newId = (now = new Date()) => `${now.toISOString().slice(0, 19).replace(/:/g, "-")}-${Math.floor(Math.random() * 0x10000).toString(16).padStart(4, "0")}`;
23
+ /** Write a file whole or not at all: a temp file, its bytes synced, then renamed over the name. */
24
+ function durable(path, bytes) {
25
+ const tmp = `${path}.fork-${process.pid}`;
26
+ const fd = openSync(tmp, "w", 0o600);
27
+ try {
28
+ writeSync(fd, bytes);
29
+ fsyncSync(fd);
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ renameSync(tmp, path);
35
+ }
36
+ /** Fork `parent` in `root` after the run that ended at `afterSeq` (absent: the last finished run). */
37
+ export function forkSession(root, parent, afterSeq) {
38
+ let log;
39
+ try {
40
+ log = readFileSync(join(root, `${parent}.jsonl`));
41
+ }
42
+ catch {
43
+ throw new ForkRefusedError(`no session ${parent} to fork`);
44
+ }
45
+ // the lines, with where each ends, and the event each holds
46
+ const lines = [];
47
+ for (let from = 0; from < log.length;) {
48
+ const nl = log.indexOf(10, from);
49
+ if (nl < 0)
50
+ break; // a line still being written is after any cut
51
+ const event = JSON.parse(log.subarray(from, nl).toString("utf8")).event;
52
+ lines.push({ end: nl + 1, seq: event["seq"], type: event["type"], event });
53
+ from = nl + 1;
54
+ }
55
+ const cut = afterSeq === undefined ? [...lines].reverse().find((l) => l.type === "terminal") : lines.find((l) => l.seq === afterSeq);
56
+ if (cut === undefined)
57
+ throw new ForkRefusedError(afterSeq === undefined ? "this session has no finished run to fork from yet" : `no event ${afterSeq} in this session`);
58
+ if (cut.type !== "terminal")
59
+ throw new ForkRefusedError("a fork starts at the end of a run: pick a reply that finished");
60
+ const kept = lines.filter((l) => l.end <= cut.end);
61
+ // an execution still waiting for a verdict belongs to the world the parent ran in: settle it there first
62
+ const resolved = new Set(kept.filter((l) => l.type === "tool_execution_resolved").map((l) => l.event["executionId"]));
63
+ if (kept.some((l) => l.type === "uncertain_pending" && !resolved.has(l.event["executionId"])))
64
+ throw new ForkRefusedError("an action before this point is still waiting for your verdict in the original session: settle it there, then fork");
65
+ // its name, else its first line (only this log is read)
66
+ const first = kept.find((l) => l.type === "user_input")?.event;
67
+ 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(" ") : "";
68
+ const title = readSessionName(root, parent) ?? said.replace(/\s+/g, " ").trim().slice(0, 60);
69
+ const id = newId();
70
+ const origin = { session: parent, afterSeq: cut.seq, at: Date.now(), title };
71
+ writeForkSidecar(root, id, { profile: tenantsOf(root, parent)["profile"], name: `${(title || "Session").slice(0, 110)} (fork)`, forkedFrom: origin });
72
+ copyEnvironment(root, parent, id); // before the log: a fork that is listed has its folders
73
+ durable(join(root, `${id}.jsonl`), log.subarray(0, cut.end));
74
+ return { id, origin };
75
+ }
76
+ const FORK_NOTE_HEAD = 'This session was forked from the session "';
77
+ /** Whether a text block is the fork note (the apps show the person's words, never this). */
78
+ export const isForkNote = (text) => text.startsWith(FORK_NOTE_HEAD);
79
+ /**
80
+ * What a fork's model is told, once, with the fork's first input after the copied history (never in the system prompt:
81
+ * a cache is a prefix, and a note there made the fork's first request read the whole history fresh, 0% against 88%
82
+ * measured on co, plan fork rev 2): what the conversation it continues does not bring with it.
83
+ */
84
+ 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. ` +
85
+ "Background tasks started there are not running here (a task id from that conversation names nothing in this session). " +
86
+ "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,17 @@
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 { addFolder, EnvironmentRefusedError, isEnvironmentNote, readEnvironment, removeFolder } from "./environment.js";
33
+ export type { EnvironmentState } from "./environment.js";
34
+ export type { ForkOrigin } from "./profile.js";
31
35
  export type { Event, SessionMeta, StoreRecord } from "./store.js";
32
36
  export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
33
37
  export type { KisoExtension } from "./extensions.js";
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ 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";
33
+ export { addFolder, EnvironmentRefusedError, isEnvironmentNote, readEnvironment, removeFolder } from "./environment.js";
32
34
  // extensions
33
35
  export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
34
36
  // 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-"));
@@ -185,6 +185,11 @@ export interface ResolvedContextWindow {
185
185
  * facts about a route and stay on lookupModelMetadata.
186
186
  */
187
187
  export declare function lookupContextWindow(model: string, endpoint?: string, upstream?: string): ResolvedContextWindow | null;
188
+ /**
189
+ * What a model takes in (owner, 2026-11-11): the table's row for this route when it says, else the release snapshot for the
190
+ * model's weights (whether a model reads pictures belongs to the weights), else null, unknown. A host decides what unknown means.
191
+ */
192
+ export declare function lookupInputModalities(model: string, endpoint?: string): readonly string[] | null;
188
193
  /**
189
194
  * XP-1 §4.2 — native-only resolution. The rules, in order:
190
195
  * default/default → NO wire fields (the byte-identity anchor — a
@@ -19,6 +19,7 @@
19
19
  * does not move; core is untouched.
20
20
  */
21
21
  import { OPENROUTER_ROWS } from "./openrouter-rows.js";
22
+ import { MODEL_SNAPSHOT, MODEL_SNAPSHOT_AS_OF } from "./model-snapshot.js";
22
23
  /**
23
24
  * REG-1 — the OFF-PEAK rates, read from the vendor's pricing table on
24
25
  * 2026-09-17. The table carries TWO sets: off-peak and peak, the peak
@@ -632,14 +633,47 @@ export function lookupContextWindow(model, endpoint, upstream) {
632
633
  }
633
634
  const identity = modelIdentity(model);
634
635
  let found = null;
636
+ let rowed = route !== null;
635
637
  for (const entry of ENTRIES) {
636
638
  const tokens = entry.capabilities.contextWindow;
637
- if (tokens === null || modelIdentity(entry.model) !== identity)
639
+ if (modelIdentity(entry.model) !== identity)
640
+ continue;
641
+ rowed = true;
642
+ if (tokens === null)
638
643
  continue;
639
644
  if (found === null || tokens < found.tokens)
640
645
  found = { tokens, source: "model", from: entry.model };
641
646
  }
642
- return found;
647
+ if (found !== null || rowed)
648
+ return found; // a row that states no window lends none (CW-1): the snapshot does not overrule it
649
+ // last, for a model the table has no row for at all: the release's snapshot (kiso.work), by the same identity
650
+ const snap = snapshotOf(model);
651
+ return snap?.window != null ? { tokens: snap.window, source: "model", from: `${snap.key} (kiso.work, ${MODEL_SNAPSHOT_AS_OF})` } : null;
652
+ }
653
+ let dashed = null;
654
+ /** The release snapshot's row for a model: by its folded name, by the weights it names (an alias), by a name that names the
655
+ * same weights, and with dots and dashes read alike (`claude-fable-5.2` is the vendor's `claude-fable-5-2`). */
656
+ function snapshotOf(model) {
657
+ const folded = model.slice(model.lastIndexOf("/") + 1).toLowerCase();
658
+ const identity = modelIdentity(model);
659
+ const names = [folded, identity, ...Object.entries(MODEL_ALIASES).filter(([, a]) => a.is === identity).map(([n]) => n)];
660
+ for (const n of names)
661
+ if (MODEL_SNAPSHOT[n] !== undefined)
662
+ return { key: n, ...MODEL_SNAPSHOT[n] };
663
+ dashed ??= new Map(Object.keys(MODEL_SNAPSHOT).map((k) => [k.replace(/\./g, "-"), k]));
664
+ for (const n of names) {
665
+ const k = dashed.get(n.replace(/\./g, "-"));
666
+ if (k !== undefined)
667
+ return { key: k, ...MODEL_SNAPSHOT[k] };
668
+ }
669
+ return null;
670
+ }
671
+ /**
672
+ * What a model takes in (owner, 2026-11-11): the table's row for this route when it says, else the release snapshot for the
673
+ * model's weights (whether a model reads pictures belongs to the weights), else null, unknown. A host decides what unknown means.
674
+ */
675
+ export function lookupInputModalities(model, endpoint) {
676
+ return lookupModelMetadata(model, endpoint)?.capabilities.inputModalities ?? snapshotOf(model)?.input ?? null;
643
677
  }
644
678
  function originOf(endpoint) {
645
679
  try {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * GENERATED by scripts/model-snapshot.mjs from https://kiso.work/models/all.json (as of 2026-11-11).
3
+ * Do not edit by hand: a release runs the script and commits the diff.
4
+ */
5
+ export declare const MODEL_SNAPSHOT_AS_OF = "2026-11-11";
6
+ export declare const MODEL_SNAPSHOT_SOURCE = "https://kiso.work/models/all.json";
7
+ /** Keyed by the model's name with its vendor prefix off, in lower case. */
8
+ export declare const MODEL_SNAPSHOT: Readonly<Record<string, {
9
+ readonly input: readonly string[] | null;
10
+ readonly window: number | null;
11
+ }>>;