@huynle/hub 0.8.2 → 0.8.3
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/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +9 -1
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +68 -3
- package/src/central/spoke-ws.ts +2 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +3 -0
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +31 -5
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-g4V9mfSr.js +0 -34
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// REC-01 registry taps (contract: Event schema → Hub): turn.started / first.token / turn.ended of the recorded root
|
|
2
|
+
// session, tool rows (summary only, never the output), say.sent per spoken sentence, reply.full, utterance.delivered.
|
|
3
|
+
// The registry calls these only from the session owner's connection; everything here is synchronous and cheap, the
|
|
4
|
+
// writes happen in the recording service (asynchronous, best effort).
|
|
5
|
+
import type { HubEventKind } from "../../protocol"
|
|
6
|
+
import type { RecordingTap } from "../registry"
|
|
7
|
+
|
|
8
|
+
export interface TapSink {
|
|
9
|
+
/** Records a hub event into every active recording of (userID, sessionID); a no-op without one. */
|
|
10
|
+
hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
|
|
11
|
+
/** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
|
|
12
|
+
mayRecord(userID: string): boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
|
|
16
|
+
"session.execution.succeeded": "succeeded",
|
|
17
|
+
"session.execution.failed": "failed",
|
|
18
|
+
"session.execution.interrupted": "interrupted",
|
|
19
|
+
}
|
|
20
|
+
const TOOL_STATES: Record<string, "called" | "success" | "failed"> = {
|
|
21
|
+
"session.tool.called": "called",
|
|
22
|
+
"session.tool.success": "success",
|
|
23
|
+
"session.tool.failed": "failed",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const remember = <K, V>(map: Map<K, V>, key: K, value: V, max = 1000) => {
|
|
27
|
+
map.delete(key)
|
|
28
|
+
map.set(key, value)
|
|
29
|
+
if (map.size > max) map.delete(map.keys().next().value as K)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RegistryTaps implements RecordingTap {
|
|
33
|
+
/** Root sessions whose turn started and whose first text delta is not seen yet. */
|
|
34
|
+
private readonly firstToken = new Set<string>()
|
|
35
|
+
/** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
|
|
36
|
+
private readonly tools = new Map<string, string>()
|
|
37
|
+
|
|
38
|
+
constructor(private readonly sink: TapSink) {}
|
|
39
|
+
|
|
40
|
+
wants(tenant: string, sessionID: string, type: string): boolean {
|
|
41
|
+
if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
|
|
42
|
+
if (type === "session.execution.started") return true
|
|
43
|
+
return this.sink.mayRecord(tenant)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
47
|
+
if (type === "session.execution.started") {
|
|
48
|
+
this.firstToken.add(sessionID)
|
|
49
|
+
if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
|
|
50
|
+
this.sink.hub(tenant, sessionID, "turn.started", {})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const outcome = OUTCOMES[type]
|
|
54
|
+
if (outcome) {
|
|
55
|
+
this.firstToken.delete(sessionID)
|
|
56
|
+
this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (type === "session.text.delta") {
|
|
60
|
+
if (!this.firstToken.delete(sessionID)) return
|
|
61
|
+
const { assistantMessageID: messageID, ordinal } = data
|
|
62
|
+
this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const callID = typeof data.id === "string" ? data.id : undefined
|
|
66
|
+
if (type === "session.tool.input.started") {
|
|
67
|
+
if (callID && typeof data.name === "string") remember(this.tools, callID, data.name.slice(0, 120))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const state = TOOL_STATES[type]
|
|
71
|
+
if (!state || !callID) return
|
|
72
|
+
const tool = this.tools.get(callID)
|
|
73
|
+
if (state !== "called") this.tools.delete(callID)
|
|
74
|
+
// The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
|
|
75
|
+
const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
|
|
76
|
+
this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void {
|
|
80
|
+
this.sink.hub(tenant, sessionID, "say.sent", { messageID, ordinal, seq, text })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void {
|
|
84
|
+
if (!text.trim()) return
|
|
85
|
+
this.sink.hub(tenant, sessionID, "reply.full", { messageID, ordinal, text })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
heard(tenant: string, sessionID: string, inboxID: string): void {
|
|
89
|
+
this.sink.hub(tenant, sessionID, "utterance.delivered", { inboxID })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A TTS ref header "<messageID>:<ordinal>:<seq>:<i>" (seq may be empty); undefined when malformed. */
|
|
94
|
+
export function parseTtsRef(header: string | null): { messageID: string; ordinal: number; seq?: number; i: number } | undefined {
|
|
95
|
+
if (!header || header.length > 400) return undefined
|
|
96
|
+
const parts = header.split(":")
|
|
97
|
+
if (parts.length < 4) return undefined
|
|
98
|
+
const i = Number(parts.pop())
|
|
99
|
+
const seqText = parts.pop()!
|
|
100
|
+
const ordinal = Number(parts.pop())
|
|
101
|
+
const messageID = parts.join(":")
|
|
102
|
+
const seq = seqText === "" ? undefined : Number(seqText)
|
|
103
|
+
if (!messageID || !Number.isSafeInteger(i) || !Number.isSafeInteger(ordinal) || (seq !== undefined && !Number.isSafeInteger(seq))) return undefined
|
|
104
|
+
return { messageID, ordinal, ...(seq !== undefined ? { seq } : {}), i }
|
|
105
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// REC-01 streaming zip writer for recording exports (contract: Export response): the STORE method (no
|
|
2
|
+
// compression; WAV hardly compresses), CRC-32 (Bun.hash.crc32), data descriptors (flag bit 3) so nothing is
|
|
3
|
+
// buffered per entry, UTF-8 names (flag bit 11), no ZIP64 (RECORDING_LIMITS.userBytes keeps an export far below 4 GiB).
|
|
4
|
+
|
|
5
|
+
export interface ZipEntry {
|
|
6
|
+
/** Path inside the archive ("manifest.json", "<id>/audio/000001.wav"). */
|
|
7
|
+
readonly name: string
|
|
8
|
+
/** The entry's bytes, read once when the writer reaches it; undefined writes an empty entry. */
|
|
9
|
+
body(): Promise<Uint8Array | ReadableStream<Uint8Array> | undefined>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FLAGS = 0x0008 | 0x0800
|
|
13
|
+
/** 1980-01-01 00:00 in DOS time (the exports carry their own timestamps in the manifest). */
|
|
14
|
+
const DOS_TIME = 0
|
|
15
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1
|
|
16
|
+
|
|
17
|
+
interface Written {
|
|
18
|
+
readonly name: Uint8Array
|
|
19
|
+
readonly offset: number
|
|
20
|
+
readonly crc: number
|
|
21
|
+
readonly size: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const le = (size: number) => {
|
|
25
|
+
const bytes = new Uint8Array(size)
|
|
26
|
+
return { bytes, view: new DataView(bytes.buffer) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function localHeader(name: Uint8Array): Uint8Array {
|
|
30
|
+
const { bytes, view } = le(30 + name.length)
|
|
31
|
+
view.setUint32(0, 0x04034b50, true)
|
|
32
|
+
view.setUint16(4, 20, true)
|
|
33
|
+
view.setUint16(6, FLAGS, true)
|
|
34
|
+
view.setUint16(8, 0, true)
|
|
35
|
+
view.setUint16(10, DOS_TIME, true)
|
|
36
|
+
view.setUint16(12, DOS_DATE, true)
|
|
37
|
+
// CRC and sizes follow in the data descriptor.
|
|
38
|
+
view.setUint16(26, name.length, true)
|
|
39
|
+
bytes.set(name, 30)
|
|
40
|
+
return bytes
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function descriptor(crc: number, size: number): Uint8Array {
|
|
44
|
+
const { bytes, view } = le(16)
|
|
45
|
+
view.setUint32(0, 0x08074b50, true)
|
|
46
|
+
view.setUint32(4, crc, true)
|
|
47
|
+
view.setUint32(8, size, true)
|
|
48
|
+
view.setUint32(12, size, true)
|
|
49
|
+
return bytes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function centralEntry(entry: Written): Uint8Array {
|
|
53
|
+
const { bytes, view } = le(46 + entry.name.length)
|
|
54
|
+
view.setUint32(0, 0x02014b50, true)
|
|
55
|
+
view.setUint16(4, 20, true)
|
|
56
|
+
view.setUint16(6, 20, true)
|
|
57
|
+
view.setUint16(8, FLAGS, true)
|
|
58
|
+
view.setUint16(10, 0, true)
|
|
59
|
+
view.setUint16(12, DOS_TIME, true)
|
|
60
|
+
view.setUint16(14, DOS_DATE, true)
|
|
61
|
+
view.setUint32(16, entry.crc, true)
|
|
62
|
+
view.setUint32(20, entry.size, true)
|
|
63
|
+
view.setUint32(24, entry.size, true)
|
|
64
|
+
view.setUint16(28, entry.name.length, true)
|
|
65
|
+
view.setUint32(42, entry.offset, true)
|
|
66
|
+
bytes.set(entry.name, 46)
|
|
67
|
+
return bytes
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function endOfDirectory(count: number, size: number, offset: number): Uint8Array {
|
|
71
|
+
const { bytes, view } = le(22)
|
|
72
|
+
view.setUint32(0, 0x06054b50, true)
|
|
73
|
+
view.setUint16(8, count, true)
|
|
74
|
+
view.setUint16(10, count, true)
|
|
75
|
+
view.setUint32(12, size, true)
|
|
76
|
+
view.setUint32(16, offset, true)
|
|
77
|
+
return bytes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The archive of `entries`, streamed: each entry's body is read only when the reader gets to it. */
|
|
81
|
+
export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
|
|
82
|
+
async function* produce(): AsyncGenerator<Uint8Array> {
|
|
83
|
+
const written: Written[] = []
|
|
84
|
+
let offset = 0
|
|
85
|
+
for await (const entry of entries as AsyncIterable<ZipEntry>) {
|
|
86
|
+
const name = new TextEncoder().encode(entry.name)
|
|
87
|
+
const header = localHeader(name)
|
|
88
|
+
const start = offset
|
|
89
|
+
yield header
|
|
90
|
+
offset += header.length
|
|
91
|
+
let crc = 0
|
|
92
|
+
let size = 0
|
|
93
|
+
const body = await entry.body()
|
|
94
|
+
const take = (chunk: Uint8Array) => {
|
|
95
|
+
crc = Bun.hash.crc32(chunk, crc)
|
|
96
|
+
size += chunk.length
|
|
97
|
+
offset += chunk.length
|
|
98
|
+
}
|
|
99
|
+
if (body instanceof Uint8Array) {
|
|
100
|
+
take(body)
|
|
101
|
+
if (body.length) yield body
|
|
102
|
+
} else if (body) {
|
|
103
|
+
for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
|
|
104
|
+
take(chunk)
|
|
105
|
+
if (chunk.length) yield chunk
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
crc >>>= 0
|
|
109
|
+
const tail = descriptor(crc, size)
|
|
110
|
+
yield tail
|
|
111
|
+
offset += tail.length
|
|
112
|
+
written.push({ name, offset: start, crc, size })
|
|
113
|
+
}
|
|
114
|
+
const directoryStart = offset
|
|
115
|
+
let directorySize = 0
|
|
116
|
+
for (const entry of written) {
|
|
117
|
+
const bytes = centralEntry(entry)
|
|
118
|
+
directorySize += bytes.length
|
|
119
|
+
yield bytes
|
|
120
|
+
}
|
|
121
|
+
yield endOfDirectory(written.length, directorySize, directoryStart)
|
|
122
|
+
}
|
|
123
|
+
const iterator = produce()
|
|
124
|
+
return new ReadableStream<Uint8Array>({
|
|
125
|
+
async pull(controller) {
|
|
126
|
+
try {
|
|
127
|
+
const { done, value } = await iterator.next()
|
|
128
|
+
if (done) controller.close()
|
|
129
|
+
else controller.enqueue(value)
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
controller.error(cause)
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async cancel() {
|
|
135
|
+
await iterator.return(undefined)
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
package/src/central/registry.ts
CHANGED
|
@@ -20,6 +20,23 @@ import type { ActivityStep, NotificationKind } from "./push"
|
|
|
20
20
|
|
|
21
21
|
export { ACTIVITY_INTERVAL_MINUTES } from "./store"
|
|
22
22
|
/** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
|
|
23
|
+
/**
|
|
24
|
+
* (REC-01) Voice recording taps: what the hub sees of a recorded root session (docs/voice-recording.md). Called
|
|
25
|
+
* only from the session owner's connection (deduped like speakDelta), never changing behaviour; the tap writes
|
|
26
|
+
* only into recordings of `tenant` (the session's owner) and answers at once (its writes are asynchronous).
|
|
27
|
+
*/
|
|
28
|
+
export interface RecordingTap {
|
|
29
|
+
/** Cheap pre-check before an event is tapped (e.g. a text delta only while a first token is awaited). */
|
|
30
|
+
wants(tenant: string, sessionID: string, type: string): boolean
|
|
31
|
+
event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void
|
|
32
|
+
sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void
|
|
33
|
+
reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void
|
|
34
|
+
heard(tenant: string, sessionID: string, inboxID: string): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Spoke events a RecordingTap sees (root sessions only). */
|
|
38
|
+
const TAPPED = new Set(["session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.tool.input.started", "session.tool.called", "session.tool.success", "session.tool.failed", "session.text.delta"])
|
|
39
|
+
|
|
23
40
|
export const LONG_TOOL_MS = 30_000
|
|
24
41
|
|
|
25
42
|
export class BusyError extends Error {
|
|
@@ -399,6 +416,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
|
|
|
399
416
|
const isOn = (s: SessionInfo) => s.exposed !== false
|
|
400
417
|
|
|
401
418
|
export class Registry {
|
|
419
|
+
/** (REC-01) Voice recording taps; optional, best effort. */
|
|
420
|
+
recordingTap?: RecordingTap
|
|
421
|
+
|
|
402
422
|
private readonly conns = new Map<string, Conn>()
|
|
403
423
|
private readonly boards = new Map<string, StatusBoard>()
|
|
404
424
|
/**
|
|
@@ -744,6 +764,7 @@ export class Registry {
|
|
|
744
764
|
}
|
|
745
765
|
|
|
746
766
|
if (sessionID) this.trackRun(event.type, sessionID)
|
|
767
|
+
if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
|
|
747
768
|
const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
|
|
748
769
|
this.observeBoards(event, tenant)
|
|
749
770
|
if (sessionID) this.activitySteps(event.type, sessionID, data, before)
|
|
@@ -769,6 +790,31 @@ export class Registry {
|
|
|
769
790
|
if (closedPermission) this.permissionClosed(...closedPermission)
|
|
770
791
|
}
|
|
771
792
|
|
|
793
|
+
/** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
|
|
794
|
+
private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
|
|
795
|
+
const tap = this.recordingTap
|
|
796
|
+
if (!tap || tenant === undefined) return
|
|
797
|
+
try {
|
|
798
|
+
if (!tap.wants(tenant, sessionID, type) || this.rootOf(sessionID) !== sessionID || this.owner(sessionID) !== connID) return
|
|
799
|
+
tap.event(tenant, sessionID, type, data)
|
|
800
|
+
} catch (cause) {
|
|
801
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** (REC-01) A tap call that never throws into the registry. */
|
|
806
|
+
private tapSafely(call: (tap: RecordingTap, tenant: string) => void, sessionID: string): void {
|
|
807
|
+
const tap = this.recordingTap
|
|
808
|
+
if (!tap) return
|
|
809
|
+
const tenant = this.sessionTenant(sessionID)
|
|
810
|
+
if (tenant === undefined) return
|
|
811
|
+
try {
|
|
812
|
+
call(tap, tenant)
|
|
813
|
+
} catch (cause) {
|
|
814
|
+
console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
772
818
|
/** Records when a session's run started (kept by the first process to report it while it runs). */
|
|
773
819
|
private trackRun(type: string, sessionID: string): void {
|
|
774
820
|
if (type === "session.execution.started") {
|
|
@@ -816,8 +862,10 @@ export class Registry {
|
|
|
816
862
|
|
|
817
863
|
/** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
|
|
818
864
|
trackUtterance(sessionID: string, inboxID: string): void {
|
|
819
|
-
if (this.deliveries.delete(inboxID))
|
|
820
|
-
|
|
865
|
+
if (this.deliveries.delete(inboxID)) {
|
|
866
|
+
this.emit({ type: "heard", sessionID, inboxID })
|
|
867
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
868
|
+
} else remember(this.utterances, inboxID, sessionID)
|
|
821
869
|
}
|
|
822
870
|
|
|
823
871
|
/**
|
|
@@ -841,6 +889,7 @@ export class Registry {
|
|
|
841
889
|
if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
|
|
842
890
|
this.utterances.delete(inboxID)
|
|
843
891
|
this.emit({ type: "heard", sessionID, inboxID })
|
|
892
|
+
this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
|
|
844
893
|
}
|
|
845
894
|
|
|
846
895
|
/** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
|
|
@@ -872,13 +921,16 @@ export class Registry {
|
|
|
872
921
|
const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
|
|
873
922
|
this.rootSpeech.delete(key)
|
|
874
923
|
for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
|
|
924
|
+
this.tapSafely((tap, tenant) => tap.reply(tenant, sessionID, messageID, ordinal, text), sessionID)
|
|
875
925
|
// Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
|
|
876
926
|
if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
|
|
877
927
|
}
|
|
878
928
|
|
|
879
929
|
private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
|
|
880
930
|
this.heardFrom(state.sessionID, text)
|
|
881
|
-
|
|
931
|
+
const seq = state.seq++
|
|
932
|
+
this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq, text })
|
|
933
|
+
this.tapSafely((tap, tenant) => tap.sentence(tenant, state.sessionID, messageID, ordinal, seq, text), state.sessionID)
|
|
882
934
|
}
|
|
883
935
|
|
|
884
936
|
/** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
|
|
@@ -1808,6 +1860,19 @@ export class Registry {
|
|
|
1808
1860
|
}
|
|
1809
1861
|
}
|
|
1810
1862
|
|
|
1863
|
+
/**
|
|
1864
|
+
* The user dismissed the session's last run failure: its status board stops showing it (consoles and the Concierge
|
|
1865
|
+
* see the state leave "error") until it fails again. True when there was one to clear.
|
|
1866
|
+
*/
|
|
1867
|
+
dismissError(sessionID: string): boolean {
|
|
1868
|
+
const rootID = this.rootOf(sessionID)
|
|
1869
|
+
const board = rootID === undefined ? undefined : this.boards.get(rootID)
|
|
1870
|
+
if (rootID === undefined || !board?.observe({ type: "session.error.dismissed", data: { sessionID } })) return false
|
|
1871
|
+
this.changed(this.sessionTenant(rootID))
|
|
1872
|
+
this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
|
|
1873
|
+
return true
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1811
1876
|
/** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
|
|
1812
1877
|
cancelTurns(sessionID: string): void {
|
|
1813
1878
|
const rootID = this.rootOf(sessionID) ?? sessionID
|
package/src/central/spoke-ws.ts
CHANGED
|
@@ -521,6 +521,8 @@ export class SpokeHub {
|
|
|
521
521
|
const owner = this.options.registry.sessionTenant(sessionID)
|
|
522
522
|
if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
|
|
523
523
|
}
|
|
524
|
+
// A dismissed error: the session's status board is this pod's (its connection is here), not the calling pod's.
|
|
525
|
+
if (call.kind === "spoke" && call.req.op === "dismissError") this.options.registry.dismissError(call.req.sessionID)
|
|
524
526
|
return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
|
|
525
527
|
}
|
|
526
528
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
|
|
3
3
|
//
|
|
4
4
|
// - parts: instructions then notes are appended by the plugin's context hook after the profile parts; the board goes
|
|
5
|
-
// after the conversation
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// after the conversation (V-01: it changes between turns, and OpenCode sends tools, system, then messages, so a
|
|
6
|
+
// board in the system prompt made every push re-write the cached conversation; see applySessionContext) as
|
|
7
|
+
// user-side <system-update> text (V-01b: OmniRoute hoists a system message after the conversation back into the
|
|
8
|
+
// system prompt). The whole set replaces the previous one.
|
|
8
9
|
// - The kept tools are put in name order (the request lists them in the hook's record order).
|
|
9
10
|
// - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
|
|
10
11
|
// future tools included: fail closed). tools.deny: globs of tool names deleted as well.
|
|
@@ -73,22 +74,41 @@ function hint(message: HookMessage): void {
|
|
|
73
74
|
if (part && part.cache === undefined) message.content[index] = { ...part, cache: EPHEMERAL }
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** (V-01b) The board as user-side text: in <system-update> tags, its markup escaped so no text can close them. */
|
|
78
|
+
export function boardUpdate(tail: readonly string[]): string {
|
|
79
|
+
const text = tail
|
|
80
|
+
.join("\n\n")
|
|
81
|
+
.replace(/[\ud800-\udfff]/gu, "") // lone surrogates (a strict provider rejects the request)
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
return `<system-update>\n${text}\n</system-update>`
|
|
86
|
+
}
|
|
87
|
+
|
|
76
88
|
/**
|
|
77
89
|
* (V-01) Adds a session context to a context hook's request so the provider's prompt cache survives a board change.
|
|
78
90
|
* OpenCode sends tools, the system parts, then the messages; it puts cache breakpoints on the last tool, the first and
|
|
79
91
|
* last system part and the last message (4 at most). The stable parts end the system prompt (the last one carries a
|
|
80
|
-
* hint); the board follows the conversation as one
|
|
92
|
+
* hint); the board follows the conversation as one user message (with a hint), and the conversation's last message
|
|
81
93
|
* gets a hint, so the next turn reads everything up to it from the cache whatever the board says. Without a board
|
|
82
|
-
* nothing is added after the conversation; a request without messages gets the
|
|
94
|
+
* (or an empty one) nothing is added after the conversation; a request without messages gets the same <system-update>
|
|
95
|
+
* text at the end of its system prompt.
|
|
96
|
+
*
|
|
97
|
+
* (V-01b) The board is user-side text, never a system message: on the OmniRoute route (claude/claude-sonnet-5-5,
|
|
98
|
+
* measured through a logging proxy) a system message after the conversation is hoisted into the top-level system
|
|
99
|
+
* prompt, so a changed board invalidated the whole cached conversation again. The prompt's instructions say the
|
|
100
|
+
* board arrives in <system-update> tags and is never the user's words; boardUpdate escapes its markup.
|
|
83
101
|
*/
|
|
84
102
|
export function applySessionContext(event: { system: HookPart[]; messages?: HookMessage[] }, system: readonly string[], tail: readonly string[]): void {
|
|
85
103
|
const messages = Array.isArray(event.messages) ? event.messages : undefined
|
|
86
|
-
|
|
104
|
+
// (V-01b verify) An empty board adds nothing; a board is always the escaped <system-update> text, never raw.
|
|
105
|
+
const board = tail.filter((text) => text.trim() !== "")
|
|
106
|
+
const ending = messages || !board.length ? system : [...system, boardUpdate(board)]
|
|
87
107
|
ending.forEach((text, i) => event.system.push(i === ending.length - 1 ? { type: "text", text, cache: EPHEMERAL } : { type: "text", text }))
|
|
88
|
-
if (!messages || !
|
|
108
|
+
if (!messages || !board.length) return
|
|
89
109
|
const last = messages[lastIndex(messages, (m) => !isEffort(m))]
|
|
90
110
|
if (last) hint(last)
|
|
91
|
-
messages.push({ role: "
|
|
111
|
+
messages.push({ role: "user", content: [{ type: "text", text: boardUpdate(board), cache: EPHEMERAL }] })
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
/** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -1135,6 +1135,9 @@ export class Spoke {
|
|
|
1135
1135
|
return {}
|
|
1136
1136
|
case "rename":
|
|
1137
1137
|
return this.rename(req.sessionID, req.title)
|
|
1138
|
+
case "dismissError":
|
|
1139
|
+
this.unheardFailures.delete(req.sessionID)
|
|
1140
|
+
return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
|
|
1138
1141
|
case "autoName":
|
|
1139
1142
|
return this.autoName(req.sessionID)
|
|
1140
1143
|
case "stop":
|
package/src/spoke/tracker.ts
CHANGED
|
@@ -94,7 +94,7 @@ export class SessionTracker {
|
|
|
94
94
|
private readonly jobs = new Map<string, { sessionID: string; running: boolean }>()
|
|
95
95
|
/** A session's pending retry (session.retry.scheduled, or a retry status), until its run moves on. */
|
|
96
96
|
private readonly retries = new Map<string, RetryStatus>()
|
|
97
|
-
/** A session's last run failure, until its next run starts. */
|
|
97
|
+
/** A session's last run failure, until its next run starts (or the user dismisses it). */
|
|
98
98
|
private readonly errors = new Map<string, CompactRunError>()
|
|
99
99
|
|
|
100
100
|
/** Applies an event seen in this process. Returns true when the snapshot may have changed. */
|
|
@@ -339,6 +339,11 @@ export class SessionTracker {
|
|
|
339
339
|
return this.errors.get(sessionID)
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/** Forgets the session's last run failure (the user dismissed it). True when there was one. */
|
|
343
|
+
dismissError(sessionID: string): boolean {
|
|
344
|
+
return this.errors.delete(sessionID)
|
|
345
|
+
}
|
|
346
|
+
|
|
342
347
|
/** Sessions whose last run failed (no run started since). */
|
|
343
348
|
failed(): string[] {
|
|
344
349
|
return [...this.errors.keys()]
|
package/src/status.ts
CHANGED
|
@@ -271,6 +271,8 @@ interface Entry {
|
|
|
271
271
|
error?: string
|
|
272
272
|
errorType?: string
|
|
273
273
|
errorStatus?: number
|
|
274
|
+
/** The error the user dismissed (fingerprint), so a seed of it (a spoke that cannot forget it) stays hidden. */
|
|
275
|
+
dismissed?: string
|
|
274
276
|
blockers: Blocker[]
|
|
275
277
|
jobs: JobInfo[]
|
|
276
278
|
}
|
|
@@ -313,6 +315,7 @@ export class StatusBoard {
|
|
|
313
315
|
entry.running = true
|
|
314
316
|
entry.retry = undefined
|
|
315
317
|
clearError(entry)
|
|
318
|
+
entry.dismissed = undefined
|
|
316
319
|
})
|
|
317
320
|
case "session.execution.succeeded":
|
|
318
321
|
case "session.execution.interrupted":
|
|
@@ -321,6 +324,7 @@ export class StatusBoard {
|
|
|
321
324
|
return this.update(data.sessionID, (entry) => {
|
|
322
325
|
endRun(entry)
|
|
323
326
|
setError(entry, data.error)
|
|
327
|
+
entry.dismissed = undefined
|
|
324
328
|
})
|
|
325
329
|
// Output resumed: the retry succeeded (OpenCode sends no status for it).
|
|
326
330
|
case "session.text.delta":
|
|
@@ -349,10 +353,18 @@ export class StatusBoard {
|
|
|
349
353
|
return this.unblock(data.sessionID, data.id)
|
|
350
354
|
case "session.status":
|
|
351
355
|
return this.update(data.sessionID, (entry) => setStatus(entry, data.status))
|
|
356
|
+
// Hub-defined: the user dismissed the session's last error (it shows again on its next failure).
|
|
357
|
+
case "session.error.dismissed": {
|
|
358
|
+
const entry = typeof data.sessionID === "string" ? this.sessions.get(data.sessionID) : undefined
|
|
359
|
+
if (!entry?.error) return false
|
|
360
|
+
entry.dismissed = fingerprint(entry)
|
|
361
|
+
clearError(entry)
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
352
364
|
case "session.seed":
|
|
353
365
|
return this.update(data.sessionID, (entry) => {
|
|
354
366
|
setStatus(entry, data.status)
|
|
355
|
-
if (typeof data.error?.message === "string" && data.error.message) setError(entry, data.error)
|
|
367
|
+
if (typeof data.error?.message === "string" && data.error.message && fingerprint(errorFields(data.error)) !== entry.dismissed) setError(entry, data.error)
|
|
356
368
|
entry.jobs = entry.jobs.filter((job) => job.status !== "running")
|
|
357
369
|
for (const job of Array.isArray(data.jobs) ? data.jobs : []) if (isJob(job)) upsertJob(entry, job)
|
|
358
370
|
})
|
|
@@ -477,17 +489,31 @@ function setStatus(entry: Entry, status: any): void {
|
|
|
477
489
|
...(errorStatus !== undefined ? { errorStatus } : {}),
|
|
478
490
|
}
|
|
479
491
|
} else entry.retry = undefined
|
|
480
|
-
if (type === "busy")
|
|
492
|
+
if (type === "busy") {
|
|
493
|
+
clearError(entry)
|
|
494
|
+
entry.dismissed = undefined
|
|
495
|
+
}
|
|
481
496
|
}
|
|
482
497
|
|
|
483
498
|
const ERROR_TYPE_MAX = 100
|
|
484
499
|
const ERROR_MESSAGE_MAX = 1000
|
|
485
500
|
|
|
486
501
|
/** A failed run's error: its message, type and status. */
|
|
502
|
+
function errorFields(error: any): Pick<Entry, "error" | "errorType" | "errorStatus"> {
|
|
503
|
+
return {
|
|
504
|
+
error: typeof error?.message === "string" && error.message ? error.message.slice(0, ERROR_MESSAGE_MAX) : "failed",
|
|
505
|
+
errorType: text(error?.type, ERROR_TYPE_MAX),
|
|
506
|
+
errorStatus: num(error?.status),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
487
510
|
function setError(entry: Entry, error: any): void {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
511
|
+
Object.assign(entry, errorFields(error))
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** An error as setError keeps it (message, type, status), to recognise a dismissed one when seeded again. */
|
|
515
|
+
function fingerprint(entry: Pick<Entry, "error" | "errorType" | "errorStatus">): string {
|
|
516
|
+
return JSON.stringify([entry.error, entry.errorType ?? null, entry.errorStatus ?? null])
|
|
491
517
|
}
|
|
492
518
|
|
|
493
519
|
function clearError(entry: Entry): void {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-
|
|
1
|
+
import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{L as o,R as s,z as c}from"./index-6mdQ7eUY.js";import{t as l}from"./ArtifactWorkbench-CkmWnP6L.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-
|
|
1
|
+
import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Qo2Pd9-O.js";import{t as o}from"./ArtifactWorkbench-CkmWnP6L.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};
|