@vincemakes/kiso-web 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +50 -2
- package/dist/main/agent.js +80 -0
- package/dist/main/approvals.js +68 -0
- package/dist/main/artifacts.js +166 -0
- package/dist/main/auth.js +109 -0
- package/dist/main/context.js +20 -0
- package/dist/main/desk-api.js +249 -0
- package/dist/main/env.js +104 -0
- package/dist/main/faux.js +12 -0
- package/dist/main/files.js +156 -0
- package/dist/main/folder-info.js +36 -0
- package/dist/main/folders.js +102 -0
- package/dist/main/fork.js +25 -0
- package/dist/main/fs-routes.js +69 -0
- package/dist/main/git.js +117 -0
- package/dist/main/headless.js +128 -0
- package/dist/main/history-worker.js +18 -0
- package/dist/main/history.js +151 -0
- package/dist/main/host.js +210 -0
- package/dist/main/login.js +27 -0
- package/dist/main/names.js +15 -0
- package/dist/main/observed-revision.js +69 -0
- package/dist/main/preview.js +141 -0
- package/dist/main/sanitize.js +28 -0
- package/dist/main/settings.js +242 -0
- package/dist/main/space.js +198 -0
- package/dist/main/tasks.js +92 -0
- package/dist/main/times.js +28 -0
- package/dist/main/usage.js +102 -0
- package/dist/main/works.js +21 -0
- package/dist/shared/files.js +2 -0
- package/dist/shared/frames.js +13 -0
- package/dist/shared/git.js +2 -0
- package/dist/shared/history.js +5 -0
- package/dist/shared/settings.js +2 -0
- package/out/renderer/assets/index-C3fCoBdq.css +1 -0
- package/out/renderer/assets/index-DiDTmq9b.js +45 -0
- package/out/renderer/favicon-32.png +0 -0
- package/out/renderer/favicon.svg +1 -0
- package/out/renderer/icon.png +0 -0
- package/out/renderer/index.html +18 -0
- package/out/renderer/manifest.webmanifest +10 -0
- package/package.json +64 -5
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { applyEffortDeclarations, contextWindowOf, demoScript, authForProfile, effortOptions, loadUserConfig, mergeConfigs, preferences, profileAvailable, rememberEffort, startingEffort, } from "@vincemakes/kiso-code/config";
|
|
4
|
+
import { MODE_LABEL, OFFERED_MODES } from "@vincemakes/kiso-code/policy";
|
|
5
|
+
import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
|
|
6
|
+
import { fauxSkip } from "./faux.js";
|
|
7
|
+
import { storeKey } from "./login.js";
|
|
8
|
+
const MAX_RECENT = 10;
|
|
9
|
+
const SAFE_SESSION = /^[A-Za-z0-9._-]+$/;
|
|
10
|
+
/** Whether the model table says the profile's model reads images; unknown is no (nothing is sent that might be dropped). */
|
|
11
|
+
const readsImages = (p) => lookupModelMetadata(p.model, p.baseUrl)?.capabilities.inputModalities?.includes("image") === true;
|
|
12
|
+
const isTier = (value) => OFFERED_MODES.some((m) => m === value);
|
|
13
|
+
export class Settings {
|
|
14
|
+
#file;
|
|
15
|
+
/** a scripted model (the proofs); the shared configuration is then not read */
|
|
16
|
+
#fixed;
|
|
17
|
+
/** the tier a Work starts with when nothing was set for it */
|
|
18
|
+
#default;
|
|
19
|
+
/** the scripted tour, once started: a factory for its model (the demo script after the turns a Work has used) */
|
|
20
|
+
#demo = null;
|
|
21
|
+
constructor(home, fixed, mode) {
|
|
22
|
+
this.#file = join(home, "desktop.json");
|
|
23
|
+
this.#fixed = fixed;
|
|
24
|
+
const saved = this.#read().mode;
|
|
25
|
+
this.#default = isTier(saved) ? saved : mode;
|
|
26
|
+
}
|
|
27
|
+
/** The tier of one Work (read by the policy on every decision), else the one new Works start with. */
|
|
28
|
+
tierOf(sessionId) {
|
|
29
|
+
const own = sessionId === undefined ? undefined : this.#read().tiers?.[sessionId];
|
|
30
|
+
return isTier(own) ? own : this.#default;
|
|
31
|
+
}
|
|
32
|
+
/** Folders the person picked, newest first; and one more picked now. */
|
|
33
|
+
recents() {
|
|
34
|
+
return this.#read().recent ?? [];
|
|
35
|
+
}
|
|
36
|
+
addRecent(path) {
|
|
37
|
+
this.write({ recent: [path, ...this.recents().filter((p) => p !== path)].slice(0, MAX_RECENT) });
|
|
38
|
+
}
|
|
39
|
+
/** Start the scripted tour: no key, the terminal's own demo script, for Works opened from now on. */
|
|
40
|
+
async startDemo() {
|
|
41
|
+
const { createFauxProvider } = await import("@vincemakes/kiso-evals");
|
|
42
|
+
this.#demo = (skip) => ({ kind: "adapter", adapter: createFauxProvider(demoScript().slice(skip)), model: "demo", route: "adapter" });
|
|
43
|
+
}
|
|
44
|
+
#read() {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(this.#file, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Merge into `desktop.json` (the folder memory lives there too). */
|
|
53
|
+
write(patch) {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(join(this.#file, ".."), { recursive: true, mode: 0o700 });
|
|
56
|
+
writeFileSync(this.#file, `${JSON.stringify({ ...this.#read(), ...patch })}\n`, { mode: 0o600 });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// a read-only home only loses the memory
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lastFolder() {
|
|
63
|
+
return this.#read().lastFolder;
|
|
64
|
+
}
|
|
65
|
+
#config() {
|
|
66
|
+
const merged = mergeConfigs(loadUserConfig(), null);
|
|
67
|
+
applyEffortDeclarations(merged);
|
|
68
|
+
return merged;
|
|
69
|
+
}
|
|
70
|
+
/** The profile in use: the Work's own pick, else the person's pick for new Works, if it still exists, else the configuration's own default. */
|
|
71
|
+
#chosen(config, session) {
|
|
72
|
+
const models = config.models ?? {};
|
|
73
|
+
const own = session === undefined ? undefined : this.#read().works?.[session]?.profile;
|
|
74
|
+
if (own !== undefined && models[own] !== undefined)
|
|
75
|
+
return own;
|
|
76
|
+
const picked = this.#read().profile;
|
|
77
|
+
if (picked !== undefined && models[picked] !== undefined)
|
|
78
|
+
return picked;
|
|
79
|
+
return config.model !== undefined && models[config.model] !== undefined ? config.model : null;
|
|
80
|
+
}
|
|
81
|
+
/** The model a Work opened now runs on; throws, in words, when none can. */
|
|
82
|
+
model(ctx) {
|
|
83
|
+
if (this.#fixed !== null)
|
|
84
|
+
return this.#fixed;
|
|
85
|
+
if (this.#demo !== null)
|
|
86
|
+
return this.#demo(ctx === undefined ? 0 : fauxSkip(ctx.sessionsDir, ctx.sessionId));
|
|
87
|
+
const config = this.#config();
|
|
88
|
+
const name = this.#chosen(config, ctx?.sessionId);
|
|
89
|
+
if (name === null)
|
|
90
|
+
throw new Error("no model is configured: add a profile to ~/.kiso/config.json (see the terminal's docs) and choose it in Settings");
|
|
91
|
+
const profile = config.models[name];
|
|
92
|
+
const auth = authForProfile(name, profile);
|
|
93
|
+
const effort = startingEffort(profile, this.#effortOf(name, ctx?.sessionId));
|
|
94
|
+
return {
|
|
95
|
+
kind: "profile",
|
|
96
|
+
name,
|
|
97
|
+
profile,
|
|
98
|
+
auth: auth.type === "oauth" ? { type: "oauth", providerId: auth.providerId } : { type: "api-key", apiKey: auth.apiKey },
|
|
99
|
+
contextWindow: contextWindowOf(profile, config) ?? undefined,
|
|
100
|
+
...(effort !== null ? { reasoning: { thinking: "default", effort } } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** The effort a Work asked for (when it is one of the profile's levels), else the terminal's remembered one for the profile. */
|
|
104
|
+
#effortOf(name, session) {
|
|
105
|
+
const own = session === undefined ? undefined : this.#read().works?.[session]?.effort;
|
|
106
|
+
return own ?? preferences().effort?.[name];
|
|
107
|
+
}
|
|
108
|
+
/** A Work's own model choice (to put back when its rebuild is refused). */
|
|
109
|
+
workOf(session) {
|
|
110
|
+
return this.#read().works?.[session];
|
|
111
|
+
}
|
|
112
|
+
/** A fork starts with its parent's own tier, model and effort, and remembers where it came from. */
|
|
113
|
+
forked(parent, child, fork) {
|
|
114
|
+
const own = this.#read();
|
|
115
|
+
const tier = own.tiers?.[parent];
|
|
116
|
+
const work = own.works?.[parent];
|
|
117
|
+
this.write({
|
|
118
|
+
...(tier !== undefined ? { tiers: { ...own.tiers, [child]: tier } } : {}),
|
|
119
|
+
...(work !== undefined ? { works: { ...own.works, [child]: work } } : {}),
|
|
120
|
+
forks: { ...own.forks, [child]: fork },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
forkOf(session) {
|
|
124
|
+
return this.#read().forks?.[session];
|
|
125
|
+
}
|
|
126
|
+
/** Pin or archive a Work (a pin goes first), or take it off. */
|
|
127
|
+
mark(session, list, on) {
|
|
128
|
+
const rest = (this.#read()[list] ?? []).filter((id) => id !== session);
|
|
129
|
+
this.write({ [list]: on ? [session, ...rest] : rest });
|
|
130
|
+
}
|
|
131
|
+
/** What the sidebar shows of a Work beyond its log: the fork's parent, its pin's place, whether it is archived. */
|
|
132
|
+
marksOf(session) {
|
|
133
|
+
const own = this.#read();
|
|
134
|
+
const parent = own.forks?.[session]?.parent;
|
|
135
|
+
const pinned = own.pins?.indexOf(session) ?? -1;
|
|
136
|
+
return { ...(parent !== undefined ? { parent } : {}), ...(pinned >= 0 ? { pinned } : {}), ...(own.archived?.includes(session) ? { archived: true } : {}) };
|
|
137
|
+
}
|
|
138
|
+
/** A deleted Work leaves nothing here; a fork's record of it stays (its notice still names the parent). */
|
|
139
|
+
forget(session) {
|
|
140
|
+
const own = this.#read();
|
|
141
|
+
const drop = (m) => Object.fromEntries(Object.entries(m ?? {}).filter(([k]) => k !== session));
|
|
142
|
+
this.write({ tiers: drop(own.tiers), works: drop(own.works), forks: drop(own.forks), pins: (own.pins ?? []).filter((id) => id !== session), archived: (own.archived ?? []).filter((id) => id !== session) });
|
|
143
|
+
}
|
|
144
|
+
restoreWork(session, work) {
|
|
145
|
+
const { [session]: _, ...rest } = this.#read().works ?? {};
|
|
146
|
+
this.write({ works: work === undefined ? rest : { ...rest, [session]: work } });
|
|
147
|
+
}
|
|
148
|
+
/** The person's own test-runner commands (`checks` in the configuration), for the compaction phase rule. */
|
|
149
|
+
checks() {
|
|
150
|
+
return Object.values(this.#config().checks ?? {});
|
|
151
|
+
}
|
|
152
|
+
/** The names of the environment variables that hold a key: no shell child may inherit them. */
|
|
153
|
+
secretEnvNames() {
|
|
154
|
+
return Object.values(this.#config().models ?? {}).flatMap((p) => (p.apiKeyEnv === undefined ? [] : [p.apiKeyEnv]));
|
|
155
|
+
}
|
|
156
|
+
/** What the home screen should offer: nothing, a key for a profile, a profile to add, or the tour already on. */
|
|
157
|
+
#setup(config, name) {
|
|
158
|
+
if (this.#fixed !== null)
|
|
159
|
+
return "ready";
|
|
160
|
+
if (this.#demo !== null)
|
|
161
|
+
return "demo";
|
|
162
|
+
const models = config.models ?? {};
|
|
163
|
+
if (Object.keys(models).length === 0)
|
|
164
|
+
return "noprofile";
|
|
165
|
+
return name !== null && profileAvailable(models[name]) ? "ready" : "signin";
|
|
166
|
+
}
|
|
167
|
+
/** Store a key for a profile (the terminal's credential file); a sign-in ends the tour. */
|
|
168
|
+
saveKey(profile, key) {
|
|
169
|
+
storeKey(this.#config().models?.[profile], key);
|
|
170
|
+
this.#demo = null;
|
|
171
|
+
if (this.#read().profile === undefined)
|
|
172
|
+
this.write({ profile });
|
|
173
|
+
}
|
|
174
|
+
snapshot(session) {
|
|
175
|
+
const config = this.#config();
|
|
176
|
+
const models = config.models ?? {};
|
|
177
|
+
const name = this.#chosen(config, session);
|
|
178
|
+
const profile = name === null ? undefined : models[name];
|
|
179
|
+
const levels = profile === undefined ? null : effortOptions(profile);
|
|
180
|
+
return {
|
|
181
|
+
profiles: Object.entries(models).map(([n, p]) => ({ name: n, model: p.model, available: profileAvailable(p), images: readsImages(p) })),
|
|
182
|
+
profile: name,
|
|
183
|
+
effort: levels === null || name === null ? null : { ...levels, current: startingEffort(profile, this.#effortOf(name, session)) },
|
|
184
|
+
mode: this.tierOf(session),
|
|
185
|
+
setup: this.#setup(config, name),
|
|
186
|
+
modes: OFFERED_MODES.map((value) => ({ value, label: MODE_LABEL[value] })),
|
|
187
|
+
signIn: name === null ? { ok: false, text: "No model is configured." } : signInOf(name, profile),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Apply a patch; returns the Work whose model binding it changed (the host rebuilds that Work), else null. */
|
|
191
|
+
apply(patch) {
|
|
192
|
+
const config = this.#config();
|
|
193
|
+
const session = patch.session !== undefined && SAFE_SESSION.test(patch.session) ? patch.session : undefined;
|
|
194
|
+
if (session !== undefined && (patch.profile !== undefined || patch.effort !== undefined)) {
|
|
195
|
+
// one Work's model: its own profile and effort, the new-Work default untouched
|
|
196
|
+
const was = this.workOf(session) ?? {};
|
|
197
|
+
const profileName = patch.profile !== undefined && config.models?.[patch.profile] !== undefined ? patch.profile : (was.profile ?? this.#chosen(config, session) ?? undefined);
|
|
198
|
+
const profile = profileName === undefined ? undefined : config.models?.[profileName];
|
|
199
|
+
const effort = patch.effort !== undefined && profile !== undefined && effortOptions(profile)?.levels.includes(patch.effort) === true ? patch.effort : patch.profile !== undefined ? undefined : was.effort;
|
|
200
|
+
const next = { ...(profileName !== undefined ? { profile: profileName } : {}), ...(effort !== undefined ? { effort } : {}) };
|
|
201
|
+
if (next.profile === was.profile && next.effort === was.effort)
|
|
202
|
+
return null;
|
|
203
|
+
this.write({ works: { ...this.#read().works, [session]: next } });
|
|
204
|
+
return session;
|
|
205
|
+
}
|
|
206
|
+
if (patch.profile !== undefined && config.models?.[patch.profile] !== undefined) {
|
|
207
|
+
this.write({ profile: patch.profile });
|
|
208
|
+
// choosing a profile that can run ends the tour
|
|
209
|
+
if (profileAvailable(config.models[patch.profile]))
|
|
210
|
+
this.#demo = null;
|
|
211
|
+
}
|
|
212
|
+
const name = patch.profile ?? this.#chosen(config);
|
|
213
|
+
const profile = name === null ? undefined : config.models?.[name];
|
|
214
|
+
if (patch.effort !== undefined && name !== null && profile !== undefined && effortOptions(profile)?.levels.includes(patch.effort) === true)
|
|
215
|
+
rememberEffort(name, patch.effort);
|
|
216
|
+
if (isTier(patch.mode)) {
|
|
217
|
+
if (patch.session === undefined) {
|
|
218
|
+
this.#default = patch.mode;
|
|
219
|
+
this.write({ mode: patch.mode });
|
|
220
|
+
}
|
|
221
|
+
else if (SAFE_SESSION.test(patch.session))
|
|
222
|
+
this.write({ tiers: { ...this.#read().tiers, [patch.session]: patch.mode } });
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Where a profile's sign-in comes from, in words; the secret is never part of it. */
|
|
228
|
+
function signInOf(name, profile) {
|
|
229
|
+
try {
|
|
230
|
+
const auth = authForProfile(name, profile);
|
|
231
|
+
if (auth.type === "oauth")
|
|
232
|
+
return { ok: true, text: `Signed in with ${auth.providerId} (shared with the terminal)` };
|
|
233
|
+
if (auth.source === "store")
|
|
234
|
+
return { ok: true, text: "Signed in: a key stored by the terminal's login" };
|
|
235
|
+
if (auth.source === "env")
|
|
236
|
+
return { ok: true, text: `Using the key in the environment variable ${profile.apiKeyEnv ?? ""}` };
|
|
237
|
+
return { ok: true, text: "This endpoint needs no key" };
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return { ok: false, text: error instanceof Error ? error.message : String(error) };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { SessionStore } from "@vincemakes/kiso-runtime";
|
|
3
|
+
import { named, titleIfUnnamed } from "./names.js";
|
|
4
|
+
import { createSessionService, toWireEvent } from "@vincemakes/kiso-server";
|
|
5
|
+
import { createHttpHandler } from "@vincemakes/kiso-server/http";
|
|
6
|
+
import { createWebAgent, routeOf } from "./agent.js";
|
|
7
|
+
import { createTaskHub, rowsOf } from "./tasks.js";
|
|
8
|
+
import { runRate, runTotals, usageFrameAt } from "./usage.js";
|
|
9
|
+
import { webSanitize } from "./sanitize.js";
|
|
10
|
+
import { TimeIndex } from "./times.js";
|
|
11
|
+
import { artifactsOf, LogImages } from "./artifacts.js";
|
|
12
|
+
const HEAD_LINES = 12;
|
|
13
|
+
const DELTAS = new Set(["thinking", "text_delta"]);
|
|
14
|
+
export function createSpace(options) {
|
|
15
|
+
const store = new SessionStore(options.dir);
|
|
16
|
+
const tasks = createTaskHub(options.dir);
|
|
17
|
+
// the Work's own route (a Work can switch its model in the composer): what its usage is canonicalized and priced on
|
|
18
|
+
const route = (sessionId) => {
|
|
19
|
+
try {
|
|
20
|
+
return routeOf(options.model({ sessionId, sessionsDir: options.dir }));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const opened = new Set();
|
|
27
|
+
const sessions = new Map();
|
|
28
|
+
const followUps = new Map();
|
|
29
|
+
// owner 2026-11-03: a Work is titled by a summary of its first message, once, beside its first run; the person's
|
|
30
|
+
// own name wins, before or after, and a failure leaves the first request's title. A first message sent in the last
|
|
31
|
+
// minute, not "a run is live": a quick run can end before its first event is seen here, and a replayed old one is old
|
|
32
|
+
const titled = new Set();
|
|
33
|
+
const title = (sessionId, event) => {
|
|
34
|
+
const session = sessions.get(sessionId);
|
|
35
|
+
if (options.titles !== true || session === undefined || titled.has(sessionId) || Date.now() - times.at(sessionId, event.seq) > 60_000)
|
|
36
|
+
return;
|
|
37
|
+
if (service.events(sessionId).some((e) => e.type === "user_input" && e.seq < event.seq) || named(options.dir, sessionId))
|
|
38
|
+
return;
|
|
39
|
+
titled.add(sessionId);
|
|
40
|
+
const text = typeof event.content === "string" ? event.content : event.content.map((b) => (b.type === "text" ? b.text : "")).join(" ");
|
|
41
|
+
void session.title(text, { signal: AbortSignal.timeout(15_000) }).then((name) => titleIfUnnamed(options.dir, sessionId, name), () => undefined);
|
|
42
|
+
};
|
|
43
|
+
const followUp = (sessionId, seq) => {
|
|
44
|
+
const session = sessions.get(sessionId);
|
|
45
|
+
const end = [...service.events(sessionId)].reverse().find((e) => e.type === "terminal");
|
|
46
|
+
if (options.followUps !== true || session === undefined || service.isRunning(sessionId) || end?.seq !== seq || end.outcome.kind !== "completed")
|
|
47
|
+
return Promise.resolve("");
|
|
48
|
+
const at = `${sessionId}:${seq}`;
|
|
49
|
+
if (!followUps.has(at))
|
|
50
|
+
followUps.set(at, session.followUp({ maxTokens: 200 }).catch(() => ""));
|
|
51
|
+
return followUps.get(at);
|
|
52
|
+
};
|
|
53
|
+
const service = createSessionService({
|
|
54
|
+
store,
|
|
55
|
+
open: async (sessionId) => {
|
|
56
|
+
opened.add(sessionId);
|
|
57
|
+
return createWebAgent({
|
|
58
|
+
store,
|
|
59
|
+
workspace: options.workspace,
|
|
60
|
+
model: options.model({ sessionId, sessionsDir: options.dir }),
|
|
61
|
+
tier: () => options.tierOf(sessionId),
|
|
62
|
+
sessionId,
|
|
63
|
+
checkRunners: options.checkRunners(),
|
|
64
|
+
tasks,
|
|
65
|
+
...(options.secretEnvNames !== undefined ? { secretEnvNames: options.secretEnvNames } : {}),
|
|
66
|
+
...(options.toolLimits !== undefined ? { toolLimits: options.toolLimits } : {}),
|
|
67
|
+
onSession: (sessionId, session) => {
|
|
68
|
+
sessions.set(sessionId, session);
|
|
69
|
+
// the model hears how a task ended; nothing wakes an idle session
|
|
70
|
+
session.useTasks(tasks.managerFor(sessionId), { wake: false });
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
// the Work's own model (a Work can switch it in the composer), for its context window
|
|
76
|
+
const windowOf = (sessionId) => {
|
|
77
|
+
try {
|
|
78
|
+
const m = options.model({ sessionId, sessionsDir: options.dir });
|
|
79
|
+
return m.kind === "profile" ? (m.contextWindow ?? null) : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const times = new TimeIndex(store);
|
|
86
|
+
const augment = (event, sessionId) => {
|
|
87
|
+
const frames = [];
|
|
88
|
+
// when it happened: for every wire event but the two delta types, which are too many
|
|
89
|
+
if (!DELTAS.has(event.type) && toWireEvent(event) !== null) {
|
|
90
|
+
const at = { seq: event.seq, ts: times.at(sessionId, event.seq) };
|
|
91
|
+
frames.push({ event: "at", data: at });
|
|
92
|
+
}
|
|
93
|
+
if (event.type === "user_input")
|
|
94
|
+
title(sessionId, event);
|
|
95
|
+
frames.push(...derived(event, sessionId));
|
|
96
|
+
return frames;
|
|
97
|
+
};
|
|
98
|
+
/** The tool, input and execution start of the call a result answers, from the log before it. */
|
|
99
|
+
const callOf = (sessionId, callId) => {
|
|
100
|
+
let call = null;
|
|
101
|
+
let startedAt = null;
|
|
102
|
+
for (const e of service.events(sessionId)) {
|
|
103
|
+
if (e.type === "tool_call_end" && e.callId === callId)
|
|
104
|
+
call = { name: e.name, input: e.input };
|
|
105
|
+
if (e.type === "tool_execution_started" && e.callId === callId)
|
|
106
|
+
startedAt = times.at(sessionId, e.seq);
|
|
107
|
+
}
|
|
108
|
+
return call === null ? null : { ...call, startedAt };
|
|
109
|
+
};
|
|
110
|
+
const realWorkspace = () => {
|
|
111
|
+
try {
|
|
112
|
+
return realpathSync(options.workspace);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return options.workspace;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const derived = (event, sessionId) => {
|
|
119
|
+
if (event.type === "usage") {
|
|
120
|
+
const frame = usageFrameAt(service.events(sessionId), event.seq, route(sessionId));
|
|
121
|
+
return frame === null ? [] : [{ event: "usage", data: { ...frame, window: windowOf(sessionId) } }];
|
|
122
|
+
}
|
|
123
|
+
if (event.type === "terminal") {
|
|
124
|
+
const events = service.events(sessionId);
|
|
125
|
+
const totals = runTotals(events, event.seq, route(sessionId));
|
|
126
|
+
return totals === null ? [] : [{ event: "run_usage", data: { seq: event.seq, ...totals, tokPerSec: runRate(events, event.seq, totals.output, (seq) => times.at(sessionId, seq)) } }];
|
|
127
|
+
}
|
|
128
|
+
if (event.type === "tool_result") {
|
|
129
|
+
const artifacts = artifactsOf(event, sessionId, callOf(sessionId, event.callId), realWorkspace(), options.dir);
|
|
130
|
+
const text = typeof event.content === "string" ? event.content : event.content.map((b) => (b.type === "text" ? b.text : `[${b.type}]`)).join("\n");
|
|
131
|
+
const lines = text.split("\n");
|
|
132
|
+
const frame = {
|
|
133
|
+
seq: event.seq,
|
|
134
|
+
callId: event.callId,
|
|
135
|
+
isError: event.isError,
|
|
136
|
+
head: lines.slice(0, HEAD_LINES),
|
|
137
|
+
lines: lines.length,
|
|
138
|
+
bytes: Buffer.byteLength(text),
|
|
139
|
+
truncated: lines.length > HEAD_LINES,
|
|
140
|
+
...((event.tags ?? []).includes("created") ? { created: true } : {}),
|
|
141
|
+
...(artifacts.length > 0 ? { artifacts } : {}),
|
|
142
|
+
};
|
|
143
|
+
return [{ event: "tool_output", data: frame }];
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
};
|
|
147
|
+
// The tasks frame: not tied to any event (a command can end while nothing
|
|
148
|
+
// else happens), so it is the host's own source, polled while a stream is open.
|
|
149
|
+
const frames = (sessionId, push) => {
|
|
150
|
+
let last = "";
|
|
151
|
+
const tick = () => {
|
|
152
|
+
try {
|
|
153
|
+
const rows = rowsOf(tasks.managerFor(sessionId));
|
|
154
|
+
// elapsed time of a running task changes every tick; the state does not
|
|
155
|
+
const key = JSON.stringify(rows.map((r) => [r.id, r.label]));
|
|
156
|
+
const running = rows.some((r) => r.endedAt === null);
|
|
157
|
+
if (key !== last || running) {
|
|
158
|
+
last = key;
|
|
159
|
+
const frame = { at: Date.now(), tasks: rows };
|
|
160
|
+
push({ event: "tasks", data: frame });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// a corrupt journal is the terminal's to report; the stream goes on
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
tick();
|
|
168
|
+
const timer = setInterval(tick, 1_000);
|
|
169
|
+
timer.unref();
|
|
170
|
+
return () => clearInterval(timer);
|
|
171
|
+
};
|
|
172
|
+
const handler = createHttpHandler(service, {
|
|
173
|
+
prefix: options.prefix,
|
|
174
|
+
// the host's guard has already run: bearer token, Host and Origin
|
|
175
|
+
authorize: () => true,
|
|
176
|
+
augment,
|
|
177
|
+
frames,
|
|
178
|
+
projection: { exposeThinking: options.exposeThinking, ...(options.sanitizer === "default" ? {} : { sanitize: webSanitize }) },
|
|
179
|
+
...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
key: options.key,
|
|
183
|
+
workspace: options.workspace,
|
|
184
|
+
dir: options.dir,
|
|
185
|
+
store,
|
|
186
|
+
service,
|
|
187
|
+
tasks,
|
|
188
|
+
images: new LogImages(options.dir),
|
|
189
|
+
handler,
|
|
190
|
+
opened: () => opened,
|
|
191
|
+
followUp,
|
|
192
|
+
drain: (graceMs) => service.drain(graceMs),
|
|
193
|
+
close: async () => {
|
|
194
|
+
await tasks.close();
|
|
195
|
+
store.closeAll();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background tasks (ADR-0058) for the web app's sessions.
|
|
3
|
+
*
|
|
4
|
+
* THE ONE FILE that reaches behind the public surface: the TaskManager and
|
|
5
|
+
* its journal types are exported only from `@vincemakes/kiso-runtime/internal`
|
|
6
|
+
* today. scripts/check-imports.mjs allows exactly this file to do so, and
|
|
7
|
+
* the spike report proposes the root export that removes the exception.
|
|
8
|
+
*/
|
|
9
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { TaskManager } from "@vincemakes/kiso-runtime/internal";
|
|
12
|
+
import { processTaskBackend } from "@vincemakes/kiso-tools-node";
|
|
13
|
+
export { TaskManager };
|
|
14
|
+
export function createTaskHub(storeRoot) {
|
|
15
|
+
// the runner is started with process.execPath: the host is plain node, so the runner is too
|
|
16
|
+
const backend = processTaskBackend();
|
|
17
|
+
const managers = new Map();
|
|
18
|
+
const managerFor = (sessionId) => {
|
|
19
|
+
let manager = managers.get(sessionId);
|
|
20
|
+
if (manager === undefined) {
|
|
21
|
+
manager = new TaskManager({ root: join(storeRoot, `${sessionId}.tasks`), backend });
|
|
22
|
+
manager.observe();
|
|
23
|
+
managers.set(sessionId, manager);
|
|
24
|
+
}
|
|
25
|
+
return manager;
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
// the manager satisfies ShellTasks structurally (tools-node takes no runtime dependency)
|
|
29
|
+
shellTasks: (sessionId) => (sessionId === undefined ? undefined : managerFor(sessionId)),
|
|
30
|
+
managerFor,
|
|
31
|
+
forget: (sessionId) => {
|
|
32
|
+
managers.get(sessionId)?.close();
|
|
33
|
+
managers.delete(sessionId);
|
|
34
|
+
},
|
|
35
|
+
close: async () => {
|
|
36
|
+
for (const manager of managers.values()) {
|
|
37
|
+
await manager.stopAll("exit", 2_000, "moved");
|
|
38
|
+
manager.close();
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** A task's state in a person's words (the terminal's `/tasks` wording). */
|
|
44
|
+
export function stateLabel(task) {
|
|
45
|
+
const s = task.state;
|
|
46
|
+
switch (s.kind) {
|
|
47
|
+
case "starting":
|
|
48
|
+
return "starting";
|
|
49
|
+
case "not_run":
|
|
50
|
+
return "never ran";
|
|
51
|
+
case "running":
|
|
52
|
+
return task.stoppedBy !== undefined ? "stopping" : s.ready ? "ready" : "running";
|
|
53
|
+
case "unknown":
|
|
54
|
+
return "lost track";
|
|
55
|
+
case "ended":
|
|
56
|
+
if (s.stopped)
|
|
57
|
+
return "stopped";
|
|
58
|
+
if (s.error !== undefined)
|
|
59
|
+
return "failed to start";
|
|
60
|
+
return s.exitCode === 0 ? "exited 0" : s.exitCode !== null ? `failed ${s.exitCode}` : `ended ${s.signal ?? ""}`.trim();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The last lines of a task's output file (read from its end, never whole). */
|
|
64
|
+
function tailOf(path, lines = 4) {
|
|
65
|
+
try {
|
|
66
|
+
const fd = openSync(path, "r");
|
|
67
|
+
try {
|
|
68
|
+
const size = fstatSync(fd).size;
|
|
69
|
+
const length = Math.min(size, 2048);
|
|
70
|
+
const buf = Buffer.alloc(length);
|
|
71
|
+
readSync(fd, buf, 0, length, size - length);
|
|
72
|
+
return buf.toString("utf8").split("\n").filter((l) => l !== "").slice(-lines);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function rowsOf(manager, now = Date.now()) {
|
|
83
|
+
return manager.list().map((t) => ({
|
|
84
|
+
id: t.id,
|
|
85
|
+
label: stateLabel(t),
|
|
86
|
+
command: t.command.split("\n")[0],
|
|
87
|
+
startedAt: t.startedAt,
|
|
88
|
+
endedAt: t.endedAt ?? null,
|
|
89
|
+
elapsedMs: (t.endedAt ?? now) - t.startedAt,
|
|
90
|
+
tail: tailOf(t.outputPath),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When each durable event happened. The wire events carry no timestamp; the
|
|
3
|
+
* durable records do (`StoreRecord.ts`), so durations ("thought for N s",
|
|
4
|
+
* a step's time, the footer's duration) are read here and sent as `at`
|
|
5
|
+
* frames. A session's records are read once; events that arrive later are
|
|
6
|
+
* stamped on arrival, which is the moment the store wrote them to within
|
|
7
|
+
* a few milliseconds.
|
|
8
|
+
*/
|
|
9
|
+
export class TimeIndex {
|
|
10
|
+
#store;
|
|
11
|
+
#bySession = new Map();
|
|
12
|
+
constructor(store) {
|
|
13
|
+
this.#store = store;
|
|
14
|
+
}
|
|
15
|
+
at(sessionId, seq) {
|
|
16
|
+
let known = this.#bySession.get(sessionId);
|
|
17
|
+
if (known === undefined) {
|
|
18
|
+
known = new Map(this.#store.load(sessionId).map((r) => [r.event.seq, r.ts]));
|
|
19
|
+
this.#bySession.set(sessionId, known);
|
|
20
|
+
}
|
|
21
|
+
let ts = known.get(seq);
|
|
22
|
+
if (ts === undefined) {
|
|
23
|
+
ts = Date.now();
|
|
24
|
+
known.set(seq, ts);
|
|
25
|
+
}
|
|
26
|
+
return ts;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
|
|
2
|
+
import { cacheRate } from "../shared/frames.js";
|
|
3
|
+
/**
|
|
4
|
+
* The run's usage, read off the durable log — `usage` is not on the wire.
|
|
5
|
+
*
|
|
6
|
+
* The same two rules the terminal's ledger states (apps/cli chat.ts,
|
|
7
|
+
* turnUsageLedger): a CALL is its LATEST report (a gateway may report one
|
|
8
|
+
* request twice), a RUN is the sum over its calls, and an unmeasured term
|
|
9
|
+
* makes the sum unmeasured. `stop` ends a call; `terminal` ends the run.
|
|
10
|
+
* The input is the canonical FRESH count, so the cache rate
|
|
11
|
+
* cache / (fresh + cache) can never pass 100%.
|
|
12
|
+
*/
|
|
13
|
+
const sum = (a, b) => (a === null || b === null ? null : a + b);
|
|
14
|
+
function fold(a, b) {
|
|
15
|
+
if (a === null)
|
|
16
|
+
return b;
|
|
17
|
+
if (b === null)
|
|
18
|
+
return a;
|
|
19
|
+
return { fresh: sum(a.fresh, b.fresh), output: sum(a.output, b.output), cacheRead: sum(a.cacheRead, b.cacheRead), costUsd: sum(a.costUsd, b.costUsd), known: a.known && b.known };
|
|
20
|
+
}
|
|
21
|
+
/** One durable usage event as numbers; `route` is the adapter identity the
|
|
22
|
+
* canonical mapping keys on ("openai-compat", "anthropic", "adapter"). */
|
|
23
|
+
export function callNumbers(route, ev) {
|
|
24
|
+
const c = canonicalizeUsage(route, ev);
|
|
25
|
+
return { fresh: c.input, output: c.output, cacheRead: ev.cacheRead === null ? null : c.cacheRead, costUsd: c.costUsd, known: ev.known };
|
|
26
|
+
}
|
|
27
|
+
/** The frame for the usage event at `seq`: the call, and the run so far. */
|
|
28
|
+
export function usageFrameAt(events, seq, route) {
|
|
29
|
+
let settled = null;
|
|
30
|
+
let inFlight = null;
|
|
31
|
+
let calls = 0;
|
|
32
|
+
for (const ev of events) {
|
|
33
|
+
if (ev.seq > seq)
|
|
34
|
+
break;
|
|
35
|
+
if (ev.type === "terminal") {
|
|
36
|
+
settled = null;
|
|
37
|
+
inFlight = null;
|
|
38
|
+
calls = 0;
|
|
39
|
+
}
|
|
40
|
+
else if (ev.type === "stop") {
|
|
41
|
+
settled = fold(settled, inFlight);
|
|
42
|
+
inFlight = null;
|
|
43
|
+
}
|
|
44
|
+
else if (ev.type === "usage") {
|
|
45
|
+
if (inFlight === null)
|
|
46
|
+
calls += 1;
|
|
47
|
+
inFlight = callNumbers(route, ev);
|
|
48
|
+
if (ev.seq === seq) {
|
|
49
|
+
const turn = fold(settled, inFlight);
|
|
50
|
+
return { seq, window: null, call: inFlight, turn: { ...turn, cacheRate: cacheRate(turn.fresh, turn.cacheRead), calls } };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** The run that ended at the terminal `seq`: its totals. */
|
|
57
|
+
export function runTotals(events, terminalSeq, route) {
|
|
58
|
+
let settled = null;
|
|
59
|
+
let inFlight = null;
|
|
60
|
+
let calls = 0;
|
|
61
|
+
for (const ev of events) {
|
|
62
|
+
if (ev.seq > terminalSeq)
|
|
63
|
+
break;
|
|
64
|
+
if (ev.type === "terminal") {
|
|
65
|
+
if (ev.seq === terminalSeq) {
|
|
66
|
+
const turn = fold(settled, inFlight);
|
|
67
|
+
return turn === null ? null : { ...turn, cacheRate: cacheRate(turn.fresh, turn.cacheRead), calls };
|
|
68
|
+
}
|
|
69
|
+
settled = inFlight = null;
|
|
70
|
+
calls = 0;
|
|
71
|
+
}
|
|
72
|
+
else if (ev.type === "stop") {
|
|
73
|
+
settled = fold(settled, inFlight);
|
|
74
|
+
inFlight = null;
|
|
75
|
+
}
|
|
76
|
+
else if (ev.type === "usage") {
|
|
77
|
+
if (inFlight === null)
|
|
78
|
+
calls += 1;
|
|
79
|
+
inFlight = callNumbers(route, ev);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const CALL_OUTPUT = new Set(["thinking", "text_start", "text_delta", "tool_call_start", "tool_call_end"]);
|
|
85
|
+
/** The run's decode rate as the terminal times one call (TPS-1), summed: each call from its first output event to its last usage
|
|
86
|
+
* report (tool time falls between calls), output over the summed time; null under 0.5 s or with no output ("0 tok/s" is no measurement). */
|
|
87
|
+
export function runRate(events, terminalSeq, output, at) {
|
|
88
|
+
const start = events.reduce((n, ev) => (ev.type === "terminal" && ev.seq < terminalSeq ? ev.seq : n), -1);
|
|
89
|
+
let [ms, first, last] = [0, -1, -1];
|
|
90
|
+
for (const ev of events) {
|
|
91
|
+
if (ev.seq <= start || ev.seq > terminalSeq)
|
|
92
|
+
continue;
|
|
93
|
+
if (CALL_OUTPUT.has(ev.type) && first < 0)
|
|
94
|
+
first = ev.seq;
|
|
95
|
+
else if (ev.type === "usage" && first >= 0)
|
|
96
|
+
last = ev.seq;
|
|
97
|
+
if (ev.type === "stop" || ev.seq === terminalSeq)
|
|
98
|
+
[ms, first, last] = [ms + (first >= 0 && last >= 0 ? Math.max(0, at(last) - at(first)) : 0), -1, -1];
|
|
99
|
+
}
|
|
100
|
+
const rate = output === null || ms < 500 ? 0 : Math.round(output / (ms / 1000));
|
|
101
|
+
return rate > 0 ? rate : null;
|
|
102
|
+
}
|