dsh-thoughtdag 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/cordis.patch.yml +11 -0
- package/dist-app/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist-app/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist-app/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist-app/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist-app/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist-app/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist-app/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist-app/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist-app/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist-app/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist-app/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist-app/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist-app/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist-app/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist-app/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist-app/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist-app/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist-app/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist-app/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist-app/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist-app/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist-app/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist-app/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist-app/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist-app/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist-app/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist-app/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist-app/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist-app/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist-app/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist-app/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist-app/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist-app/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist-app/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist-app/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist-app/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist-app/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist-app/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist-app/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist-app/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist-app/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist-app/assets/canonical-DbPqsMYz.js +2 -0
- package/dist-app/assets/canvas-record-C1A8ExlM.js +1 -0
- package/dist-app/assets/claude-code-session-DQRgWy00.js +7 -0
- package/dist-app/assets/codex-session--KVj3_vb.js +12 -0
- package/dist-app/assets/dsh-session-6OCSm4h5.js +6 -0
- package/dist-app/assets/experiment-loop-BfxITu2e.js +2 -0
- package/dist-app/assets/index-6m02Pb4Q.js +761 -0
- package/dist-app/assets/index-BH-U0zVI.js +105 -0
- package/dist-app/assets/index-BL39OXsY.js +221 -0
- package/dist-app/assets/index-BeoRn2gJ.js +2 -0
- package/dist-app/assets/index-D-BTv0n0.js +3 -0
- package/dist-app/assets/index-DEHbIrRr.css +1 -0
- package/dist-app/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist-app/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist-app/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist-app/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist-app/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist-app/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist-app/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist-app/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist-app/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist-app/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist-app/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist-app/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist-app/assets/live-mirror-BElcCk_a.js +2 -0
- package/dist-app/assets/pdf-gh-BrLFn.js +12 -0
- package/dist-app/assets/pdf.worker.min-C_APr3k7.js +1 -0
- package/dist-app/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- package/dist-app/assets/purify.es-Cz4mVeUR.js +3 -0
- package/dist-app/assets/sensitive-scan-L0rFzufE.js +7 -0
- package/dist-app/assets/session-handoff-CGrowwZp.js +2 -0
- package/dist-app/assets/shared-CURSZnXt.js +10 -0
- package/dist-app/assets/turndown-plugin-gfm.cjs-p3SpZgsj.js +22 -0
- package/dist-app/assets/turndown.browser.es-kLzRgxYD.js +61 -0
- package/dist-app/assets/update-check-D5daO3Cz.js +1 -0
- package/dist-app/favicon.svg +10 -0
- package/dist-app/index.html +31 -0
- package/dist-app/logo-social.png +0 -0
- package/dist-app/logo-social.svg +52 -0
- package/dist-app/og-twitter.jpg +0 -0
- package/dist-app/og.jpg +0 -0
- package/dist-app/robots.txt +4 -0
- package/dist-app/sitemap.xml +6 -0
- package/dist-app/tutorial/map-en.gif +0 -0
- package/dist-app/tutorial/map-zh.gif +0 -0
- package/dist-app/tutorial/prune-en.gif +0 -0
- package/dist-app/tutorial/prune-zh.gif +0 -0
- package/dist-app/tutorial/reading-en.gif +0 -0
- package/dist-app/tutorial/reading-zh.gif +0 -0
- package/dist-app/tutorial/ref-en.gif +0 -0
- package/dist-app/tutorial/ref-zh.gif +0 -0
- package/lib/client.js +84 -0
- package/lib/index.js +845 -0
- package/package.json +107 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
// dsh-thoughtdag host half — a plain Cordis plugin that mounts the
|
|
2
|
+
// ThoughtDAG SPA under a prefix on the EXISTING DSH web server (no second
|
|
3
|
+
// process, no second port) and bridges DSH sessions to the canvas.
|
|
4
|
+
//
|
|
5
|
+
// Layout of this file, mirroring dsh-synapse:
|
|
6
|
+
// - the static ThoughtDAG build lives in ../dist-app (a Vite build with
|
|
7
|
+
// base = the mount prefix, so every asset reference is already
|
|
8
|
+
// /<prefix>/... and resolves through the routes registered below)
|
|
9
|
+
// - /<prefix>/ the SPA shell (index.html)
|
|
10
|
+
// - /<prefix>/<asset...> static assets (prefix route, path-traversal safe)
|
|
11
|
+
// - /<prefix>/api/... the session bridge (Host-header fenced)
|
|
12
|
+
//
|
|
13
|
+
// The bridge's read direction feeds the ThoughtDAG dsh-session adapter:
|
|
14
|
+
// the adapter parses the same event JSONL a real ~/.dsh session log holds,
|
|
15
|
+
// so the bridge serves a session's durable events as that text.
|
|
16
|
+
//
|
|
17
|
+
// The write direction is how an edited canvas becomes the model's next
|
|
18
|
+
// context — through DSH's own primitives, never by touching a log:
|
|
19
|
+
// POST /sessions/<id>/fork { afterTurn | atSeq } → a child session that
|
|
20
|
+
// inherits the prefix up to that turn boundary
|
|
21
|
+
// (sessionController.fork: the UI sees it too)
|
|
22
|
+
// POST /sessions/<id>/inject { text | blocks } → model-facing context
|
|
23
|
+
// for the next step (agent.inject: no wake,
|
|
24
|
+
// shown in the transcript as injected context)
|
|
25
|
+
// POST /sessions/<id>/followup { text | blocks, mode } → a user prompt that
|
|
26
|
+
// wakes the agent (sessionController.prompt)
|
|
27
|
+
// GET /sessions/<id>/turns turn boundaries with their seqs, so the
|
|
28
|
+
// canvas can name a fork point by turn
|
|
29
|
+
//
|
|
30
|
+
// The other agents on this machine: the host is a local Node process, so it
|
|
31
|
+
// can read what the desktop shell reads — Claude Code's ~/.claude/projects
|
|
32
|
+
// and Codex's ~/.codex/sessions — and serve them with the desktop bridge's
|
|
33
|
+
// own primitives, so Session Atlas inside the harness sees all three:
|
|
34
|
+
// GET /roots the two file roots (key, path, exists)
|
|
35
|
+
// GET /roots/<key>/list every .jsonl under a root (rel, size, mtime)
|
|
36
|
+
// GET /roots/<key>/head?rel&bytes the first bytes of one file, as text
|
|
37
|
+
// GET /roots/<key>/read?rel one whole file
|
|
38
|
+
// GET /roots/<key>/range?rel&start&length a line-aligned byte window
|
|
39
|
+
// Everything the model then sees is on the child's log, in DSH's own
|
|
40
|
+
// event vocabulary: switching back to the chat shows exactly that.
|
|
41
|
+
//
|
|
42
|
+
// The model connection: inside the harness ThoughtDAG has no proxy of its
|
|
43
|
+
// own, so this host also answers the SPA's proxy protocol with the harness's
|
|
44
|
+
// providers and credentials (the SPA is built with VITE_API_BASE=/thoughtdag):
|
|
45
|
+
// GET /models the harness's model catalog in the SPA's list shape
|
|
46
|
+
// (vision from each adapter's declared input modalities)
|
|
47
|
+
// POST /stream one model call, streamed as the SPA's SSE frames; images
|
|
48
|
+
// enter the attachment store and ride the last user message
|
|
49
|
+
// POST /claude the same call, whole answer as JSON
|
|
50
|
+
// POST /fetch-url the SPA's link snapshot, through the harness's bounded fetcher
|
|
51
|
+
// These are canvas-native calls (summaries, condensing, a canvas that is
|
|
52
|
+
// not a mirrored session) — they run on the harness's models but do not
|
|
53
|
+
// enter any session log; a mirrored session's turns go through /followup.
|
|
54
|
+
//
|
|
55
|
+
// One entry in that catalog is not a model: "harness/agent". Picking it
|
|
56
|
+
// sends the question INTO the harness — a fresh session per call, the
|
|
57
|
+
// canvas's wired context injected first, the harness running its own agent
|
|
58
|
+
// loop with tools — and streams the agent's text, reasoning and tool calls
|
|
59
|
+
// back as the same SSE frames. The session stays: the Chat lists it, the
|
|
60
|
+
// atlas can open it, and the canvas gets its id in the first frame.
|
|
61
|
+
|
|
62
|
+
import { randomUUID } from 'node:crypto'
|
|
63
|
+
import { open, readFile, readdir, stat } from 'node:fs/promises'
|
|
64
|
+
import { extname, join, normalize, resolve } from 'node:path'
|
|
65
|
+
import os from 'node:os'
|
|
66
|
+
import { fileURLToPath } from 'node:url'
|
|
67
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
68
|
+
|
|
69
|
+
export const name = 'thoughtdag'
|
|
70
|
+
export const inject = ['webServer', 'sessions', 'sessionController', 'agents', 'llm', 'attachments', 'web']
|
|
71
|
+
|
|
72
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
73
|
+
const APP_DIR = resolve(__dirname, '../dist-app')
|
|
74
|
+
|
|
75
|
+
const MAX_BODY_BYTES = 32 * 1024
|
|
76
|
+
// a compiled canvas context can be long; the write endpoints take up to this
|
|
77
|
+
const MAX_WRITE_BODY_BYTES = 4 * 1024 * 1024
|
|
78
|
+
// a model call carries the whole context the canvas compiled
|
|
79
|
+
const MAX_CALL_BODY_BYTES = 24 * 1024 * 1024
|
|
80
|
+
const MIME = {
|
|
81
|
+
'.html': 'text/html; charset=utf-8',
|
|
82
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
83
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
84
|
+
'.css': 'text/css; charset=utf-8',
|
|
85
|
+
'.svg': 'image/svg+xml',
|
|
86
|
+
'.png': 'image/png',
|
|
87
|
+
'.jpg': 'image/jpeg',
|
|
88
|
+
'.jpeg': 'image/jpeg',
|
|
89
|
+
'.gif': 'image/gif',
|
|
90
|
+
'.webp': 'image/webp',
|
|
91
|
+
'.json': 'application/json; charset=utf-8',
|
|
92
|
+
'.woff': 'font/woff',
|
|
93
|
+
'.woff2': 'font/woff2',
|
|
94
|
+
'.ttf': 'font/ttf',
|
|
95
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
96
|
+
'.xml': 'application/xml',
|
|
97
|
+
'.ico': 'image/x-icon',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Owns an error + an HTTP status; rendered as a JSON body. */
|
|
101
|
+
class HttpError extends Error {
|
|
102
|
+
constructor(status, message) {
|
|
103
|
+
super(message)
|
|
104
|
+
this.status = status
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readJson(req, limit = MAX_BODY_BYTES) {
|
|
109
|
+
const chunks = []
|
|
110
|
+
let length = 0
|
|
111
|
+
for await (const chunk of req) {
|
|
112
|
+
length += chunk.length
|
|
113
|
+
if (length > limit) throw new HttpError(413, 'request body too large')
|
|
114
|
+
chunks.push(chunk)
|
|
115
|
+
}
|
|
116
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { throw new HttpError(400, 'request body is not valid JSON') }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sendJson(res, status, body) {
|
|
120
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
121
|
+
res.end(JSON.stringify(body))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sendFile(res, contentType, body) {
|
|
125
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' })
|
|
126
|
+
res.end(body)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Static asset path under APP_DIR, or null when it escapes (traversal). */
|
|
130
|
+
function assetPath(rel) {
|
|
131
|
+
const normalized = normalize(rel)
|
|
132
|
+
const abs = resolve(APP_DIR, normalized)
|
|
133
|
+
if (abs !== APP_DIR && !abs.startsWith(APP_DIR + '/')) return null
|
|
134
|
+
return abs
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every event of a live session, inherited prefix included — the Session
|
|
138
|
+
* class exposes a snapshot read, not a raw array. */
|
|
139
|
+
function allEvents(session) {
|
|
140
|
+
if (typeof session.snapshotEvents === 'function') return session.snapshotEvents()
|
|
141
|
+
return session.events ?? []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Session list the bridge exposes: identity + a cheap title, never the log. */
|
|
145
|
+
function sessionSummary(session) {
|
|
146
|
+
const header = session.header ?? {}
|
|
147
|
+
const meta = header.meta ?? {}
|
|
148
|
+
return {
|
|
149
|
+
id: session.id,
|
|
150
|
+
title: session.displayTitle ?? null,
|
|
151
|
+
cwd: meta.cwd ?? null,
|
|
152
|
+
parentSession: header.parentSession ?? undefined,
|
|
153
|
+
createdAt: header.createdAt ?? null,
|
|
154
|
+
firstLiveSeq: session.firstLiveSeq ?? null,
|
|
155
|
+
// the log's current length: a poller compares it to know the session moved
|
|
156
|
+
seq: typeof session.seq === 'number' ? session.seq : null,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Serve one session's durable event log as JSONL text (the input dialect of
|
|
161
|
+
* ThoughtDAG's dsh-session adapter). Sequenced events are serialized in log
|
|
162
|
+
* order; the exact wire shape mirrors the on-disk session.jsonl.zstd rows
|
|
163
|
+
* (a type/session header line first), so the canvas imports a live DSH
|
|
164
|
+
* session exactly like a file on disk. */
|
|
165
|
+
function sessionToJsonl(session) {
|
|
166
|
+
const lines = []
|
|
167
|
+
const header = session.header ?? {}
|
|
168
|
+
const hdr = { type: 'session', version: 0, id: session.id }
|
|
169
|
+
if (header.createdAt !== undefined) hdr.createdAt = header.createdAt
|
|
170
|
+
const meta = header.meta
|
|
171
|
+
if (meta?.cwd !== undefined) hdr.cwd = meta.cwd
|
|
172
|
+
// fork lineage, exactly as the on-disk header carries it: who the parent
|
|
173
|
+
// is and how many leading events are inherited (a reader of the log tells
|
|
174
|
+
// the child's own turns from the prefix by seq >= seedLength)
|
|
175
|
+
if (header.parentSession !== undefined) hdr.parentSession = header.parentSession
|
|
176
|
+
if (typeof session.inheritedEventCount === 'number' && session.inheritedEventCount > 0) hdr.seedLength = session.inheritedEventCount
|
|
177
|
+
lines.push(JSON.stringify(hdr))
|
|
178
|
+
for (const event of allEvents(session)) {
|
|
179
|
+
lines.push(JSON.stringify(event))
|
|
180
|
+
}
|
|
181
|
+
return lines.join('\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── durable-session bridge (disk) ──────────────────────────────────────
|
|
185
|
+
// DSH persists each session as session.jsonl.zstd under
|
|
186
|
+
// $DSH_HOME/sessions/<encoded-cwd>/<session-id>/. The zstd log is a
|
|
187
|
+
// CONCATENATION of independently-encoded frames (one per durable batch
|
|
188
|
+
// append); Node's one-shot zstdDecompressSync only decodes the FIRST frame,
|
|
189
|
+
// so frames are located structurally and decoded one by one (the same
|
|
190
|
+
// container walk the DSH persistence backend performs). A final torn frame
|
|
191
|
+
// (live tail) is dropped; the next read picks it up whole.
|
|
192
|
+
|
|
193
|
+
const ZSTD_MAGIC = 4247762216 // 0xFD2FB528 LE
|
|
194
|
+
function scanZstdFrames(buffer) {
|
|
195
|
+
const frames = []
|
|
196
|
+
let offset = 0
|
|
197
|
+
while (offset < buffer.length) {
|
|
198
|
+
const start = offset
|
|
199
|
+
if (buffer.length - offset < 4) break
|
|
200
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return { frames }
|
|
201
|
+
offset += 4
|
|
202
|
+
if (offset === buffer.length) break
|
|
203
|
+
const descriptor = buffer.readUInt8(offset)
|
|
204
|
+
offset += 1
|
|
205
|
+
const contentSizeFlag = descriptor >>> 6
|
|
206
|
+
const singleSegment = (descriptor & 32) !== 0
|
|
207
|
+
const checksum = (descriptor & 4) !== 0
|
|
208
|
+
const dictionaryFlag = descriptor & 3
|
|
209
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
|
210
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
211
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
212
|
+
if (buffer.length - offset < remainingHeaderBytes) break
|
|
213
|
+
offset += remainingHeaderBytes
|
|
214
|
+
for (;;) {
|
|
215
|
+
if (buffer.length - offset < 3) return { frames }
|
|
216
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
217
|
+
offset += 3
|
|
218
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
219
|
+
const blockType = (blockHeader >>> 1) & 3
|
|
220
|
+
const blockSize = blockHeader >>> 3
|
|
221
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize
|
|
222
|
+
if (buffer.length - offset < payloadBytes) return { frames }
|
|
223
|
+
offset += payloadBytes
|
|
224
|
+
if (lastBlock) break
|
|
225
|
+
}
|
|
226
|
+
if (checksum) {
|
|
227
|
+
if (buffer.length - offset < 4) return { frames }
|
|
228
|
+
offset += 4
|
|
229
|
+
}
|
|
230
|
+
frames.push({ start, end: offset })
|
|
231
|
+
}
|
|
232
|
+
return { frames }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Decode a concatenated-frame zstd buffer to UTF-8 text ('' when corrupt). */
|
|
236
|
+
function decompressZstdToText(raw) {
|
|
237
|
+
try {
|
|
238
|
+
const { frames } = scanZstdFrames(raw)
|
|
239
|
+
if (frames.length === 0) return ''
|
|
240
|
+
const parts = []
|
|
241
|
+
for (const { start, end } of frames) parts.push(zstdDecompressSync(raw.subarray(start, end)))
|
|
242
|
+
return Buffer.concat(parts).toString('utf8')
|
|
243
|
+
} catch { return '' }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Walk $DSH_HOME/sessions for session.jsonl.zstd files (2 levels deep). */
|
|
247
|
+
async function findSessionFiles(dshHome) {
|
|
248
|
+
const root = join(dshHome, 'sessions')
|
|
249
|
+
const out = []
|
|
250
|
+
let entries
|
|
251
|
+
try { entries = await readdir(root, { withFileTypes: true }) } catch { return out }
|
|
252
|
+
for (const ws of entries) {
|
|
253
|
+
if (!ws.isDirectory()) continue
|
|
254
|
+
let sessions
|
|
255
|
+
try { sessions = await readdir(join(root, ws.name), { withFileTypes: true }) } catch { continue }
|
|
256
|
+
for (const s of sessions) {
|
|
257
|
+
if (!s.isDirectory()) continue
|
|
258
|
+
const log = join(root, ws.name, s.name, 'session.jsonl.zstd')
|
|
259
|
+
try {
|
|
260
|
+
const st = await stat(log)
|
|
261
|
+
out.push({ dir: ws.name, sessionDir: s.name, log, size: st.size, mtime: st.mtimeMs })
|
|
262
|
+
} catch { /* no log yet */ }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Cheap title from the FIRST zstd frame only (bounded read): the session
|
|
269
|
+
* header names the id+cwd; session/title arrives right after in live logs.
|
|
270
|
+
* Falls back to the session id when the frame boundary cuts early. */
|
|
271
|
+
const HEAD_READ_BYTES = 256 * 1024 // covers the first frames: header + title + opening turns
|
|
272
|
+
async function sessionTitleFromHead(logPath) {
|
|
273
|
+
try {
|
|
274
|
+
const fh = await open(logPath, 'r')
|
|
275
|
+
let buf
|
|
276
|
+
try {
|
|
277
|
+
const st = await fh.stat()
|
|
278
|
+
const want = Math.min(st.size, HEAD_READ_BYTES)
|
|
279
|
+
const b = Buffer.alloc(want)
|
|
280
|
+
const { bytesRead } = await fh.read(b, 0, want, 0)
|
|
281
|
+
buf = b.subarray(0, bytesRead)
|
|
282
|
+
} finally { await fh.close() }
|
|
283
|
+
const text = decompressZstdToText(buf)
|
|
284
|
+
let title = null
|
|
285
|
+
let id = null
|
|
286
|
+
let cwd = null
|
|
287
|
+
for (const raw of text.split('\n')) {
|
|
288
|
+
try {
|
|
289
|
+
const o = JSON.parse(raw)
|
|
290
|
+
if (o.type === 'session') {
|
|
291
|
+
if (typeof o.id === 'string') id = o.id
|
|
292
|
+
if (typeof o.cwd === 'string') cwd = o.cwd
|
|
293
|
+
}
|
|
294
|
+
if (o.type === 'session/title' && typeof o.data?.title === 'string') { title = o.data.title; break }
|
|
295
|
+
} catch { /* partial tail */ }
|
|
296
|
+
}
|
|
297
|
+
return { title, id, cwd }
|
|
298
|
+
} catch { return { title: null, id: null } }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── write direction helpers ────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/** The live session for an id, across the store's method spellings. */
|
|
304
|
+
const liveSession = (ctx, id) => (typeof ctx.sessions.get === 'function' ? ctx.sessions.get(id) : ctx.sessions.sessionOf?.(id)) ?? undefined
|
|
305
|
+
|
|
306
|
+
/** Turn boundaries of an event list: where each turn starts and ends (seq),
|
|
307
|
+
* and the id of the message a person actually sent in it. A fork can only
|
|
308
|
+
* cut at an endSeq (DSH rejects a boundary inside an open turn). */
|
|
309
|
+
function turnsOf(events) {
|
|
310
|
+
const turns = new Map()
|
|
311
|
+
for (const e of events) {
|
|
312
|
+
if (!e || typeof e.type !== 'string') continue
|
|
313
|
+
const n = e.data?.turn
|
|
314
|
+
if (e.type === 'turn/start' && typeof n === 'number') turns.set(n, { turn: n, startSeq: e.seq, endSeq: null, userMessageId: null })
|
|
315
|
+
else if (e.type === 'turn/end' && typeof n === 'number' && turns.has(n)) turns.get(n).endSeq = e.seq
|
|
316
|
+
else if (e.type === 'user/message' && (e.data?.source?.kind ?? 'user') === 'user') {
|
|
317
|
+
const openTurn = [...turns.values()].reverse().find(t => t.endSeq === null)
|
|
318
|
+
if (openTurn && openTurn.userMessageId === null) openTurn.userMessageId = e.data?.id ?? null
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return [...turns.values()]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Events of a session by id: the live store first, else the durable log. */
|
|
325
|
+
async function eventsOfSession(ctx, id) {
|
|
326
|
+
const live = liveSession(ctx, id)
|
|
327
|
+
if (live !== undefined) return allEvents(live)
|
|
328
|
+
const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
|
|
329
|
+
const files = await findSessionFiles(dshHome)
|
|
330
|
+
const hit = files.find(f => f.sessionDir === id)
|
|
331
|
+
if (!hit) return null
|
|
332
|
+
const raw = await readFile(hit.log).catch(() => null)
|
|
333
|
+
if (!raw) return null
|
|
334
|
+
const events = []
|
|
335
|
+
for (const line of decompressZstdToText(raw).split('\n')) {
|
|
336
|
+
if (!line) continue
|
|
337
|
+
try { events.push(JSON.parse(line)) } catch { /* torn tail */ }
|
|
338
|
+
}
|
|
339
|
+
return events
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Model-facing content from a request body: `blocks` (text blocks only for
|
|
343
|
+
* now — images arrive through the attachment store in a later step) or a
|
|
344
|
+
* plain `text`. Empty content is a 400, never a silent no-op. */
|
|
345
|
+
function contentOf(body) {
|
|
346
|
+
if (Array.isArray(body?.blocks)) {
|
|
347
|
+
const blocks = body.blocks.filter(b => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0).map(b => ({ type: 'text', text: b.text }))
|
|
348
|
+
if (blocks.length === 0) throw new HttpError(400, 'blocks: no text block with content')
|
|
349
|
+
if (blocks.length !== body.blocks.length) throw new HttpError(400, 'blocks: only { type: "text", text } is accepted here')
|
|
350
|
+
return blocks
|
|
351
|
+
}
|
|
352
|
+
if (typeof body?.text === 'string' && body.text.length > 0) return [{ type: 'text', text: body.text }]
|
|
353
|
+
throw new HttpError(400, 'body needs text or blocks')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const SOURCE = { kind: 'plugin', plugin: 'dsh-thoughtdag' }
|
|
357
|
+
|
|
358
|
+
// ── the other agents' session files on this machine ────────────────────
|
|
359
|
+
|
|
360
|
+
const FILE_ROOTS = {
|
|
361
|
+
'claude-projects': join(os.homedir(), '.claude', 'projects'),
|
|
362
|
+
'codex-sessions': join(os.homedir(), '.codex', 'sessions'),
|
|
363
|
+
}
|
|
364
|
+
const FILE_READ_MAX = 256 * 1024 * 1024
|
|
365
|
+
|
|
366
|
+
/** An absolute path inside a root, or a 400/404 — rel never escapes. */
|
|
367
|
+
function fileInRoot(rootKey, rel) {
|
|
368
|
+
const root = FILE_ROOTS[rootKey]
|
|
369
|
+
if (!root) throw new HttpError(404, 'no such root')
|
|
370
|
+
if (typeof rel !== 'string' || !rel) throw new HttpError(400, 'rel required')
|
|
371
|
+
const abs = resolve(root, rel)
|
|
372
|
+
if (abs !== root && !abs.startsWith(root + '/')) throw new HttpError(400, 'rel escapes its root')
|
|
373
|
+
return abs
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every session file under a root, two-to-five levels deep like the desktop's walk. */
|
|
377
|
+
async function listRoot(rootKey) {
|
|
378
|
+
const root = FILE_ROOTS[rootKey]
|
|
379
|
+
if (!root) throw new HttpError(404, 'no such root')
|
|
380
|
+
const out = []
|
|
381
|
+
const walk = async (dir, depth) => {
|
|
382
|
+
if (depth > 5) return
|
|
383
|
+
let entries
|
|
384
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
385
|
+
for (const ent of entries) {
|
|
386
|
+
const p = join(dir, ent.name)
|
|
387
|
+
if (ent.isDirectory()) await walk(p, depth + 1)
|
|
388
|
+
else if (ent.isFile() && ent.name.endsWith('.jsonl')) {
|
|
389
|
+
try { const st = await stat(p); out.push({ rel: p.slice(root.length + 1), size: st.size, mtime: st.mtimeMs }) } catch { /* raced */ }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await walk(root, 0)
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The first `bytes` of a file, as text (the desktop bridge's head). */
|
|
398
|
+
async function headOfFile(abs, bytes) {
|
|
399
|
+
const n = Math.min(Math.max(1024, bytes | 0), 524288)
|
|
400
|
+
const fh = await open(abs, 'r')
|
|
401
|
+
try { const buf = Buffer.alloc(n); const { bytesRead } = await fh.read(buf, 0, n, 0); return buf.subarray(0, bytesRead).toString('utf8') } finally { await fh.close() }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A byte window cut on a line boundary (the desktop bridge's read-range). */
|
|
405
|
+
async function rangeOfFile(abs, start, length) {
|
|
406
|
+
const fh = await open(abs, 'r')
|
|
407
|
+
try {
|
|
408
|
+
const from = Math.max(0, Number(start) || 0)
|
|
409
|
+
const want = Math.min(Math.max(65536, Number(length) || 0), 32 * 1024 * 1024)
|
|
410
|
+
const total = (await fh.stat()).size
|
|
411
|
+
const size = Math.min(want, Math.max(0, total - from))
|
|
412
|
+
if (size === 0) return { text: '', nextStart: from, eof: true }
|
|
413
|
+
const b = Buffer.alloc(size)
|
|
414
|
+
const { bytesRead } = await fh.read(b, 0, size, from)
|
|
415
|
+
let slice = b.subarray(0, bytesRead)
|
|
416
|
+
const eof = from + size >= total
|
|
417
|
+
if (!eof) {
|
|
418
|
+
const lastNl = slice.lastIndexOf(0x0a)
|
|
419
|
+
if (lastNl >= 0) slice = slice.subarray(0, lastNl + 1)
|
|
420
|
+
else return { text: '', nextStart: from + size, eof: false }
|
|
421
|
+
}
|
|
422
|
+
return { text: slice.toString('utf8'), nextStart: from + slice.length, eof }
|
|
423
|
+
} finally { await fh.close() }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── model connection helpers ───────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
/** The SPA names a harness model as "<provider>/<model>" (the model id itself
|
|
429
|
+
* may contain slashes, so the split is at the first one). */
|
|
430
|
+
function splitModelId(id) {
|
|
431
|
+
if (typeof id !== 'string') return null
|
|
432
|
+
const i = id.indexOf('/')
|
|
433
|
+
if (i <= 0 || i === id.length - 1) return null
|
|
434
|
+
return { provider: id.slice(0, i), model: id.slice(i + 1) }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The harness's catalog in the SPA's list shape. No model is marked as
|
|
438
|
+
* seeing images: a model is marked vision when its adapter declares image
|
|
439
|
+
* input; those images then enter the attachment store and ride the call. */
|
|
440
|
+
const AGENT_MODEL = 'harness/agent'
|
|
441
|
+
|
|
442
|
+
/** Which of a provider's models take images: the adapter's declared input
|
|
443
|
+
* modalities; a name that says "vision" only when the adapter says nothing. */
|
|
444
|
+
async function visionIdsOf(ctx, providerId, catalogModels) {
|
|
445
|
+
const out = new Set()
|
|
446
|
+
try {
|
|
447
|
+
for (const m of await ctx.llm.listModels(providerId)) if ((m.inputModalities ?? []).includes('image')) out.add(m.id)
|
|
448
|
+
if (out.size > 0) return out
|
|
449
|
+
} catch { /* provider not routable right now */ }
|
|
450
|
+
for (const m of catalogModels) if (/vision/i.test(m.id) || /vision/i.test(m.name ?? '')) out.add(m.id)
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function modelsPayload(ctx) {
|
|
455
|
+
const cat = await ctx.sessionController.modelCatalog()
|
|
456
|
+
// the harness itself, as an entry: the agent loop with tools, not a bare model
|
|
457
|
+
const models = [{ id: AGENT_MODEL, name: 'DeepSeek Harness · Agent (tools)', provider: 'DeepSeek Harness', vision: true }]
|
|
458
|
+
for (const g of cat.groups ?? []) {
|
|
459
|
+
const vision = await visionIdsOf(ctx, g.id, g.models ?? [])
|
|
460
|
+
for (const m of g.models ?? []) models.push({ id: `${g.id}/${m.id}`, name: m.name ?? m.id, provider: g.name ?? g.id, vision: vision.has(m.id) })
|
|
461
|
+
}
|
|
462
|
+
const def = cat.default ? `${cat.default.provider}/${cat.default.model}` : null
|
|
463
|
+
return {
|
|
464
|
+
models,
|
|
465
|
+
default: def && models.some(m => m.id === def) ? def : (models[0]?.id ?? null),
|
|
466
|
+
capabilities: { webSearch: false, searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
|
|
467
|
+
harness: { routableProviders: cat.routableProviders ?? [], failures: (cat.failures ?? []).map(f => ({ provider: f.id, message: f.message })) },
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const IMAGE_MEDIA = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
472
|
+
|
|
473
|
+
/** The SPA's { role, content } messages as harness Messages; system lines
|
|
474
|
+
* fold into the call's system prompt. Images (base64) attach to the last
|
|
475
|
+
* user message as image blocks. */
|
|
476
|
+
async function callOf(ctx, body, target) {
|
|
477
|
+
const system = []
|
|
478
|
+
const messages = []
|
|
479
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
480
|
+
if (!m || typeof m.content !== 'string') continue
|
|
481
|
+
if (m.role === 'system') { system.push(m.content); continue }
|
|
482
|
+
const role = m.role === 'assistant' ? 'assistant' : 'user'
|
|
483
|
+
messages.push({
|
|
484
|
+
id: 'td-' + randomUUID(), role,
|
|
485
|
+
content: [{ type: 'text', text: m.content }],
|
|
486
|
+
source: role === 'assistant' ? { kind: 'model', provider: target.provider, model: target.model } : { kind: 'user' },
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
if (messages.length === 0) throw new HttpError(400, 'messages: nothing to send')
|
|
490
|
+
// the canvas sends images only to a model it was told sees them; they
|
|
491
|
+
// enter the attachment store first (the harness's image contract), then
|
|
492
|
+
// ride the last user message as image blocks
|
|
493
|
+
const images = Array.isArray(body?.images) ? body.images.filter(i => i && typeof i.data === 'string' && IMAGE_MEDIA.has(i.mimeType)) : []
|
|
494
|
+
if (images.length > 0) {
|
|
495
|
+
let lastUser = null
|
|
496
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'user') { lastUser = messages[i]; break }
|
|
497
|
+
if (lastUser) {
|
|
498
|
+
for (const img of images) {
|
|
499
|
+
try {
|
|
500
|
+
const ref = await ctx.attachments.saveImage({ data: Buffer.from(img.data, 'base64'), mediaType: img.mimeType, name: 'canvas-image' })
|
|
501
|
+
lastUser.content.push({ type: 'image', attachment: ref })
|
|
502
|
+
} catch (error) {
|
|
503
|
+
throw new HttpError(400, 'image rejected by the harness: ' + (error instanceof Error ? error.message : String(error)))
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { provider: target.provider, model: target.model, messages, ...(system.length ? { system: system.join('\n\n') } : {}) }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The canvas's compiled messages, split for the agent: the last user
|
|
512
|
+
* message is the question; everything before it is the context the canvas
|
|
513
|
+
* wired in, rendered as one block the harness receives ahead of the turn. */
|
|
514
|
+
function compileForAgent(body) {
|
|
515
|
+
const msgs = (Array.isArray(body?.messages) ? body.messages : []).filter(m => m && typeof m.content === 'string' && m.content.trim())
|
|
516
|
+
if (msgs.length === 0) throw new HttpError(400, 'messages: nothing to send')
|
|
517
|
+
const last = msgs[msgs.length - 1]
|
|
518
|
+
if (last.role !== 'user') throw new HttpError(400, 'the last message must be the user\'s question')
|
|
519
|
+
const system = msgs.slice(0, -1).filter(m => m.role === 'system').map(m => m.content.trim())
|
|
520
|
+
const history = msgs.slice(0, -1).filter(m => m.role !== 'system')
|
|
521
|
+
const parts = []
|
|
522
|
+
if (system.length) parts.push('Instructions from the canvas:\n\n' + system.join('\n\n'))
|
|
523
|
+
if (history.length) parts.push('The conversation the canvas wired into this question, oldest first:\n\n' + history.map(m => `[${m.role === 'assistant' ? 'assistant' : 'user'}]\n${m.content.trim()}`).join('\n\n'))
|
|
524
|
+
const context = parts.length ? '[ThoughtDAG canvas context] The question that follows was asked from a ThoughtDAG canvas. Treat the material below as the conversation so far.\n\n' + parts.join('\n\n') : ''
|
|
525
|
+
return { question: last.content.trim(), context }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** A short line naming what a tool call is doing, for the canvas's progress row. */
|
|
529
|
+
function toolQuery(name, args) {
|
|
530
|
+
let a = args
|
|
531
|
+
if (typeof a === 'string') { try { a = JSON.parse(a) } catch { return a.slice(0, 120) } }
|
|
532
|
+
if (!a || typeof a !== 'object') return ''
|
|
533
|
+
if (/^run_code$/i.test(name) && typeof a.code === 'string') return (a.code.split('\n').find(l => l.trim()) ?? '').trim().slice(0, 120)
|
|
534
|
+
for (const k of ['file_path', 'command', 'pattern', 'path', 'url', 'query', 'description']) if (typeof a[k] === 'string' && a[k]) return a[k].slice(0, 120)
|
|
535
|
+
const first = Object.values(a).find(v => typeof v === 'string' && v)
|
|
536
|
+
return first ? first.slice(0, 120) : ''
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Run one question through the harness's agent loop in a fresh session and
|
|
540
|
+
* report it as SSE-style frames via `emit`; resolves when the turn ends. */
|
|
541
|
+
async function runAgentTurn(ctx, body, emit, isClosed) {
|
|
542
|
+
const { question, context } = compileForAgent(body)
|
|
543
|
+
// continue the session the canvas mirrors (a tail follow-up), else a fresh
|
|
544
|
+
// session in the requested working directory (the canvas's project)
|
|
545
|
+
const continueId = typeof body?.harness?.session === 'string' && body.harness.session ? body.harness.session : null
|
|
546
|
+
let sessionId
|
|
547
|
+
if (continueId && (liveSession(ctx, continueId) || (await eventsOfSession(ctx, continueId)) !== null)) {
|
|
548
|
+
sessionId = continueId
|
|
549
|
+
} else {
|
|
550
|
+
const cwd = typeof body?.harness?.cwd === 'string' && body.harness.cwd ? { cwd: body.harness.cwd } : {}
|
|
551
|
+
sessionId = (await ctx.sessionController.create({ ...cwd })).sessionId
|
|
552
|
+
}
|
|
553
|
+
emit({ harnessSession: sessionId, continued: sessionId === continueId })
|
|
554
|
+
const hasImages = Array.isArray(body?.images) && body.images.length > 0
|
|
555
|
+
if (hasImages) {
|
|
556
|
+
const vm = await visionModel(ctx).catch(() => null)
|
|
557
|
+
if (vm) await ctx.sessionController.selectModel({ sessionId, provider: vm.provider, model: vm.model }).catch(() => {})
|
|
558
|
+
}
|
|
559
|
+
const agent = await agentOf(ctx, sessionId)
|
|
560
|
+
let sawChunk = false
|
|
561
|
+
let fullText = ''
|
|
562
|
+
let done
|
|
563
|
+
const finished = new Promise(resolve => { done = resolve })
|
|
564
|
+
let currentTurn = null
|
|
565
|
+
const off = ctx.on('session/event', (session, event) => {
|
|
566
|
+
if (session?.id !== sessionId || !event) return
|
|
567
|
+
if (event.type === 'turn/start') { currentTurn = event.data?.turn ?? null; return }
|
|
568
|
+
// the person's question entering the surface: THIS is the turn the
|
|
569
|
+
// canvas node stands for — with its id the canvas marks the node as the
|
|
570
|
+
// mirror of that turn, and the live mirror does not append it a second time
|
|
571
|
+
if (event.type === 'user/message' && (event.data?.source?.kind ?? 'user') === 'user') {
|
|
572
|
+
emit({ harnessTurn: { session: sessionId, turn: currentTurn, userMessageId: event.data?.id ?? null, seq: event.seq ?? null } })
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
if (event.type === 'assistant/chunk') {
|
|
576
|
+
const c = event.data?.chunk
|
|
577
|
+
if (c?.type === 'text-delta' && c.text) { sawChunk = true; fullText += c.text; emit({ text: c.text }) }
|
|
578
|
+
else if (c?.type === 'reasoning-delta' && c.text) emit({ reasoning: c.text })
|
|
579
|
+
} else if (event.type === 'assistant/message' && !sawChunk) {
|
|
580
|
+
// chunks did not reach us live (e.g. a compacted row): the whole text at once
|
|
581
|
+
const text = (event.data?.message?.content ?? []).filter(b => b.type === 'text').map(b => b.text).join('')
|
|
582
|
+
if (text) { fullText += text; emit({ text }) }
|
|
583
|
+
} else if (event.type === 'tool/call') {
|
|
584
|
+
emit({ tool: { name: event.data?.name ?? 'tool', query: toolQuery(event.data?.name, event.data?.arguments) } })
|
|
585
|
+
} else if (event.type === 'tool/code-dispatch-start') {
|
|
586
|
+
emit({ tool: { name: event.data?.name ?? 'tool', query: toolQuery(event.data?.name, event.data?.arguments) } })
|
|
587
|
+
} else if (event.type === 'turn/end') {
|
|
588
|
+
done()
|
|
589
|
+
}
|
|
590
|
+
})
|
|
591
|
+
try {
|
|
592
|
+
const injectContext = continueId && sessionId === continueId ? (typeof body?.harness?.extraContext === 'string' ? body.harness.extraContext : '') : context
|
|
593
|
+
if (injectContext) agent.inject({ id: 'td-' + randomUUID(), role: 'user', content: [{ type: 'text', text: injectContext }], source: SOURCE })
|
|
594
|
+
const promptContent = [{ type: 'text', text: question }]
|
|
595
|
+
for (const img of Array.isArray(body?.images) ? body.images : []) {
|
|
596
|
+
if (img && typeof img.data === 'string' && IMAGE_MEDIA.has(img.mimeType)) promptContent.push({ type: 'image', mediaType: img.mimeType, data: img.data })
|
|
597
|
+
}
|
|
598
|
+
await ctx.sessionController.prompt({ requestId: 'td-' + randomUUID(), sessionId, mode: 'queue', content: promptContent }, AbortSignal.timeout(30000))
|
|
599
|
+
const timeout = new Promise(resolve => setTimeout(resolve, 20 * 60 * 1000))
|
|
600
|
+
const closed = new Promise(resolve => { const t = setInterval(() => { if (isClosed()) { clearInterval(t); resolve() } }, 500); finished.then(() => clearInterval(t)) })
|
|
601
|
+
await Promise.race([finished, agent.whenIdle().then(() => finished), timeout, closed])
|
|
602
|
+
if (isClosed()) { try { agent.cancel({ kind: 'user' }) } catch { /* best effort */ } }
|
|
603
|
+
} finally {
|
|
604
|
+
if (typeof off === 'function') off()
|
|
605
|
+
}
|
|
606
|
+
return { sessionId, text: fullText }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** A vision-capable model id ("provider/model") from the catalog, or null. */
|
|
610
|
+
async function visionModel(ctx) {
|
|
611
|
+
const cat = await ctx.sessionController.modelCatalog()
|
|
612
|
+
for (const g of cat.groups ?? []) for (const m of g.models ?? []) if (/vision/i.test(m.id) || /vision/i.test(m.name ?? '')) return { provider: g.id, model: m.id }
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Resolve the requested model against the catalog, falling back to its default. */
|
|
617
|
+
async function targetOf(ctx, requested) {
|
|
618
|
+
const payload = await modelsPayload(ctx)
|
|
619
|
+
const id = requested && payload.models.some(m => m.id === requested) ? requested : payload.default
|
|
620
|
+
const t = splitModelId(id)
|
|
621
|
+
if (!t) throw new HttpError(503, 'no model is routable in this harness')
|
|
622
|
+
return t
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The live agent for a session, resuming a cold one through the controller. */
|
|
626
|
+
async function agentOf(ctx, id) {
|
|
627
|
+
const live = ctx.agents.get(id)
|
|
628
|
+
if (live !== undefined) return live
|
|
629
|
+
const r = await ctx.sessionController.resolveAgent(id)
|
|
630
|
+
if ('agent' in r) return r.agent
|
|
631
|
+
throw new HttpError(404, 'no such session: ' + (r.error?.code ?? r.error?.message ?? 'unknown'))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function apply(ctx, config) {
|
|
635
|
+
const prefix = typeof config?.mountPrefix === 'string' && config.mountPrefix.startsWith('/') && config.mountPrefix.length > 1
|
|
636
|
+
? config.mountPrefix.replace(/\/+$/, '')
|
|
637
|
+
: '/thoughtdag'
|
|
638
|
+
const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(h => String(h).trim().toLowerCase()).filter(Boolean)])
|
|
639
|
+
|
|
640
|
+
const api = async (req, res) => {
|
|
641
|
+
try {
|
|
642
|
+
const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
|
|
643
|
+
if (!trustedHosts.has(hostname)) return sendJson(res, 403, { error: 'untrusted Host header' })
|
|
644
|
+
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
645
|
+
// full pathname is /<prefix>/api/<rest>; the register prefix strips /api
|
|
646
|
+
const path = url.pathname.slice((prefix + '/api').length)
|
|
647
|
+
// Durable sessions on disk — the archive ThoughtDAG's canvas imports.
|
|
648
|
+
if (path === '/disksessions' && req.method === 'GET') {
|
|
649
|
+
const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
|
|
650
|
+
const files = await findSessionFiles(dshHome)
|
|
651
|
+
const sessions = []
|
|
652
|
+
for (const f of files) {
|
|
653
|
+
const head = await sessionTitleFromHead(f.log)
|
|
654
|
+
sessions.push({
|
|
655
|
+
id: head.id ?? f.sessionDir,
|
|
656
|
+
title: head.title,
|
|
657
|
+
cwd: head.cwd,
|
|
658
|
+
workspaceDir: f.dir,
|
|
659
|
+
size: f.size,
|
|
660
|
+
mtime: f.mtime,
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
sessions.sort((a, b) => b.mtime - a.mtime)
|
|
664
|
+
return sendJson(res, 200, { sessions })
|
|
665
|
+
}
|
|
666
|
+
const diskOne = /^\/disksessions\/([^/]+?)(\/log)?$/.exec(path)
|
|
667
|
+
if (diskOne !== null && req.method === 'GET') {
|
|
668
|
+
const name = decodeURIComponent(diskOne[1])
|
|
669
|
+
const dshHome = process.env.DSH_HOME || join(os.homedir(), '.dsh')
|
|
670
|
+
const files = await findSessionFiles(dshHome)
|
|
671
|
+
const hit = files.find(f => f.sessionDir === name || f.sessionDir.startsWith(name))
|
|
672
|
+
if (!hit) return sendJson(res, 404, { error: 'no such session on disk' })
|
|
673
|
+
const raw = await readFile(hit.log).catch(() => null)
|
|
674
|
+
if (!raw) return sendJson(res, 500, { error: 'session unreadable' })
|
|
675
|
+
if (diskOne[2] === '/log') return sendFile(res, 'application/x-ndjson; charset=utf-8', decompressZstdToText(raw))
|
|
676
|
+
const head = await sessionTitleFromHead(hit.log)
|
|
677
|
+
return sendJson(res, 200, { session: { id: head.id ?? hit.sessionDir, title: head.title, cwd: head.cwd, size: hit.size, mtime: hit.mtime } })
|
|
678
|
+
}
|
|
679
|
+
// Live sessions (this process's in-memory store).
|
|
680
|
+
if (path === '/sessions' && req.method === 'GET') {
|
|
681
|
+
const list = ctx.sessions.list()
|
|
682
|
+
return sendJson(res, 200, { sessions: list.map(sessionSummary) })
|
|
683
|
+
}
|
|
684
|
+
const one = /^\/sessions\/([^/]+?)(\/log)?$/.exec(path)
|
|
685
|
+
if (one !== null && req.method === 'GET') {
|
|
686
|
+
const id = decodeURIComponent(one[1])
|
|
687
|
+
const session = liveSession(ctx, id)
|
|
688
|
+
if (session === undefined) return sendJson(res, 404, { error: 'no such session' })
|
|
689
|
+
if (one[2] === '/log') return sendFile(res, 'application/x-ndjson; charset=utf-8', sessionToJsonl(session))
|
|
690
|
+
return sendJson(res, 200, { session: sessionSummary(session) })
|
|
691
|
+
}
|
|
692
|
+
// ── the other agents' session files ──
|
|
693
|
+
if (path === '/roots' && req.method === 'GET') {
|
|
694
|
+
const roots = []
|
|
695
|
+
for (const [key, p] of Object.entries(FILE_ROOTS)) roots.push({ key, path: p, builtin: true, exists: await stat(p).then(st => st.isDirectory()).catch(() => false) })
|
|
696
|
+
return sendJson(res, 200, { roots })
|
|
697
|
+
}
|
|
698
|
+
const rootRoute = /^\/roots\/([a-z-]+)\/(list|head|read|range)$/.exec(path)
|
|
699
|
+
if (rootRoute !== null && req.method === 'GET') {
|
|
700
|
+
const key = rootRoute[1]
|
|
701
|
+
if (rootRoute[2] === 'list') return sendJson(res, 200, { files: await listRoot(key) })
|
|
702
|
+
const rel = url.searchParams.get('rel') ?? ''
|
|
703
|
+
const abs = fileInRoot(key, rel)
|
|
704
|
+
if (rootRoute[2] === 'head') return sendFile(res, 'text/plain; charset=utf-8', await headOfFile(abs, Number(url.searchParams.get('bytes')) || 16384).catch(() => ''))
|
|
705
|
+
if (rootRoute[2] === 'read') {
|
|
706
|
+
const st = await stat(abs).catch(() => null)
|
|
707
|
+
if (!st) return sendJson(res, 404, { error: 'no such file' })
|
|
708
|
+
if (st.size > FILE_READ_MAX) return sendJson(res, 413, { error: 'file too large to read whole; use range' })
|
|
709
|
+
return sendFile(res, 'application/x-ndjson; charset=utf-8', await readFile(abs))
|
|
710
|
+
}
|
|
711
|
+
return sendJson(res, 200, await rangeOfFile(abs, url.searchParams.get('start'), url.searchParams.get('length')))
|
|
712
|
+
}
|
|
713
|
+
// ── link snapshots: the SPA's /api/fetch-url on the harness's bounded fetcher ──
|
|
714
|
+
if (path === '/fetch-url' && req.method === 'POST') {
|
|
715
|
+
const body = await readJson(req)
|
|
716
|
+
const target = typeof body?.url === 'string' ? body.url.trim() : ''
|
|
717
|
+
if (!/^https?:\/\//i.test(target)) return sendJson(res, 400, { error: 'url must be http(s)' })
|
|
718
|
+
let r
|
|
719
|
+
try { r = await ctx.web.fetch({ url: target }, AbortSignal.timeout(30000)) } catch (error) { return sendJson(res, 502, { error: error instanceof Error ? error.message : String(error) }) }
|
|
720
|
+
if (r.statusCode >= 400) return sendJson(res, 502, { error: `HTTP ${r.statusCode} from ${target}` })
|
|
721
|
+
const html = r.body?.kind === 'html' ? r.body.content : null
|
|
722
|
+
const title = (html && /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()) || new URL(target).hostname
|
|
723
|
+
const text = html
|
|
724
|
+
? html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+\n/g, '\n').replace(/[ \t]+/g, ' ').trim()
|
|
725
|
+
: String(r.body?.content ?? '')
|
|
726
|
+
return sendJson(res, 200, { title, text, fetchedAt: new Date().toISOString(), ...(html ? { html } : {}), url: r.url ?? target, truncated: !!r.truncated })
|
|
727
|
+
}
|
|
728
|
+
// ── model connection (the SPA's proxy protocol, on the harness's providers) ──
|
|
729
|
+
if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx))
|
|
730
|
+
if ((path === '/stream' || path === '/claude') && req.method === 'POST') {
|
|
731
|
+
const body = await readJson(req, MAX_CALL_BODY_BYTES)
|
|
732
|
+
if (body?.model === AGENT_MODEL) {
|
|
733
|
+
if (path === '/claude') {
|
|
734
|
+
const r = await runAgentTurn(ctx, body, () => {}, () => false)
|
|
735
|
+
return sendJson(res, 200, { text: r.text, model: AGENT_MODEL, harnessSession: r.sessionId })
|
|
736
|
+
}
|
|
737
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
738
|
+
let closed = false
|
|
739
|
+
req.on('close', () => { closed = true })
|
|
740
|
+
try {
|
|
741
|
+
await runAgentTurn(ctx, body, frame => { if (!closed) res.write(`data: ${JSON.stringify(frame)}\n\n`) }, () => closed)
|
|
742
|
+
if (!closed) res.write('data: [DONE]\n\n')
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
|
|
745
|
+
}
|
|
746
|
+
res.end()
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
const target = await targetOf(ctx, body?.model)
|
|
750
|
+
const options = await callOf(ctx, body, target)
|
|
751
|
+
if (path === '/claude') {
|
|
752
|
+
let text = ''
|
|
753
|
+
for await (const chunk of ctx.llm.stream(options)) if (chunk.type === 'text-delta') text += chunk.text
|
|
754
|
+
return sendJson(res, 200, { text, model: `${target.provider}/${target.model}` })
|
|
755
|
+
}
|
|
756
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
757
|
+
let closed = false
|
|
758
|
+
req.on('close', () => { closed = true })
|
|
759
|
+
try {
|
|
760
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
761
|
+
if (closed) break
|
|
762
|
+
if (chunk.type === 'text-delta' && chunk.text) res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`)
|
|
763
|
+
else if (chunk.type === 'reasoning-delta' && chunk.text) res.write(`data: ${JSON.stringify({ reasoning: chunk.text })}\n\n`)
|
|
764
|
+
}
|
|
765
|
+
if (!closed) res.write('data: [DONE]\n\n')
|
|
766
|
+
} catch (error) {
|
|
767
|
+
if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
|
|
768
|
+
}
|
|
769
|
+
res.end()
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
// ── write direction ──
|
|
773
|
+
const turnsRoute = /^\/sessions\/([^/]+)\/turns$/.exec(path)
|
|
774
|
+
if (turnsRoute !== null && req.method === 'GET') {
|
|
775
|
+
const id = decodeURIComponent(turnsRoute[1])
|
|
776
|
+
const events = await eventsOfSession(ctx, id)
|
|
777
|
+
if (events === null) return sendJson(res, 404, { error: 'no such session' })
|
|
778
|
+
return sendJson(res, 200, { session: id, turns: turnsOf(events) })
|
|
779
|
+
}
|
|
780
|
+
const write = /^\/sessions\/([^/]+)\/(fork|inject|followup)$/.exec(path)
|
|
781
|
+
if (write !== null && req.method === 'POST') {
|
|
782
|
+
const id = decodeURIComponent(write[1])
|
|
783
|
+
const body = await readJson(req, MAX_WRITE_BODY_BYTES)
|
|
784
|
+
if (write[2] === 'fork') {
|
|
785
|
+
// the boundary: an explicit seq, or the end of a named turn
|
|
786
|
+
let atSeq = typeof body?.atSeq === 'number' ? body.atSeq : undefined
|
|
787
|
+
if (typeof body?.afterTurn === 'number') {
|
|
788
|
+
const events = await eventsOfSession(ctx, id)
|
|
789
|
+
if (events === null) return sendJson(res, 404, { error: 'no such session' })
|
|
790
|
+
const t = turnsOf(events).find(x => x.turn === body.afterTurn)
|
|
791
|
+
if (!t) return sendJson(res, 400, { error: 'no such turn: ' + body.afterTurn })
|
|
792
|
+
if (t.endSeq === null) return sendJson(res, 409, { error: 'turn ' + body.afterTurn + ' is still open; a fork needs a completed turn' })
|
|
793
|
+
atSeq = t.endSeq
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
const r = await ctx.sessionController.fork({ sessionId: id, ...(atSeq !== undefined ? { atSeq } : {}) })
|
|
797
|
+
return sendJson(res, 200, { session: r.sessionId, parent: id, atSeq: atSeq ?? null })
|
|
798
|
+
} catch (error) {
|
|
799
|
+
const code = error?.code ?? error?.name ?? 'fork failed'
|
|
800
|
+
return sendJson(res, /NOT_FOUND/.test(String(code)) ? 404 : 409, { error: String(error?.message ?? code), code })
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const content = contentOf(body)
|
|
804
|
+
if (write[2] === 'inject') {
|
|
805
|
+
const agent = await agentOf(ctx, id)
|
|
806
|
+
const message = { id: 'td-' + randomUUID(), role: 'user', content, source: SOURCE }
|
|
807
|
+
agent.inject(message)
|
|
808
|
+
return sendJson(res, 200, { accepted: true, session: id, messageId: message.id })
|
|
809
|
+
}
|
|
810
|
+
// followup: a person's prompt from the canvas — the controller owns
|
|
811
|
+
// the user source and the queue/steer semantics the UI uses
|
|
812
|
+
const requestId = 'td-' + randomUUID()
|
|
813
|
+
const mode = body?.mode === 'steer' ? 'steer' : 'queue'
|
|
814
|
+
await ctx.sessionController.prompt({ requestId, sessionId: id, mode, content }, AbortSignal.timeout(30000))
|
|
815
|
+
return sendJson(res, 200, { accepted: true, session: id, requestId, mode })
|
|
816
|
+
}
|
|
817
|
+
return sendJson(res, 404, { error: 'not found' })
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (error instanceof HttpError) return sendJson(res, error.status, { error: error.message })
|
|
820
|
+
ctx.logger.error(error instanceof Error ? error : new Error(String(error)))
|
|
821
|
+
// the bridge only answers trusted local hosts, so the message may travel
|
|
822
|
+
return sendJson(res, 500, { error: 'bridge unavailable', detail: error instanceof Error ? error.message : String(error) })
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const staticHandler = async (req, res) => {
|
|
827
|
+
const url = new URL(req.url ?? '/', 'http://dsh.local')
|
|
828
|
+
let rel = url.pathname.slice(prefix.length).replace(/^\/+/, '') || 'index.html'
|
|
829
|
+
if (rel.endsWith('/')) rel += 'index.html'
|
|
830
|
+
if (url.pathname.startsWith(prefix + '/api/')) { rel = 'index.html' } // api never reaches the static table
|
|
831
|
+
const abs = assetPath(rel)
|
|
832
|
+
if (abs === null) { res.writeHead(403); res.end('forbidden'); return }
|
|
833
|
+
try {
|
|
834
|
+
const body = await readFile(abs)
|
|
835
|
+
sendFile(res, MIME[extname(abs).toLowerCase()] ?? 'application/octet-stream', body)
|
|
836
|
+
} catch {
|
|
837
|
+
res.writeHead(404); res.end('not found')
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: prefix, handler: (_req, res) => { res.writeHead(302, { location: prefix + '/' }); res.end() } }), 'thoughtdag: redirect')
|
|
842
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix + '/api', handler: api }), 'thoughtdag: api')
|
|
843
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: prefix, handler: staticHandler }), 'thoughtdag: static')
|
|
844
|
+
ctx.logger.info('[dsh-thoughtdag] ThoughtDAG mounted at ' + prefix + '/')
|
|
845
|
+
}
|