@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.
- package/dist/environment.d.ts +34 -0
- package/dist/environment.js +159 -0
- package/dist/fork.d.ts +27 -0
- package/dist/fork.js +86 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -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/provider/metadata.d.ts +5 -0
- package/dist/provider/metadata.js +36 -2
- package/dist/provider/model-snapshot.d.ts +11 -0
- package/dist/provider/model-snapshot.js +361 -0
- package/dist/session.d.ts +33 -0
- package/dist/session.js +153 -3
- package/dist/store.d.ts +11 -0
- package/dist/store.js +101 -5
- package/package.json +6 -6
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,43 @@ 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; "" for none, and ""
|
|
99
|
+
* for a line over 200 characters (a cut message is not one the person would send, so it is dropped, not shortened). */
|
|
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;
|
|
104
|
+
}
|
|
105
|
+
/** The built-in follow-up's room when its thinking cannot be turned off: a one-line answer after the model's own thinking. */
|
|
106
|
+
const FOLLOW_UP_THINKING_ROOM = 600;
|
|
107
|
+
/** The session title's ask (Session.title). The language is named, unlike the follow-up's: the title sees one message
|
|
108
|
+
* and no history, and a short greeting under an English ask came back English (ds, a three-character Chinese
|
|
109
|
+
* greeting: 5 of 10 English titles without the clause, 0 of 10 with it; owner 2026-11-04). */
|
|
110
|
+
export const TITLE_PROMPT = "Write a short title (2 to 8 words) for a conversation that opens with the user's message, in the language of that message. Only the title: no quotes, no punctuation at the end.";
|
|
111
|
+
/** No thinking for a side answer, where the model table says how: thinking disabled, else effort `none`; null when the
|
|
112
|
+
* table knows neither (then nothing is sent: a raw field to an unknown model can be a 400). */
|
|
113
|
+
function quietReasoning(model, baseUrl) {
|
|
114
|
+
const off = resolveReasoning(model, { thinking: "disabled", effort: "default" }, baseUrl);
|
|
115
|
+
if (off.ok && off.wire.thinking !== undefined)
|
|
116
|
+
return off.wire;
|
|
117
|
+
const none = resolveReasoning(model, { thinking: "default", effort: "none" }, baseUrl);
|
|
118
|
+
return none.ok && none.wire.effort !== undefined ? none.wire : null;
|
|
119
|
+
}
|
|
120
|
+
/** A model's title as a name: its first non-empty line, without quotes around it or punctuation at its end, its spaces
|
|
121
|
+
* collapsed, at most 60 characters; "" when no letter or digit is left. */
|
|
122
|
+
export function cleanTitle(text) {
|
|
123
|
+
const line = text.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
|
|
124
|
+
// quotes and ending punctuation can wrap each other ("Title." / Title"."): strip both until neither is left
|
|
125
|
+
let bare = line;
|
|
126
|
+
for (let was = ""; was !== bare;)
|
|
127
|
+
(was = bare), (bare = bare.replace(/^["'`\u201c\u2018\u300c]+|["'`\u201d\u2019\u300d]+$/g, "").replace(/[.!?;:,\u3002\uff01\uff1f\uff1b\uff1a\uff0c]+$/u, "").trim());
|
|
128
|
+
bare = bare.replace(/\s+/g, " ");
|
|
129
|
+
const cut = [...bare].length > 60 ? `${[...bare].slice(0, 59).join("")}…` : bare;
|
|
130
|
+
return /[\p{L}\p{N}]/u.test(cut) ? cut : "";
|
|
131
|
+
}
|
|
94
132
|
/** ADR-0057 §5 (3e D1): a steer detaches a foreground command only once it
|
|
95
133
|
* is this old — a command about to end on its own is left to end. */
|
|
96
134
|
export const AUTO_DETACH_MIN_AGE_MS = 2_000;
|
|
@@ -101,6 +139,8 @@ export class AgentSession {
|
|
|
101
139
|
// NOT readonly since 0.1.23: /model replaces it between runs (the
|
|
102
140
|
// constructor, setAdapter, and setModelBinding are the only writers).
|
|
103
141
|
#adapter;
|
|
142
|
+
/** the last request a run sent, as the provider adapter got it (in memory only): what followUp sends again */
|
|
143
|
+
#lastRequest = null;
|
|
104
144
|
#config;
|
|
105
145
|
/** 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
146
|
#checkpoint;
|
|
@@ -778,6 +818,10 @@ export class AgentSession {
|
|
|
778
818
|
* CLI's honest degradation.
|
|
779
819
|
*/
|
|
780
820
|
async sideQuery(options) {
|
|
821
|
+
return this.#sideQuery(options);
|
|
822
|
+
}
|
|
823
|
+
/** sideQuery's body; `reasoning` (resolved, native) is the title's way to ask for no thinking, not a public option. */
|
|
824
|
+
async #sideQuery(options) {
|
|
781
825
|
this.ensureHealthy();
|
|
782
826
|
// a fresh id: this request is not the paused run's work, and giving
|
|
783
827
|
// it the run's id would put on-demand rent inside a run's total.
|
|
@@ -810,6 +854,7 @@ export class AgentSession {
|
|
|
810
854
|
messages: [{ role: "user", content: options.prompt }],
|
|
811
855
|
systemPrompt: options.systemPrompt,
|
|
812
856
|
...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
|
|
857
|
+
...(options.reasoning !== undefined ? { reasoning: options.reasoning } : {}),
|
|
813
858
|
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
814
859
|
})) {
|
|
815
860
|
if (ev.type === "text_delta")
|
|
@@ -821,6 +866,27 @@ export class AgentSession {
|
|
|
821
866
|
}
|
|
822
867
|
return text;
|
|
823
868
|
}
|
|
869
|
+
/**
|
|
870
|
+
* A title for the conversation `firstMessage` opens (owner 2026-11-03: the person's first line is not a title): one
|
|
871
|
+
* side query with its own short prompt and that message (its first 500 characters), no tools, no history, purpose
|
|
872
|
+
* "title" in the trace. It asks for no thinking where the route allows it (measured on co: 6-10 tokens out instead of
|
|
873
|
+
* 100 cut short): thinking disabled, else effort `none`, resolved through the model table as the manual summary is;
|
|
874
|
+
* for a model the table does not know it sends no reasoning field and allows more output. The answer is cleaned to
|
|
875
|
+
* one line (no quotes, no ending punctuation, at most 60 characters); "" when nothing is left. It writes nothing: the
|
|
876
|
+
* caller keeps the title where it keeps names, and only for a session the person has not named.
|
|
877
|
+
*/
|
|
878
|
+
async title(firstMessage, options) {
|
|
879
|
+
const quiet = quietReasoning(this.#model, this.#baseUrl);
|
|
880
|
+
const text = await this.#sideQuery({
|
|
881
|
+
purpose: "title",
|
|
882
|
+
systemPrompt: TITLE_PROMPT,
|
|
883
|
+
prompt: firstMessage.slice(0, 500),
|
|
884
|
+
maxTokens: quiet === null ? 400 : 100,
|
|
885
|
+
...(quiet !== null ? { reasoning: quiet } : {}),
|
|
886
|
+
...(options?.signal !== undefined ? { signal: options.signal } : {}),
|
|
887
|
+
});
|
|
888
|
+
return cleanTitle(text);
|
|
889
|
+
}
|
|
824
890
|
/** Run one user turn. Iterate to consume; `run.abort()` cancels. */
|
|
825
891
|
/** REL-0152-D11: `input` is text, or the content blocks a turn with an
|
|
826
892
|
* attachment carries. The durable event, the projection and both
|
|
@@ -837,7 +903,7 @@ export class AgentSession {
|
|
|
837
903
|
if (this.#profilePending)
|
|
838
904
|
this.#recordProfile(); // XP-1: legacy revision 1, before the first request
|
|
839
905
|
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);
|
|
906
|
+
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
907
|
}
|
|
842
908
|
/**
|
|
843
909
|
* Continue the interrupted run (Area 2): apply durable decisions,
|
|
@@ -847,7 +913,91 @@ export class AgentSession {
|
|
|
847
913
|
*/
|
|
848
914
|
resume() {
|
|
849
915
|
this.ensureHealthy();
|
|
850
|
-
return new Run(this.#store, this.#learning(this.#adapter), this.#effectiveConfig(), this, undefined, undefined, true);
|
|
916
|
+
return new Run(this.#store, this.#remembering(this.#learning(this.#adapter)), this.#effectiveConfig(), this, undefined, undefined, true);
|
|
917
|
+
}
|
|
918
|
+
/** A fork's first input carries the fork note before the person's words, once (plan fork rev 2): after the copied
|
|
919
|
+
* history, so the request's prefix is the parent's and a caching route reads it from the cache. "First" is the log
|
|
920
|
+
* still ending where it was cut; a runtime notice (source "system") never carries it. */
|
|
921
|
+
#withForkNote(input, source) {
|
|
922
|
+
if (source === "system")
|
|
923
|
+
return input;
|
|
924
|
+
const origin = readForkOrigin(this.#store.root, this.id);
|
|
925
|
+
if (origin === null || this.log.lastSeq !== origin.afterSeq)
|
|
926
|
+
return input;
|
|
927
|
+
return [{ type: "text", text: forkNote(origin.title) }, ...(typeof input === "string" ? [{ type: "text", text: input }] : input)];
|
|
928
|
+
}
|
|
929
|
+
/** The adapter a run uses, noting each request it carries (the last one stands, without its signal) for followUp. */
|
|
930
|
+
#remembering(adapter) {
|
|
931
|
+
return {
|
|
932
|
+
stream: (options) => {
|
|
933
|
+
const { signal: _signal, ...request } = options;
|
|
934
|
+
this.#lastRequest = request;
|
|
935
|
+
return adapter.stream(options);
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* 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
|
|
941
|
+
* in the conversation (the run's last reply, as the projection has it, reasoning included) and `ask` as a user message,
|
|
942
|
+
* through the session's own adapter; the model's text comes back. The prefix is the run's own, so a route
|
|
943
|
+
* that caches reads nearly all of it from the cache: the web app asks it for the line the person may send next.
|
|
944
|
+
*
|
|
945
|
+
* Like sideQuery it writes nothing durable and lands one trace line with a fresh runId and purpose "follow-up". It
|
|
946
|
+
* sends nothing, and answers "", when no run has sent a request since this session was opened, or when the model has
|
|
947
|
+
* been switched since (that prefix would not be this model's). Tools stay in the request (they are part of the
|
|
948
|
+
* prefix); a tool call in the answer is ignored.
|
|
949
|
+
*/
|
|
950
|
+
/** What the conversation holds after a request's messages: the projection's rest when it starts with them (the usual
|
|
951
|
+
* case), else only its last reply. */
|
|
952
|
+
#after(sent) {
|
|
953
|
+
const now = this.projected();
|
|
954
|
+
if (JSON.stringify(now.slice(0, sent.length)) === JSON.stringify(sent))
|
|
955
|
+
return now.slice(sent.length);
|
|
956
|
+
const last = now.at(-1);
|
|
957
|
+
return last?.role === "assistant" ? [last] : [];
|
|
958
|
+
}
|
|
959
|
+
async followUp(options) {
|
|
960
|
+
this.ensureHealthy();
|
|
961
|
+
const last = this.#lastRequest;
|
|
962
|
+
if (last === null || last.model !== this.#model)
|
|
963
|
+
return "";
|
|
964
|
+
const tracer = new RequestTracer({
|
|
965
|
+
root: this.#store.root,
|
|
966
|
+
sessionId: this.id,
|
|
967
|
+
runId: crypto.randomUUID(),
|
|
968
|
+
provider: this.#provider ?? "adapter",
|
|
969
|
+
model: this.#model,
|
|
970
|
+
...(this.#baseUrl !== undefined ? { endpoint: this.#baseUrl } : {}),
|
|
971
|
+
adapterVersion: runtimeVersion(),
|
|
972
|
+
purpose: "follow-up",
|
|
973
|
+
log: [],
|
|
974
|
+
rentParts: { base: last.systemPrompt ?? "", appends: [] },
|
|
975
|
+
});
|
|
976
|
+
tracer.init();
|
|
977
|
+
const guarded = traceGuard(tracer, idleGuard(this.#adapter, this.#config.streamIdleMs ?? DEFAULT_STREAM_IDLE_MS));
|
|
978
|
+
// The built-in line asks for no thinking where the model table says how, as the title does: half of the measured
|
|
979
|
+
// follow-ups spent 80-200 output tokens thinking about one line, and some were cut off by the budget. Not on the
|
|
980
|
+
// anthropic route, where changing the thinking setting drops the cached prefix this request exists to reuse. A model
|
|
981
|
+
// that cannot be quieted keeps its own setting and gets room for its thinking. A host's own `ask` is sent as before.
|
|
982
|
+
const quiet = options.ask === undefined && this.#provider !== "anthropic" ? quietReasoning(this.#model, this.#baseUrl) : null;
|
|
983
|
+
const maxTokens = options.ask === undefined && quiet === null && options.maxTokens !== undefined ? Math.max(options.maxTokens, FOLLOW_UP_THINKING_ROOM) : options.maxTokens;
|
|
984
|
+
let text = "";
|
|
985
|
+
try {
|
|
986
|
+
for await (const ev of guarded.stream({
|
|
987
|
+
...last,
|
|
988
|
+
...(quiet !== null ? { reasoning: quiet } : {}),
|
|
989
|
+
messages: [...last.messages, ...this.#after(last.messages), { role: "user", content: options.ask ?? FOLLOW_UP_ASK }],
|
|
990
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
991
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
992
|
+
})) {
|
|
993
|
+
if (ev.type === "text_delta")
|
|
994
|
+
text += ev.text;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
finally {
|
|
998
|
+
tracer.finishRun();
|
|
999
|
+
}
|
|
1000
|
+
return options.ask === undefined ? followUpLine(text) : text;
|
|
851
1001
|
}
|
|
852
1002
|
/**
|
|
853
1003
|
* /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,8 +45,10 @@
|
|
|
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";
|
|
51
|
+
import { isEnvironmentNote } from "./environment.js";
|
|
50
52
|
/** History that does not parse as a contiguous kiso trajectory. */
|
|
51
53
|
export class StoreCorruptionError extends Error {
|
|
52
54
|
constructor(message) {
|
|
@@ -126,8 +128,10 @@ function contentText(content) {
|
|
|
126
128
|
if (block === null || typeof block !== "object")
|
|
127
129
|
continue;
|
|
128
130
|
const b = block;
|
|
129
|
-
if (b.type === "text" && typeof b.text === "string")
|
|
130
|
-
|
|
131
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
132
|
+
if (!isEnvironmentNote(b.text))
|
|
133
|
+
parts.push(b.text); // the host's line about a folder, never the person's words
|
|
134
|
+
}
|
|
131
135
|
else if (b.type === "image")
|
|
132
136
|
parts.push("(image)");
|
|
133
137
|
}
|
|
@@ -159,6 +163,8 @@ export class SessionStore {
|
|
|
159
163
|
#fds = new Map();
|
|
160
164
|
/** sessionId → the lock handle THIS instance holds (ADR-0050). */
|
|
161
165
|
#lockHandles = new Map();
|
|
166
|
+
/** sessionId → what the last load read: the file it was (device, inode, size, mtime), the byte after its last committed line, and the records (load's tail read). */
|
|
167
|
+
#loaded = new Map();
|
|
162
168
|
#lockAdapter;
|
|
163
169
|
/** round 4 (adversarial): serialize concurrent acquireLock calls ON this instance —
|
|
164
170
|
* two racing appends must not issue two acquisitions and fight each other. */
|
|
@@ -366,9 +372,92 @@ export class SessionStore {
|
|
|
366
372
|
*/
|
|
367
373
|
load(sessionId) {
|
|
368
374
|
const path = this.pathFor(sessionId);
|
|
369
|
-
if (!existsSync(path))
|
|
375
|
+
if (!existsSync(path)) {
|
|
376
|
+
this.#loaded.delete(sessionId);
|
|
370
377
|
return [];
|
|
371
|
-
|
|
378
|
+
}
|
|
379
|
+
const fresh = this.#loadTail(sessionId, path);
|
|
380
|
+
if (fresh !== null)
|
|
381
|
+
return fresh;
|
|
382
|
+
const st = statSync(path);
|
|
383
|
+
const raw = readFileSync(path);
|
|
384
|
+
const records = this.#loadWhole(raw.toString("utf8"));
|
|
385
|
+
const end = raw.lastIndexOf(0x0a);
|
|
386
|
+
let lines = 0;
|
|
387
|
+
for (let i = 0; i <= end; i++)
|
|
388
|
+
if (raw[i] === 0x0a)
|
|
389
|
+
lines += 1;
|
|
390
|
+
this.#loaded.set(sessionId, { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, committed: end + 1, lines, records });
|
|
391
|
+
return records.slice();
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The log read again only from where the last load stopped, when the file is the same file (device and inode) and has
|
|
395
|
+
* only grown past that point, which is how this store writes it: append-only, a torn tail truncated. Anything else (a
|
|
396
|
+
* shorter file, another inode, the same size with a new mtime, a last committed byte that is not a newline) is read whole
|
|
397
|
+
* by the strict path below, so a cached read never stands in for one that would have refused. Each caller gets its own
|
|
398
|
+
* array; the records themselves are shared and never mutated.
|
|
399
|
+
*/
|
|
400
|
+
#loadTail(sessionId, path) {
|
|
401
|
+
const held = this.#loaded.get(sessionId);
|
|
402
|
+
if (held === undefined)
|
|
403
|
+
return null;
|
|
404
|
+
let st;
|
|
405
|
+
try {
|
|
406
|
+
st = statSync(path);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
if (st.dev !== held.dev || st.ino !== held.ino || st.size < held.committed)
|
|
412
|
+
return null;
|
|
413
|
+
if (st.size === held.size && st.mtimeMs === held.mtimeMs)
|
|
414
|
+
return held.records.slice();
|
|
415
|
+
if (st.size === held.size)
|
|
416
|
+
return null;
|
|
417
|
+
const fd = openSync(path, "r");
|
|
418
|
+
let chunk;
|
|
419
|
+
try {
|
|
420
|
+
const from = held.committed - 1;
|
|
421
|
+
chunk = Buffer.alloc(st.size - from);
|
|
422
|
+
let got = 0;
|
|
423
|
+
while (got < chunk.length) {
|
|
424
|
+
const n = readSync(fd, chunk, got, chunk.length - got, from + got);
|
|
425
|
+
if (n === 0)
|
|
426
|
+
break;
|
|
427
|
+
got += n;
|
|
428
|
+
}
|
|
429
|
+
chunk = chunk.subarray(0, got);
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
closeSync(fd);
|
|
433
|
+
}
|
|
434
|
+
if (held.committed > 0 && chunk[0] !== 0x0a)
|
|
435
|
+
return null; // the byte before the new part must end a committed line
|
|
436
|
+
const body = held.committed > 0 ? chunk.subarray(1) : chunk;
|
|
437
|
+
const end = body.lastIndexOf(0x0a);
|
|
438
|
+
const records = held.records.slice();
|
|
439
|
+
let line = held.lines;
|
|
440
|
+
if (end >= 0) {
|
|
441
|
+
for (const text of body.subarray(0, end).toString("utf8").split("\n")) {
|
|
442
|
+
line += 1;
|
|
443
|
+
if (text === "")
|
|
444
|
+
continue;
|
|
445
|
+
let parsed;
|
|
446
|
+
try {
|
|
447
|
+
parsed = JSON.parse(text);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return null; // the strict path names it
|
|
451
|
+
}
|
|
452
|
+
if (!isRecord(parsed) || parsed.event.seq !== records.length)
|
|
453
|
+
return null;
|
|
454
|
+
records.push(parsed);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
this.#loaded.set(sessionId, { dev: st.dev, ino: st.ino, size: st.size, mtimeMs: st.mtimeMs, committed: held.committed + (end >= 0 ? end + 1 : 0), lines: line, records });
|
|
458
|
+
return records.slice();
|
|
459
|
+
}
|
|
460
|
+
#loadWhole(raw) {
|
|
372
461
|
const lines = raw.split("\n");
|
|
373
462
|
const nonEmpty = [];
|
|
374
463
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -415,6 +504,12 @@ export class SessionStore {
|
|
|
415
504
|
* needs only the ids must never pay that. An empty log is skipped, as
|
|
416
505
|
* list() skips it.
|
|
417
506
|
*/
|
|
507
|
+
/** Fork `parent` after the run that ended at `afterSeq` (absent: its last finished run): a new session with the
|
|
508
|
+
* conversation up to there and nothing after (fork.ts). Throws ForkRefusedError, in words, when it cannot. */
|
|
509
|
+
fork(parent, options) {
|
|
510
|
+
this.pathFor(parent); // the id is checked before any file is read
|
|
511
|
+
return forkSession(this.root, parent, options?.afterSeq);
|
|
512
|
+
}
|
|
418
513
|
ids() {
|
|
419
514
|
const out = [];
|
|
420
515
|
for (const entry of readdirSync(this.root)) {
|
|
@@ -449,6 +544,7 @@ export class SessionStore {
|
|
|
449
544
|
runs: new Set(records.map((r) => r.runId)).size,
|
|
450
545
|
createdAt: records[0]?.ts ?? 0,
|
|
451
546
|
updatedAt: records.at(-1)?.ts ?? 0,
|
|
547
|
+
...((origin) => (origin === null ? {} : { forkedFrom: origin.session }))(readForkOrigin(this.root, id)),
|
|
452
548
|
});
|
|
453
549
|
}
|
|
454
550
|
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.54.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.54.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.54.0",
|
|
32
|
+
"@vincemakes/kiso-provider-openai": "0.54.0",
|
|
33
|
+
"@vincemakes/kiso-provider-openai-responses": "0.54.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.54.0",
|
|
48
48
|
"@types/node": "^26.1.2",
|
|
49
49
|
"typescript": "^5.7.2",
|
|
50
50
|
"vitest": "^3.0.0"
|