@codeoid/core 0.1.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/README.md +39 -0
- package/package.json +28 -0
- package/src/approvals.ts +60 -0
- package/src/client.ts +626 -0
- package/src/format.ts +91 -0
- package/src/identity.ts +58 -0
- package/src/index.ts +9 -0
- package/src/messages.ts +304 -0
- package/src/resume.ts +74 -0
- package/src/sanitize-url.ts +86 -0
- package/src/slash.ts +247 -0
- package/src/usage-days.ts +40 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display formatters for usage metrics — pure, deterministic, shared by
|
|
3
|
+
* every frontend so "1.2M tokens · $3.42 · 2m 15s" reads identically in the
|
|
4
|
+
* web UI, the TUI, and the mobile app. Colour/style decisions stay in each
|
|
5
|
+
* frontend (they're design-system-specific); everything here returns plain
|
|
6
|
+
* strings.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Token count → "1.2M" / "12.5k" / "342". */
|
|
10
|
+
export function formatTokens(n: number | null | undefined): string {
|
|
11
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
12
|
+
const abs = Math.abs(n);
|
|
13
|
+
if (abs >= 1_000_000) return `${trimZeros(n / 1_000_000)}M`;
|
|
14
|
+
if (abs >= 10_000) return `${Math.round(n / 1_000)}k`;
|
|
15
|
+
if (abs >= 1_000) return `${trimZeros(n / 1_000)}k`;
|
|
16
|
+
return Math.round(n).toString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** USD cost → "$3.42" / "$0.012" / "<$0.01". Never returns "$0.00" for non-zero amounts. */
|
|
20
|
+
export function formatCostUsd(usd: number | null | undefined): string {
|
|
21
|
+
if (usd == null || !Number.isFinite(usd)) return "—";
|
|
22
|
+
if (usd === 0) return "$0";
|
|
23
|
+
if (usd < 0.005) return "<$0.01";
|
|
24
|
+
if (usd < 0.1) return `$${usd.toFixed(3)}`;
|
|
25
|
+
if (usd < 10) return `$${usd.toFixed(2)}`;
|
|
26
|
+
return `$${Math.round(usd).toLocaleString()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Duration in ms → "1h 24m" / "2m 15s" / "8s" / "240ms". */
|
|
30
|
+
export function formatDuration(ms: number | null | undefined): string {
|
|
31
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return "—";
|
|
32
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
33
|
+
const total = Math.round(ms / 1000);
|
|
34
|
+
const h = Math.floor(total / 3600);
|
|
35
|
+
const m = Math.floor((total % 3600) / 60);
|
|
36
|
+
const s = total % 60;
|
|
37
|
+
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
38
|
+
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
39
|
+
return `${s}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wall-clock elapsed since `since` (ISO 8601 string or epoch ms). */
|
|
43
|
+
export function elapsedSince(since: string | number | Date, now = Date.now()): string {
|
|
44
|
+
const t = since instanceof Date ? since.getTime() : typeof since === "number" ? since : Date.parse(since);
|
|
45
|
+
if (!Number.isFinite(t)) return "—";
|
|
46
|
+
return formatDuration(now - t);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compact relative time → "5m ago" / "2h ago" / "yesterday" / "Mar 5". */
|
|
50
|
+
export function relativeTime(ts: string | number | Date, now = Date.now()): string {
|
|
51
|
+
const t = ts instanceof Date ? ts.getTime() : typeof ts === "number" ? ts : Date.parse(ts);
|
|
52
|
+
if (!Number.isFinite(t)) return "—";
|
|
53
|
+
const diff = now - t;
|
|
54
|
+
if (diff < 0) return "just now";
|
|
55
|
+
if (diff < 60_000) return "just now";
|
|
56
|
+
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
57
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
|
58
|
+
if (diff < 172_800_000) return "yesterday";
|
|
59
|
+
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
60
|
+
return new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cache hit rate (0-1) → "63%". Returns "—" if not provided. */
|
|
64
|
+
export function formatPercent(ratio: number | null | undefined, digits = 0): string {
|
|
65
|
+
if (ratio == null || !Number.isFinite(ratio)) return "—";
|
|
66
|
+
return `${(ratio * 100).toFixed(digits)}%`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Context-window utilization severity — frontends map this to their own
|
|
71
|
+
* colour tokens. <60% "ok", 60-85% "warn", >85% "danger".
|
|
72
|
+
*/
|
|
73
|
+
export type CtxSeverity = "ok" | "warn" | "danger";
|
|
74
|
+
export function ctxWindowSeverity(ratio: number): CtxSeverity {
|
|
75
|
+
if (ratio < 0.6) return "ok";
|
|
76
|
+
if (ratio < 0.85) return "warn";
|
|
77
|
+
return "danger";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** HH:MM:SS for a timestamp string/number. Best-effort; falls back to the input. */
|
|
81
|
+
export function formatClock(ts: string | number): string {
|
|
82
|
+
const t = typeof ts === "number" ? ts : Date.parse(ts);
|
|
83
|
+
if (!Number.isFinite(t)) return String(ts);
|
|
84
|
+
return new Date(t).toLocaleTimeString(undefined, { hour12: false });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One decimal, but trim a trailing ".0" so "3.0k" reads as "3k".
|
|
88
|
+
function trimZeros(n: number): string {
|
|
89
|
+
const s = n.toFixed(1);
|
|
90
|
+
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
|
91
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity helpers — surface ZeroID provenance everywhere, identically in
|
|
3
|
+
* every frontend. Every message carries a `MessageIdentity { sub, name?,
|
|
4
|
+
* type }`; clients render the *real* label (name or short sub), never a
|
|
5
|
+
* generic "you" / "agent", with the full WIMSE URI available on demand as
|
|
6
|
+
* proof of provenance. Colour mapping stays in each frontend.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MessageIdentity, SessionInfo } from "@codeoid/protocol";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Last path segment of a SPIFFE / WIMSE URI.
|
|
13
|
+
*
|
|
14
|
+
* spiffe://highflame.ai/acct/proj/agent/codeoid-session-abc → codeoid-session-abc
|
|
15
|
+
* anonymous:session:abc → abc
|
|
16
|
+
* you@example.com → you@example.com (passthrough)
|
|
17
|
+
*/
|
|
18
|
+
export function shortSub(uri: string | null | undefined): string {
|
|
19
|
+
if (!uri) return "—";
|
|
20
|
+
// Anonymous markers: anonymous:<kind>:<id>
|
|
21
|
+
if (uri.startsWith("anonymous:")) {
|
|
22
|
+
const tail = uri.split(":").pop();
|
|
23
|
+
return tail || uri;
|
|
24
|
+
}
|
|
25
|
+
// SPIFFE / WIMSE: take everything after the last "/".
|
|
26
|
+
const slashIdx = uri.lastIndexOf("/");
|
|
27
|
+
if (slashIdx >= 0 && slashIdx < uri.length - 1) {
|
|
28
|
+
return uri.slice(slashIdx + 1);
|
|
29
|
+
}
|
|
30
|
+
return uri;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Display label for an identity — name when present, else short sub. */
|
|
34
|
+
export function identityLabel(id: MessageIdentity | null | undefined): string {
|
|
35
|
+
if (!id) return "—";
|
|
36
|
+
if (id.name && id.name.trim().length > 0) return id.name.trim();
|
|
37
|
+
return shortSub(id.sub);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Truncate a WIMSE URI for inline display while keeping the head + tail
|
|
42
|
+
* visible — e.g. `spiffe://highflame.ai/.../agent/codeoid-session-abc`.
|
|
43
|
+
* The full URI stays available via the title attribute / hover popover.
|
|
44
|
+
*/
|
|
45
|
+
export function truncateWimseUri(uri: string, headChars = 24, tailChars = 28): string {
|
|
46
|
+
if (uri.length <= headChars + tailChars + 3) return uri;
|
|
47
|
+
return `${uri.slice(0, headChars)}…${uri.slice(-tailChars)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One-liner provenance for a session — agent URI when registered, else
|
|
52
|
+
* a clearly-marked "anonymous". Used by session headers.
|
|
53
|
+
*/
|
|
54
|
+
export function sessionAgentLabel(s: SessionInfo): string {
|
|
55
|
+
if (!s.agentUri) return "anonymous session";
|
|
56
|
+
if (s.agentUri.startsWith("anonymous:")) return "anonymous session";
|
|
57
|
+
return shortSub(s.agentUri);
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./client.js";
|
|
2
|
+
export * from "./messages.js";
|
|
3
|
+
export * from "./resume.js";
|
|
4
|
+
export * from "./format.js";
|
|
5
|
+
export * from "./identity.js";
|
|
6
|
+
export * from "./approvals.js";
|
|
7
|
+
export * from "./usage-days.js";
|
|
8
|
+
export * from "./sanitize-url.js";
|
|
9
|
+
export * from "./slash.js";
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message semantics — the single source of truth for how session transcripts
|
|
3
|
+
* accumulate on a client, shared by every frontend.
|
|
4
|
+
*
|
|
5
|
+
* Two layers:
|
|
6
|
+
*
|
|
7
|
+
* 1. KERNELS — pure functions encoding the merge semantics
|
|
8
|
+
* (`mergeDeltaInto`, `dedupeReplay`). Frontends with their own reactive
|
|
9
|
+
* store (the Solid web UI) call these inside their store transactions so
|
|
10
|
+
* the semantics can't drift per-client.
|
|
11
|
+
*
|
|
12
|
+
* 2. `MessageStore` — a batteries-included, framework-agnostic store built
|
|
13
|
+
* on the kernels for clients WITHOUT a bespoke reactive layer (React
|
|
14
|
+
* Native, headless tools). Mirrors the web store's behaviour exactly:
|
|
15
|
+
* upsert-by-messageId (idempotent re-broadcast/replay), O(1) positional
|
|
16
|
+
* index, per-message version counters, per-session epoch counters, and
|
|
17
|
+
* `ingest()` — the full broadcast-routing decision table (live message /
|
|
18
|
+
* delta / snapshot replay / chunked replay / incremental resume) that is
|
|
19
|
+
* easy to get subtly wrong when reimplemented.
|
|
20
|
+
*
|
|
21
|
+
* Everything here mirrors the Rust TUI's `MessageStore` semantics.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ContentPart,
|
|
26
|
+
DaemonMessage,
|
|
27
|
+
ScrollbackReplayMsg,
|
|
28
|
+
SessionMessage,
|
|
29
|
+
SessionMessageDelta,
|
|
30
|
+
ToolState,
|
|
31
|
+
} from "@codeoid/protocol";
|
|
32
|
+
import type { ResumeCursors } from "./resume.js";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Kernels
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply a streaming delta to its target message, in place. The daemon
|
|
40
|
+
* guarantees deltas reference an existing message; callers gate on existence
|
|
41
|
+
* (stale deltas for evicted/unknown messages are dropped — the next replay
|
|
42
|
+
* resyncs).
|
|
43
|
+
*
|
|
44
|
+
* Mutates `target` deliberately: both the Solid store (inside `produce`) and
|
|
45
|
+
* `MessageStore` rely on in-place patching so streaming stays O(delta), not
|
|
46
|
+
* O(message). `parts` arrays are copy-on-write so consumers holding the old
|
|
47
|
+
* array reference are not surprised by index mutation.
|
|
48
|
+
*/
|
|
49
|
+
export function mergeDeltaInto(target: SessionMessage, delta: SessionMessageDelta): void {
|
|
50
|
+
if (delta.contentAppend) {
|
|
51
|
+
target.content = (target.content ?? "") + delta.contentAppend;
|
|
52
|
+
}
|
|
53
|
+
if (delta.partsAppend && delta.partsAppend.length > 0) {
|
|
54
|
+
const parts: ContentPart[] = target.parts ? [...target.parts] : [];
|
|
55
|
+
parts.push(...delta.partsAppend);
|
|
56
|
+
target.parts = parts;
|
|
57
|
+
}
|
|
58
|
+
if (delta.partsUpdate && delta.partsUpdate.length > 0) {
|
|
59
|
+
const parts: ContentPart[] = target.parts ? [...target.parts] : [];
|
|
60
|
+
for (const upd of delta.partsUpdate) {
|
|
61
|
+
const i = upd.index;
|
|
62
|
+
if (i >= 0 && i < parts.length) parts[i] = upd.part;
|
|
63
|
+
else parts.push(upd.part);
|
|
64
|
+
}
|
|
65
|
+
target.parts = parts;
|
|
66
|
+
}
|
|
67
|
+
if (delta.toolStateUpdate && target.tool) {
|
|
68
|
+
// ToolState is a discriminated union; assigning replaces wholesale.
|
|
69
|
+
target.tool = { ...target.tool, state: delta.toolStateUpdate satisfies ToolState };
|
|
70
|
+
}
|
|
71
|
+
target.timestamp = delta.timestamp;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Dedupe a replayed message array by messageId — the message keeps the
|
|
76
|
+
* position of its FIRST occurrence, the LAST occurrence's content wins
|
|
77
|
+
* (mirrors upsert semantics). Returns the deduped array plus the
|
|
78
|
+
* messageId → position index, which IS the store's positional index for
|
|
79
|
+
* the replaced session.
|
|
80
|
+
*/
|
|
81
|
+
export function dedupeReplay(messages: readonly SessionMessage[]): {
|
|
82
|
+
messages: SessionMessage[];
|
|
83
|
+
posById: Map<string, number>;
|
|
84
|
+
} {
|
|
85
|
+
const deduped: SessionMessage[] = [];
|
|
86
|
+
const posById = new Map<string, number>();
|
|
87
|
+
for (const m of messages) {
|
|
88
|
+
const at = posById.get(m.messageId);
|
|
89
|
+
if (at !== undefined) {
|
|
90
|
+
deduped[at] = m;
|
|
91
|
+
} else {
|
|
92
|
+
posById.set(m.messageId, deduped.length);
|
|
93
|
+
deduped.push(m);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { messages: deduped, posById };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// MessageStore
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
/** Change notification. `messageId` is null for whole-session changes (replay/clear). */
|
|
104
|
+
export type MessageStoreListener = (sessionId: string, messageId: string | null) => void;
|
|
105
|
+
|
|
106
|
+
/** Stable empty slice — avoids an allocation per miss and gives callers a
|
|
107
|
+
* consistent identity for "no messages". */
|
|
108
|
+
const EMPTY: readonly SessionMessage[] = Object.freeze([]);
|
|
109
|
+
|
|
110
|
+
export class MessageStore {
|
|
111
|
+
#bySession = new Map<string, SessionMessage[]>();
|
|
112
|
+
#indexBySession = new Map<string, Map<string, number>>();
|
|
113
|
+
#versions = new Map<string, number>();
|
|
114
|
+
#epochs = new Map<string, number>();
|
|
115
|
+
#listeners = new Set<MessageStoreListener>();
|
|
116
|
+
|
|
117
|
+
/** Subscribe to changes. Returns an unsubscribe fn. */
|
|
118
|
+
onChange(listener: MessageStoreListener): () => void {
|
|
119
|
+
this.#listeners.add(listener);
|
|
120
|
+
return () => this.#listeners.delete(listener);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Messages for a session, in arrival order. The returned array is live —
|
|
124
|
+
* treat as read-only and re-read on change notifications / epoch bumps. */
|
|
125
|
+
messagesFor(sessionId: string): readonly SessionMessage[] {
|
|
126
|
+
return this.#bySession.get(sessionId) ?? EMPTY;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** O(1) existence check. */
|
|
130
|
+
hasMessage(sessionId: string, messageId: string): boolean {
|
|
131
|
+
return this.#indexBySession.get(sessionId)?.has(messageId) ?? false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Monotonic per-message version (render-cache key). 0 = never seen. */
|
|
135
|
+
versionOf(messageId: string): number {
|
|
136
|
+
return this.#versions.get(messageId) ?? 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Per-session epoch — bumps on EVERY mutation, including in-place delta
|
|
140
|
+
* patches (array identity is not a reliable change signal). */
|
|
141
|
+
epochOf(sessionId: string | null | undefined): number {
|
|
142
|
+
if (!sessionId) return 0;
|
|
143
|
+
return this.#epochs.get(sessionId) ?? 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Upsert a complete message (live broadcast or re-broadcast). */
|
|
147
|
+
applyMessage(msg: SessionMessage): void {
|
|
148
|
+
const index = this.#index(msg.sessionId);
|
|
149
|
+
const buf = this.#buffer(msg.sessionId);
|
|
150
|
+
const at = index.get(msg.messageId);
|
|
151
|
+
if (at !== undefined) {
|
|
152
|
+
buf[at] = msg;
|
|
153
|
+
} else {
|
|
154
|
+
index.set(msg.messageId, buf.length);
|
|
155
|
+
buf.push(msg);
|
|
156
|
+
}
|
|
157
|
+
this.#bumpVersion(msg.messageId);
|
|
158
|
+
this.#bumpEpoch(msg.sessionId);
|
|
159
|
+
this.#notify(msg.sessionId, msg.messageId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Apply a streaming delta. Unknown/stale targets are dropped silently —
|
|
163
|
+
* the daemon's replay resyncs on the next attach. */
|
|
164
|
+
applyDelta(delta: SessionMessageDelta): void {
|
|
165
|
+
const idx = this.#indexBySession.get(delta.sessionId)?.get(delta.messageId);
|
|
166
|
+
if (idx === undefined) return;
|
|
167
|
+
const target = this.#bySession.get(delta.sessionId)?.[idx];
|
|
168
|
+
if (!target) return;
|
|
169
|
+
mergeDeltaInto(target, delta);
|
|
170
|
+
this.#bumpVersion(delta.messageId);
|
|
171
|
+
this.#bumpEpoch(delta.sessionId);
|
|
172
|
+
this.#notify(delta.sessionId, delta.messageId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Authoritative snapshot replay — RESET the session to these messages. */
|
|
176
|
+
replaceScrollback(sessionId: string, messages: readonly SessionMessage[]): void {
|
|
177
|
+
const { messages: deduped, posById } = dedupeReplay(messages);
|
|
178
|
+
this.#bySession.set(sessionId, deduped);
|
|
179
|
+
this.#indexBySession.set(sessionId, posById);
|
|
180
|
+
this.#bumpEpoch(sessionId);
|
|
181
|
+
this.#notify(sessionId, null);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Chunked-replay continuation / incremental resume tail — upsert by
|
|
185
|
+
* messageId, keeping first-seen position; new messages append in order. */
|
|
186
|
+
appendScrollback(sessionId: string, messages: readonly SessionMessage[]): void {
|
|
187
|
+
if (messages.length === 0) return;
|
|
188
|
+
const index = this.#index(sessionId);
|
|
189
|
+
const buf = this.#buffer(sessionId);
|
|
190
|
+
for (const m of messages) {
|
|
191
|
+
const at = index.get(m.messageId);
|
|
192
|
+
if (at !== undefined) {
|
|
193
|
+
buf[at] = m;
|
|
194
|
+
} else {
|
|
195
|
+
index.set(m.messageId, buf.length);
|
|
196
|
+
buf.push(m);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.#bumpEpoch(sessionId);
|
|
200
|
+
this.#notify(sessionId, null);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drop all state for a destroyed session. */
|
|
204
|
+
clearSession(sessionId: string): void {
|
|
205
|
+
const ids = this.#indexBySession.get(sessionId);
|
|
206
|
+
if (ids) for (const id of ids.keys()) this.#versions.delete(id);
|
|
207
|
+
this.#bySession.delete(sessionId);
|
|
208
|
+
this.#indexBySession.delete(sessionId);
|
|
209
|
+
this.#epochs.delete(sessionId);
|
|
210
|
+
this.#notify(sessionId, null);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Route a daemon broadcast into the store — the full decision table:
|
|
215
|
+
*
|
|
216
|
+
* - `session.message` → upsert (and advance the resume cursor);
|
|
217
|
+
* - `session.message.delta` → in-place merge (and advance the cursor);
|
|
218
|
+
* - `scrollback.replay`:
|
|
219
|
+
* · `mode: "incremental"` → APPEND/upsert, never reset — the daemon
|
|
220
|
+
* sent only the tail mutated since our cursor (chunk 0 of an
|
|
221
|
+
* incremental replay is NOT a snapshot);
|
|
222
|
+
* · snapshot chunk 0 / single-frame legacy → RESET the session;
|
|
223
|
+
* · snapshot chunk > 0 (#84) → append in order.
|
|
224
|
+
* Replay frames also (re)establish the resume cursor.
|
|
225
|
+
*
|
|
226
|
+
* Returns true when the frame was store-relevant (callers may use this to
|
|
227
|
+
* skip their own routing). Pass `cursors` to keep incremental resume
|
|
228
|
+
* working across reconnects; omit it to opt out of cursor tracking.
|
|
229
|
+
*/
|
|
230
|
+
ingest(msg: DaemonMessage, cursors?: ResumeCursors): boolean {
|
|
231
|
+
switch (msg.type) {
|
|
232
|
+
case "session.message":
|
|
233
|
+
cursors?.noteLiveSeq(msg.sessionId, msg.seq);
|
|
234
|
+
this.applyMessage(msg);
|
|
235
|
+
return true;
|
|
236
|
+
case "session.message.delta":
|
|
237
|
+
cursors?.noteLiveSeq(msg.sessionId, msg.seq);
|
|
238
|
+
this.applyDelta(msg);
|
|
239
|
+
return true;
|
|
240
|
+
case "scrollback.replay": {
|
|
241
|
+
const replay = msg as ScrollbackReplayMsg;
|
|
242
|
+
cursors?.noteReplayFrame(replay);
|
|
243
|
+
if (replay.mode === "incremental") {
|
|
244
|
+
this.appendScrollback(replay.sessionId, replay.messages);
|
|
245
|
+
} else if (replay.seq === undefined || replay.seq === 0) {
|
|
246
|
+
this.replaceScrollback(replay.sessionId, replay.messages);
|
|
247
|
+
} else {
|
|
248
|
+
this.appendScrollback(replay.sessionId, replay.messages);
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
default:
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Test/reset hook. */
|
|
258
|
+
clear(): void {
|
|
259
|
+
this.#bySession.clear();
|
|
260
|
+
this.#indexBySession.clear();
|
|
261
|
+
this.#versions.clear();
|
|
262
|
+
this.#epochs.clear();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
#buffer(sessionId: string): SessionMessage[] {
|
|
268
|
+
let buf = this.#bySession.get(sessionId);
|
|
269
|
+
if (!buf) {
|
|
270
|
+
buf = [];
|
|
271
|
+
this.#bySession.set(sessionId, buf);
|
|
272
|
+
}
|
|
273
|
+
return buf;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#index(sessionId: string): Map<string, number> {
|
|
277
|
+
let index = this.#indexBySession.get(sessionId);
|
|
278
|
+
if (!index) {
|
|
279
|
+
index = new Map();
|
|
280
|
+
this.#indexBySession.set(sessionId, index);
|
|
281
|
+
}
|
|
282
|
+
return index;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#bumpVersion(messageId: string): void {
|
|
286
|
+
this.#versions.set(messageId, (this.#versions.get(messageId) ?? 0) + 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#bumpEpoch(sessionId: string): void {
|
|
290
|
+
this.#epochs.set(sessionId, (this.#epochs.get(sessionId) ?? 0) + 1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#notify(sessionId: string, messageId: string | null): void {
|
|
294
|
+
// Isolate subscriber faults: one throwing listener must not break the
|
|
295
|
+
// fan-out for later listeners or unwind the mutation that notified.
|
|
296
|
+
for (const l of this.#listeners) {
|
|
297
|
+
try {
|
|
298
|
+
l(sessionId, messageId);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.error("[codeoid/core] MessageStore listener threw:", err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session resume cursors (`replay.resume`).
|
|
3
|
+
*
|
|
4
|
+
* Tracks, per session, the daemon's replay-buffer identity (`resumeKey`) and
|
|
5
|
+
* the highest session sequence value observed (`seq`) across replay frames
|
|
6
|
+
* and live message/delta traffic. On re-attach the cursor is passed back so
|
|
7
|
+
* the daemon replays only the tail mutated since — instead of the full
|
|
8
|
+
* scrollback — which is what makes reconnects cheap on flaky links.
|
|
9
|
+
*
|
|
10
|
+
* Safety property: the cursor may lag reality (a frame without `seq` doesn't
|
|
11
|
+
* advance it) but must never lead it — a lagging cursor just means a few
|
|
12
|
+
* messages are resent and deduped by the store's upsert-by-messageId; a
|
|
13
|
+
* leading cursor would silently skip content. Everything here only ever
|
|
14
|
+
* raises the cursor to values actually observed.
|
|
15
|
+
*
|
|
16
|
+
* A class (not module state) so each daemon connection owns its own cursor
|
|
17
|
+
* space; hosts hold one instance next to their `CodeoidClient` /
|
|
18
|
+
* `MessageStore` and pass it to `MessageStore.ingest()`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ScrollbackReplayMsg } from "@codeoid/protocol";
|
|
22
|
+
|
|
23
|
+
interface Cursor {
|
|
24
|
+
key: string;
|
|
25
|
+
seq: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ResumeCursors {
|
|
29
|
+
#cursors = new Map<string, Cursor>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ingest a replay frame. A frame carrying a NEW `resumeKey` (first contact,
|
|
33
|
+
* or the daemon restarted and rebuilt its buffer) resets the cursor to that
|
|
34
|
+
* key's `maxSeq` — old-key seq values are meaningless in the new domain.
|
|
35
|
+
* Same-key frames only ever raise the cursor.
|
|
36
|
+
*/
|
|
37
|
+
noteReplayFrame(msg: ScrollbackReplayMsg): void {
|
|
38
|
+
if (msg.resumeKey === undefined || msg.maxSeq === undefined) return;
|
|
39
|
+
const existing = this.#cursors.get(msg.sessionId);
|
|
40
|
+
if (existing && existing.key === msg.resumeKey) {
|
|
41
|
+
if (msg.maxSeq > existing.seq) existing.seq = msg.maxSeq;
|
|
42
|
+
} else {
|
|
43
|
+
this.#cursors.set(msg.sessionId, { key: msg.resumeKey, seq: msg.maxSeq });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ingest a live frame's session cursor (`SessionMessage.seq` /
|
|
49
|
+
* `SessionMessageDelta.seq`). Only meaningful once a replay frame has
|
|
50
|
+
* established which key the seq domain belongs to — live seqs arriving
|
|
51
|
+
* before any cursor exists are dropped (we can't resume without a key).
|
|
52
|
+
*/
|
|
53
|
+
noteLiveSeq(sessionId: string, seq: number | undefined): void {
|
|
54
|
+
if (seq === undefined) return;
|
|
55
|
+
const cursor = this.#cursors.get(sessionId);
|
|
56
|
+
if (cursor && seq > cursor.seq) cursor.seq = seq;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The resume argument for `session.attach`, or undefined for a full replay. */
|
|
60
|
+
resumeFor(sessionId: string): { key: string; sinceSeq: number } | undefined {
|
|
61
|
+
const cursor = this.#cursors.get(sessionId);
|
|
62
|
+
return cursor ? { key: cursor.key, sinceSeq: cursor.seq } : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Drop a session's cursor (session destroyed). */
|
|
66
|
+
clear(sessionId: string): void {
|
|
67
|
+
this.#cursors.delete(sessionId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Drop everything (new connection to a different daemon, tests). */
|
|
71
|
+
reset(): void {
|
|
72
|
+
this.#cursors.clear();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL allowlisting for markdown rendered from UNTRUSTED content (assistant /
|
|
3
|
+
* tool output). `solid-markdown` does not sanitize `href`/`src` by default
|
|
4
|
+
* (its `transformLinkUri` default is `null`), so without these a model can emit
|
|
5
|
+
* `[x](javascript:…)` — one click runs script in the app origin, which can read
|
|
6
|
+
* the persisted ZeroID key from localStorage and drive the authenticated socket.
|
|
7
|
+
*
|
|
8
|
+
* Pass `safeLinkUri` as `transformLinkUri` and `safeImageUri` as
|
|
9
|
+
* `transformImageUri` on every `<SolidMarkdown>` that renders model output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Link schemes we allow. Everything else (javascript:, data:, vbscript:,
|
|
13
|
+
* file:, …) is dropped. Relative and anchor URLs always pass. */
|
|
14
|
+
const SAFE_LINK_PROTOCOLS = ["http", "https", "mailto", "tel"];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return `uri` if it is safe to use as an `<a href>`, else `""` (which renders
|
|
18
|
+
* a non-navigable link). Mirrors the battle-tested react-markdown
|
|
19
|
+
* `uriTransformer`: a leading `scheme:` is only honored when the scheme is on
|
|
20
|
+
* the allowlist, and a `:` that appears after the first `?`/`#` is treated as
|
|
21
|
+
* data inside a relative URL rather than a scheme. This defeats obfuscations
|
|
22
|
+
* like `java\tscript:` (no valid scheme match → treated as relative, so the
|
|
23
|
+
* browser's own scheme parser never sees a control-laced `javascript:`).
|
|
24
|
+
*/
|
|
25
|
+
export function safeLinkUri(uri: string): string {
|
|
26
|
+
const url = (uri ?? "").trim();
|
|
27
|
+
if (url === "") return "";
|
|
28
|
+
const first = url.charAt(0);
|
|
29
|
+
if (first === "#" || first === "/") return url; // anchor / root-relative
|
|
30
|
+
|
|
31
|
+
const colon = url.indexOf(":");
|
|
32
|
+
if (colon === -1) return url; // no scheme → relative
|
|
33
|
+
|
|
34
|
+
for (const protocol of SAFE_LINK_PROTOCOLS) {
|
|
35
|
+
if (
|
|
36
|
+
colon === protocol.length &&
|
|
37
|
+
url.slice(0, protocol.length).toLowerCase() === protocol
|
|
38
|
+
) {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A `:` after the first `?`/`#` belongs to the query/fragment, not a scheme.
|
|
44
|
+
const q = url.indexOf("?");
|
|
45
|
+
if (q !== -1 && colon > q) return url;
|
|
46
|
+
const h = url.indexOf("#");
|
|
47
|
+
if (h !== -1 && colon > h) return url;
|
|
48
|
+
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Return `src` if it is safe to auto-load as an `<img>`, else `""`. In addition
|
|
54
|
+
* to the scheme rules above, REMOTE image URLs (http/https) are dropped: an
|
|
55
|
+
* `` in model output is a zero-click exfiltration
|
|
56
|
+
* channel (the browser fetches it the moment the row mounts). Inline `data:`
|
|
57
|
+
* images and relative/same-origin paths — which cannot exfiltrate — are allowed.
|
|
58
|
+
*/
|
|
59
|
+
export function safeImageUri(src: string): string {
|
|
60
|
+
const url = (src ?? "").trim();
|
|
61
|
+
if (url === "") return "";
|
|
62
|
+
// Protocol-relative / network-path references (//host, and backslash-
|
|
63
|
+
// obfuscated variants /\host, \\host that browsers normalize) fetch
|
|
64
|
+
// cross-origin under the page's scheme — a zero-click exfil channel. Treat
|
|
65
|
+
// them like http(s), NOT same-origin, so they must be caught before the
|
|
66
|
+
// root-relative `/` allowance below.
|
|
67
|
+
if (/^[/\\]{2}/.test(url)) return "";
|
|
68
|
+
const first = url.charAt(0);
|
|
69
|
+
if (first === "#" || first === "/") return url; // anchor / root-relative
|
|
70
|
+
|
|
71
|
+
const colon = url.indexOf(":");
|
|
72
|
+
if (colon === -1) return url; // no scheme → relative
|
|
73
|
+
|
|
74
|
+
// Inline data: images never touch the network → safe from exfiltration.
|
|
75
|
+
if (/^data:image\//i.test(url)) return url;
|
|
76
|
+
|
|
77
|
+
// A `:` after the first `?`/`#` belongs to the query/fragment, not a scheme.
|
|
78
|
+
const q = url.indexOf("?");
|
|
79
|
+
if (q !== -1 && colon > q) return url;
|
|
80
|
+
const h = url.indexOf("#");
|
|
81
|
+
if (h !== -1 && colon > h) return url;
|
|
82
|
+
|
|
83
|
+
// Any real scheme here means a network fetch (http/https) or a dangerous
|
|
84
|
+
// scheme — drop it so untrusted content can't silently exfiltrate.
|
|
85
|
+
return "";
|
|
86
|
+
}
|