@vincemakes/kiso-web 0.0.0-stage → 0.52.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,175 @@
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 { fauxSkip } from "./faux.js";
6
+ import { storeKey } from "./login.js";
7
+ const MAX_RECENT = 10;
8
+ const isTier = (value) => OFFERED_MODES.some((m) => m === value);
9
+ export class Settings {
10
+ #file;
11
+ /** a scripted model (the proofs); the shared configuration is then not read */
12
+ #fixed;
13
+ /** the tier a Work starts with when nothing was set for it */
14
+ #default;
15
+ /** the scripted tour, once started: a factory for its model (the demo script after the turns a Work has used) */
16
+ #demo = null;
17
+ constructor(home, fixed, mode) {
18
+ this.#file = join(home, "desktop.json");
19
+ this.#fixed = fixed;
20
+ const saved = this.#read().mode;
21
+ this.#default = isTier(saved) ? saved : mode;
22
+ }
23
+ /** The tier of one Work (read by the policy on every decision), else the one new Works start with. */
24
+ tierOf(sessionId) {
25
+ const own = sessionId === undefined ? undefined : this.#read().tiers?.[sessionId];
26
+ return isTier(own) ? own : this.#default;
27
+ }
28
+ /** Folders the person picked, newest first; and one more picked now. */
29
+ recents() {
30
+ return this.#read().recent ?? [];
31
+ }
32
+ addRecent(path) {
33
+ this.write({ recent: [path, ...this.recents().filter((p) => p !== path)].slice(0, MAX_RECENT) });
34
+ }
35
+ /** Start the scripted tour: no key, the terminal's own demo script, for Works opened from now on. */
36
+ async startDemo() {
37
+ const { createFauxProvider } = await import("@vincemakes/kiso-evals");
38
+ this.#demo = (skip) => ({ kind: "adapter", adapter: createFauxProvider(demoScript().slice(skip)), model: "demo", route: "adapter" });
39
+ }
40
+ #read() {
41
+ try {
42
+ return JSON.parse(readFileSync(this.#file, "utf8"));
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ }
48
+ /** Merge into `desktop.json` (the folder memory lives there too). */
49
+ write(patch) {
50
+ try {
51
+ mkdirSync(join(this.#file, ".."), { recursive: true, mode: 0o700 });
52
+ writeFileSync(this.#file, `${JSON.stringify({ ...this.#read(), ...patch })}\n`, { mode: 0o600 });
53
+ }
54
+ catch {
55
+ // a read-only home only loses the memory
56
+ }
57
+ }
58
+ lastFolder() {
59
+ return this.#read().lastFolder;
60
+ }
61
+ #config() {
62
+ const merged = mergeConfigs(loadUserConfig(), null);
63
+ applyEffortDeclarations(merged);
64
+ return merged;
65
+ }
66
+ /** The profile in use: the person's pick if it still exists, else the configuration's own default. */
67
+ #chosen(config) {
68
+ const models = config.models ?? {};
69
+ const picked = this.#read().profile;
70
+ if (picked !== undefined && models[picked] !== undefined)
71
+ return picked;
72
+ return config.model !== undefined && models[config.model] !== undefined ? config.model : null;
73
+ }
74
+ /** The model a Work opened now runs on; throws, in words, when none can. */
75
+ model(ctx) {
76
+ if (this.#fixed !== null)
77
+ return this.#fixed;
78
+ if (this.#demo !== null)
79
+ return this.#demo(ctx === undefined ? 0 : fauxSkip(ctx.sessionsDir, ctx.sessionId));
80
+ const config = this.#config();
81
+ const name = this.#chosen(config);
82
+ if (name === null)
83
+ throw new Error("no model is configured: add a profile to ~/.kiso/config.json (see the terminal's docs) and choose it in Settings");
84
+ const profile = config.models[name];
85
+ const auth = authForProfile(name, profile);
86
+ const effort = startingEffort(profile, preferences().effort?.[name]);
87
+ return {
88
+ kind: "profile",
89
+ name,
90
+ profile,
91
+ auth: auth.type === "oauth" ? { type: "oauth", providerId: auth.providerId } : { type: "api-key", apiKey: auth.apiKey },
92
+ contextWindow: contextWindowOf(profile, config) ?? undefined,
93
+ ...(effort !== null ? { reasoning: { thinking: "default", effort } } : {}),
94
+ };
95
+ }
96
+ /** The person's own test-runner commands (`checks` in the configuration), for the compaction phase rule. */
97
+ checks() {
98
+ return Object.values(this.#config().checks ?? {});
99
+ }
100
+ /** The names of the environment variables that hold a key: no shell child may inherit them. */
101
+ secretEnvNames() {
102
+ return Object.values(this.#config().models ?? {}).flatMap((p) => (p.apiKeyEnv === undefined ? [] : [p.apiKeyEnv]));
103
+ }
104
+ /** What the home screen should offer: nothing, a key for a profile, a profile to add, or the tour already on. */
105
+ #setup(config, name) {
106
+ if (this.#fixed !== null)
107
+ return "ready";
108
+ if (this.#demo !== null)
109
+ return "demo";
110
+ const models = config.models ?? {};
111
+ if (Object.keys(models).length === 0)
112
+ return "noprofile";
113
+ return name !== null && profileAvailable(models[name]) ? "ready" : "signin";
114
+ }
115
+ /** Store a key for a profile (the terminal's credential file); a sign-in ends the tour. */
116
+ saveKey(profile, key) {
117
+ storeKey(this.#config().models?.[profile], key);
118
+ this.#demo = null;
119
+ if (this.#read().profile === undefined)
120
+ this.write({ profile });
121
+ }
122
+ snapshot(session) {
123
+ const config = this.#config();
124
+ const models = config.models ?? {};
125
+ const name = this.#chosen(config);
126
+ const profile = name === null ? undefined : models[name];
127
+ const levels = profile === undefined ? null : effortOptions(profile);
128
+ return {
129
+ profiles: Object.entries(models).map(([n, p]) => ({ name: n, model: p.model, available: profileAvailable(p) })),
130
+ profile: name,
131
+ effort: levels === null || name === null ? null : { ...levels, current: startingEffort(profile, preferences().effort?.[name]) },
132
+ mode: this.tierOf(session),
133
+ setup: this.#setup(config, name),
134
+ modes: OFFERED_MODES.map((value) => ({ value, label: MODE_LABEL[value] })),
135
+ signIn: name === null ? { ok: false, text: "No model is configured." } : signInOf(name, profile),
136
+ };
137
+ }
138
+ apply(patch) {
139
+ const config = this.#config();
140
+ if (patch.profile !== undefined && config.models?.[patch.profile] !== undefined) {
141
+ this.write({ profile: patch.profile });
142
+ // choosing a profile that can run ends the tour
143
+ if (profileAvailable(config.models[patch.profile]))
144
+ this.#demo = null;
145
+ }
146
+ const name = patch.profile ?? this.#chosen(config);
147
+ const profile = name === null ? undefined : config.models?.[name];
148
+ if (patch.effort !== undefined && name !== null && profile !== undefined && effortOptions(profile)?.levels.includes(patch.effort) === true)
149
+ rememberEffort(name, patch.effort);
150
+ if (isTier(patch.mode)) {
151
+ if (patch.session === undefined) {
152
+ this.#default = patch.mode;
153
+ this.write({ mode: patch.mode });
154
+ }
155
+ else if (/^[A-Za-z0-9._-]+$/.test(patch.session))
156
+ this.write({ tiers: { ...this.#read().tiers, [patch.session]: patch.mode } });
157
+ }
158
+ }
159
+ }
160
+ /** Where a profile's sign-in comes from, in words; the secret is never part of it. */
161
+ function signInOf(name, profile) {
162
+ try {
163
+ const auth = authForProfile(name, profile);
164
+ if (auth.type === "oauth")
165
+ return { ok: true, text: `Signed in with ${auth.providerId} (shared with the terminal)` };
166
+ if (auth.source === "store")
167
+ return { ok: true, text: "Signed in: a key stored by the terminal's login" };
168
+ if (auth.source === "env")
169
+ return { ok: true, text: `Using the key in the environment variable ${profile.apiKeyEnv ?? ""}` };
170
+ return { ok: true, text: "This endpoint needs no key" };
171
+ }
172
+ catch (error) {
173
+ return { ok: false, text: error instanceof Error ? error.message : String(error) };
174
+ }
175
+ }
@@ -0,0 +1,164 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { SessionStore } from "@vincemakes/kiso-runtime";
3
+ import { createSessionService, toWireEvent } from "@vincemakes/kiso-server";
4
+ import { createHttpHandler } from "@vincemakes/kiso-server/http";
5
+ import { createWebAgent, routeOf } from "./agent.js";
6
+ import { createTaskHub, rowsOf } from "./tasks.js";
7
+ import { runTotals, usageFrameAt } from "./usage.js";
8
+ import { webSanitize } from "./sanitize.js";
9
+ import { TimeIndex } from "./times.js";
10
+ import { artifactsOf, LogImages } from "./artifacts.js";
11
+ const HEAD_LINES = 12;
12
+ const DELTAS = new Set(["thinking", "text_delta"]);
13
+ export function createSpace(options) {
14
+ const store = new SessionStore(options.dir);
15
+ const tasks = createTaskHub(options.dir);
16
+ const route = () => {
17
+ try {
18
+ return routeOf(options.model());
19
+ }
20
+ catch {
21
+ return "unknown";
22
+ }
23
+ };
24
+ const opened = new Set();
25
+ const service = createSessionService({
26
+ store,
27
+ open: async (sessionId) => {
28
+ opened.add(sessionId);
29
+ return createWebAgent({
30
+ store,
31
+ workspace: options.workspace,
32
+ model: options.model({ sessionId, sessionsDir: options.dir }),
33
+ tier: () => options.tierOf(sessionId),
34
+ sessionId,
35
+ checkRunners: options.checkRunners(),
36
+ tasks,
37
+ ...(options.secretEnvNames !== undefined ? { secretEnvNames: options.secretEnvNames } : {}),
38
+ ...(options.toolLimits !== undefined ? { toolLimits: options.toolLimits } : {}),
39
+ onSession: (sessionId, session) => {
40
+ // the model hears how a task ended; nothing wakes an idle session
41
+ session.useTasks(tasks.managerFor(sessionId), { wake: false });
42
+ },
43
+ });
44
+ },
45
+ });
46
+ const windowOf = () => {
47
+ try {
48
+ const m = options.model();
49
+ return m.kind === "profile" ? (m.contextWindow ?? null) : null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ };
55
+ const times = new TimeIndex(store);
56
+ const augment = (event, sessionId) => {
57
+ const frames = [];
58
+ // when it happened: for every wire event but the two delta types, which are too many
59
+ if (!DELTAS.has(event.type) && toWireEvent(event) !== null) {
60
+ const at = { seq: event.seq, ts: times.at(sessionId, event.seq) };
61
+ frames.push({ event: "at", data: at });
62
+ }
63
+ frames.push(...derived(event, sessionId));
64
+ return frames;
65
+ };
66
+ /** The tool, input and execution start of the call a result answers, from the log before it. */
67
+ const callOf = (sessionId, callId) => {
68
+ let call = null;
69
+ let startedAt = null;
70
+ for (const e of service.events(sessionId)) {
71
+ if (e.type === "tool_call_end" && e.callId === callId)
72
+ call = { name: e.name, input: e.input };
73
+ if (e.type === "tool_execution_started" && e.callId === callId)
74
+ startedAt = times.at(sessionId, e.seq);
75
+ }
76
+ return call === null ? null : { ...call, startedAt };
77
+ };
78
+ const realWorkspace = () => {
79
+ try {
80
+ return realpathSync(options.workspace);
81
+ }
82
+ catch {
83
+ return options.workspace;
84
+ }
85
+ };
86
+ const derived = (event, sessionId) => {
87
+ if (event.type === "usage") {
88
+ const frame = usageFrameAt(service.events(sessionId), event.seq, route());
89
+ return frame === null ? [] : [{ event: "usage", data: { ...frame, window: windowOf() } }];
90
+ }
91
+ if (event.type === "terminal") {
92
+ const totals = runTotals(service.events(sessionId), event.seq, route());
93
+ return totals === null ? [] : [{ event: "run_usage", data: { seq: event.seq, ...totals } }];
94
+ }
95
+ if (event.type === "tool_result") {
96
+ const artifacts = artifactsOf(event, sessionId, callOf(sessionId, event.callId), realWorkspace());
97
+ const text = typeof event.content === "string" ? event.content : event.content.map((b) => (b.type === "text" ? b.text : `[${b.type}]`)).join("\n");
98
+ const lines = text.split("\n");
99
+ const frame = {
100
+ seq: event.seq,
101
+ callId: event.callId,
102
+ isError: event.isError,
103
+ head: lines.slice(0, HEAD_LINES),
104
+ lines: lines.length,
105
+ bytes: Buffer.byteLength(text),
106
+ truncated: lines.length > HEAD_LINES,
107
+ ...((event.tags ?? []).includes("created") ? { created: true } : {}),
108
+ ...(artifacts.length > 0 ? { artifacts } : {}),
109
+ };
110
+ return [{ event: "tool_output", data: frame }];
111
+ }
112
+ return [];
113
+ };
114
+ // The tasks frame: not tied to any event (a command can end while nothing
115
+ // else happens), so it is the host's own source, polled while a stream is open.
116
+ const frames = (sessionId, push) => {
117
+ let last = "";
118
+ const tick = () => {
119
+ try {
120
+ const rows = rowsOf(tasks.managerFor(sessionId));
121
+ // elapsed time of a running task changes every tick; the state does not
122
+ const key = JSON.stringify(rows.map((r) => [r.id, r.label]));
123
+ const running = rows.some((r) => r.endedAt === null);
124
+ if (key !== last || running) {
125
+ last = key;
126
+ const frame = { at: Date.now(), tasks: rows };
127
+ push({ event: "tasks", data: frame });
128
+ }
129
+ }
130
+ catch {
131
+ // a corrupt journal is the terminal's to report; the stream goes on
132
+ }
133
+ };
134
+ tick();
135
+ const timer = setInterval(tick, 1_000);
136
+ timer.unref();
137
+ return () => clearInterval(timer);
138
+ };
139
+ const handler = createHttpHandler(service, {
140
+ prefix: options.prefix,
141
+ // the host's guard has already run: bearer token, Host and Origin
142
+ authorize: () => true,
143
+ augment,
144
+ frames,
145
+ projection: { exposeThinking: options.exposeThinking, ...(options.sanitizer === "default" ? {} : { sanitize: webSanitize }) },
146
+ ...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
147
+ });
148
+ return {
149
+ key: options.key,
150
+ workspace: options.workspace,
151
+ dir: options.dir,
152
+ store,
153
+ service,
154
+ tasks,
155
+ images: new LogImages(options.dir),
156
+ handler,
157
+ opened: () => opened,
158
+ drain: (graceMs) => service.drain(graceMs),
159
+ close: async () => {
160
+ await tasks.close();
161
+ store.closeAll();
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,88 @@
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
+ close: async () => {
32
+ for (const manager of managers.values()) {
33
+ await manager.stopAll("exit", 2_000, "moved");
34
+ manager.close();
35
+ }
36
+ },
37
+ };
38
+ }
39
+ /** A task's state in a person's words (the terminal's `/tasks` wording). */
40
+ export function stateLabel(task) {
41
+ const s = task.state;
42
+ switch (s.kind) {
43
+ case "starting":
44
+ return "starting";
45
+ case "not_run":
46
+ return "never ran";
47
+ case "running":
48
+ return task.stoppedBy !== undefined ? "stopping" : s.ready ? "ready" : "running";
49
+ case "unknown":
50
+ return "lost track";
51
+ case "ended":
52
+ if (s.stopped)
53
+ return "stopped";
54
+ if (s.error !== undefined)
55
+ return "failed to start";
56
+ return s.exitCode === 0 ? "exited 0" : s.exitCode !== null ? `failed ${s.exitCode}` : `ended ${s.signal ?? ""}`.trim();
57
+ }
58
+ }
59
+ /** The last lines of a task's output file (read from its end, never whole). */
60
+ function tailOf(path, lines = 4) {
61
+ try {
62
+ const fd = openSync(path, "r");
63
+ try {
64
+ const size = fstatSync(fd).size;
65
+ const length = Math.min(size, 2048);
66
+ const buf = Buffer.alloc(length);
67
+ readSync(fd, buf, 0, length, size - length);
68
+ return buf.toString("utf8").split("\n").filter((l) => l !== "").slice(-lines);
69
+ }
70
+ finally {
71
+ closeSync(fd);
72
+ }
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
78
+ export function rowsOf(manager, now = Date.now()) {
79
+ return manager.list().map((t) => ({
80
+ id: t.id,
81
+ label: stateLabel(t),
82
+ command: t.command.split("\n")[0],
83
+ startedAt: t.startedAt,
84
+ endedAt: t.endedAt ?? null,
85
+ elapsedMs: (t.endedAt ?? now) - t.startedAt,
86
+ tail: tailOf(t.outputPath),
87
+ }));
88
+ }
@@ -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,83 @@
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
+ }
@@ -0,0 +1,2 @@
1
+ /** What the read-only file doors answer (`/desk/fs/tree`, `/desk/fs/file`) and the task-log door. Nothing here leaves the Work's folder. */
2
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The web app's own frames — what the host adds beside the wire events on
3
+ * the same stream (`event: <name>` without an `id:`). None of them is
4
+ * durable: a reconnecting client is replayed the wire events and gets the
5
+ * frames of the events it missed again, and `tasks` afresh.
6
+ */
7
+ /** cache / (fresh + cache), as a percentage; null when unmeasured. */
8
+ export function cacheRate(fresh, cache) {
9
+ if (fresh === null || cache === null)
10
+ return null;
11
+ const total = fresh + cache;
12
+ return total > 0 ? (cache / total) * 100 : null;
13
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The history of Works across every project folder: what `GET /desk/history`
3
+ * answers. A Work is one session; it belongs to the folder it was started in.
4
+ */
5
+ export {};
@@ -0,0 +1,2 @@
1
+ /** What `GET /desk/settings` answers and `POST /desk/settings` takes. No credential is ever in it: only where the sign-in comes from. */
2
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Thinking on the wire arrives as many small `thinking` deltas. The stream
3
+ * the owner approved shows ONE line that pages to each paragraph's first
4
+ * sentence and opens to the full text. This is that pager, as a pure
5
+ * function over the deltas — nothing in the protocol needs to change.
6
+ *
7
+ * push(text) → what became known: a paragraph's `line` (its first
8
+ * sentence, once that sentence is complete), or a closed
9
+ * `paragraph` (its full text, once a blank line follows)
10
+ * flush() → the open paragraph at the end of the thinking
11
+ *
12
+ * A paragraph ends at a blank line. Its first sentence ends at the first
13
+ * `.`, `!` or `?` followed by whitespace, at a line break, or at the end of
14
+ * the paragraph when none comes.
15
+ */
16
+ const SENTENCE_END = /^(.*?[.!?])\s/s;
17
+ export class ThinkingPager {
18
+ #buf = "";
19
+ #paragraph = 0;
20
+ #lineSent = false;
21
+ push(text) {
22
+ this.#buf += text;
23
+ const events = [];
24
+ for (;;) {
25
+ const gap = /\n[ \t]*\n/.exec(this.#buf);
26
+ if (gap === null)
27
+ break;
28
+ const body = this.#buf.slice(0, gap.index);
29
+ this.#buf = this.#buf.slice(gap.index + gap[0].length);
30
+ if (body.trim() !== "")
31
+ this.#close(body, events);
32
+ }
33
+ this.#firstLine(events);
34
+ return events;
35
+ }
36
+ flush() {
37
+ const events = [];
38
+ if (this.#buf.trim() !== "")
39
+ this.#close(this.#buf, events);
40
+ this.#buf = "";
41
+ return events;
42
+ }
43
+ #firstLine(events) {
44
+ if (this.#lineSent)
45
+ return;
46
+ const trimmed = this.#buf.replace(/^\s+/, "");
47
+ const nl = trimmed.indexOf("\n");
48
+ const m = SENTENCE_END.exec(trimmed);
49
+ const cut = m !== null ? m[1].length : -1;
50
+ const end = nl !== -1 && (cut === -1 || nl < cut) ? nl : cut;
51
+ if (end !== -1 && end > 0) {
52
+ this.#lineSent = true;
53
+ events.push({ kind: "line", paragraph: this.#paragraph, text: trimmed.slice(0, end).trim() });
54
+ }
55
+ }
56
+ #close(body, events) {
57
+ if (!this.#lineSent) {
58
+ // a short paragraph with no sentence end: its whole text is its line
59
+ events.push({ kind: "line", paragraph: this.#paragraph, text: body.trim().split("\n")[0].trim() });
60
+ }
61
+ events.push({ kind: "paragraph", paragraph: this.#paragraph, text: body.trim() });
62
+ this.#paragraph += 1;
63
+ this.#lineSent = false;
64
+ }
65
+ }