dsh-thoughtdag 0.5.5 → 0.5.7
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/dist-app/_headers +4 -0
- package/dist-app/assets/{canonical-DyO4cfs8.js → canonical-4rZrwQLF.js} +2 -2
- package/dist-app/assets/{canvas-record-B106EgJV.js → canvas-record-UgW4msHo.js} +1 -1
- package/dist-app/assets/{claude-code-session-COqNVArs.js → claude-code-session-r3i8eC0x.js} +1 -1
- package/dist-app/assets/{codex-session-CVhXEocv.js → codex-session-C1oydShA.js} +1 -1
- package/dist-app/assets/{dsh-session-y6pdDHlg.js → dsh-session-DN19dxyt.js} +1 -1
- package/dist-app/assets/{experiment-loop-Bj2wGvJp.js → experiment-loop-B9S8G8tm.js} +1 -1
- package/dist-app/assets/{http-bridge-TYkQm1R3.js → http-bridge-P6t5GDSV.js} +1 -1
- package/dist-app/assets/index-4MlMfE8C.js +914 -0
- package/dist-app/assets/{index-BZP0zxsA.js → index-CdxNcIpd.js} +3 -3
- package/dist-app/assets/index-CgnmeNGy.css +1 -0
- package/dist-app/assets/{index-D2RofBbL.js → index-D0CNSZAd.js} +1 -1
- package/dist-app/assets/{index-BndAg25p.js → index-DOqNVCbY.js} +1 -1
- package/dist-app/assets/local-ladder.worker-Dj3KyWai.js +1 -0
- package/dist-app/assets/{pi-session-fSCIsceL.js → pi-session-BvpmiBJX.js} +1 -1
- package/dist-app/assets/{sensitive-scan-_-YPfT-P.js → sensitive-scan-BJN9JBOQ.js} +1 -1
- package/dist-app/assets/{session-handoff-rVZFB7ll.js → session-handoff-BGisIC5k.js} +2 -2
- package/dist-app/assets/{shared-svBDp9_R.js → shared-Kh4K9LTp.js} +1 -1
- package/dist-app/assets/{turndown-plugin-gfm.cjs-DBiLx1SX.js → turndown-plugin-gfm.cjs-Ccez8Set.js} +1 -1
- package/dist-app/assets/update-check-oK0-hbLG.js +1 -0
- package/dist-app/index.html +2 -2
- package/lib/client.js +33 -8
- package/lib/index.js +183 -11
- package/lib/why.mjs +56 -0
- package/package.json +1 -1
- package/dist-app/assets/index-CqebPxzE.css +0 -1
- package/dist-app/assets/index-DR0C_9n-.js +0 -912
- package/dist-app/assets/local-ladder.worker-BkmzXrqq.js +0 -1
- package/dist-app/assets/update-check-n2hvSdhA.js +0 -1
package/lib/client.js
CHANGED
|
@@ -33,18 +33,37 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const style = document.createElement('style')
|
|
36
|
-
|
|
36
|
+
// The map overlay starts below the window's top strip: the desktop host
|
|
37
|
+
// publishes --dsh-frame-top-clearance on its root (48px under the
|
|
38
|
+
// hiddenInset traffic lights on macOS, the caption height on Windows,
|
|
39
|
+
// 0 in native fullscreen). In that strip the plugin draws a title band
|
|
40
|
+
// of its own: the ThoughtDAG name, the 对话|思维图 switch and the canvas's
|
|
41
|
+
// name, draggable like the host's own chrome rows (data-window-drag),
|
|
42
|
+
// starting past the traffic lights (--dsh-frame-leading-clearance while
|
|
43
|
+
// the sidebar is collapsed, 84px otherwise, since the overlay covers the
|
|
44
|
+
// sidebar too) and stopping short of the Windows caption buttons. So
|
|
45
|
+
// the lights and the window controls are never covered, and nothing of
|
|
46
|
+
// the host's session header shows through (#39). A browser tab has no
|
|
47
|
+
// clearance: no band, the overlay fills the window, the canvas keeps its
|
|
48
|
+
// own switch.
|
|
49
|
+
style.textContent = '.dsh-td-switch{position:fixed;z-index:120;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-td-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-td-switch button:hover{background:#f3f4f6;color:#111827}.dsh-td-switch button.active{background:#111827;color:#fff}.dsh-td-switch[hidden]{display:none}.dsh-td-bar{position:fixed;z-index:121;top:0;left:0;right:0;height:var(--dsh-frame-top-clearance,0px);display:flex;align-items:center;gap:14px;padding:0 16px;box-sizing:border-box;background:#faf9f7;border-bottom:1px solid #e7e2d9;font:500 12px Inter,system-ui,sans-serif;color:#6b7280;overflow:hidden;-webkit-app-region:drag;user-select:none}.dsh-td-bar[hidden]{display:none}html[data-platform="darwin"] .dsh-td-bar{padding-left:max(var(--dsh-frame-leading-clearance,0px),84px)}html[data-windows-titlebar] .dsh-td-bar{padding-right:calc(100% - env(titlebar-area-width,100%) + 16px)}.dsh-td-bar .dsh-td-brand{display:flex;align-items:center;gap:7px;color:#111827;font-weight:600;font-size:13px;white-space:nowrap}.dsh-td-bar .dsh-td-brand i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#6d5dfc;box-shadow:0 -7px 0 #a99cff,0 7px 0 #f0a35a}.dsh-td-bar .dsh-td-switch{position:static;transform:none;-webkit-app-region:no-drag}.dsh-td-bar .dsh-td-title{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#6b7280}.dsh-td-overlay{position:fixed;z-index:100;inset:var(--dsh-frame-top-clearance,0px) 0 0 0;background:#faf9f7}.dsh-td-overlay[hidden]{display:none}.dsh-td-overlay iframe{display:block;width:100%;height:100%;border:0}'
|
|
37
50
|
document.head.append(style)
|
|
38
51
|
|
|
39
52
|
const host = document.createElement('div')
|
|
40
|
-
host.innerHTML = '<div class="dsh-td-switch" role="group" aria-label="view switch"><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">思维图</button></div><section class="dsh-td-overlay" hidden><iframe title="ThoughtDAG" data-src="/thoughtdag/"></iframe></section>'
|
|
53
|
+
host.innerHTML = '<div class="dsh-td-switch" role="group" aria-label="view switch"><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">思维图</button></div><div class="dsh-td-bar" data-window-drag hidden><span class="dsh-td-brand"><i></i>ThoughtDAG</span><div class="dsh-td-switch" role="group" aria-label="view switch"><button type="button" data-view="dialog" aria-pressed="false">对话</button><button type="button" data-view="map" class="active" aria-pressed="true">思维图</button></div><span class="dsh-td-title"></span></div><section class="dsh-td-overlay" hidden><iframe title="ThoughtDAG" data-src="/thoughtdag/"></iframe></section>'
|
|
41
54
|
document.body.append(host)
|
|
42
55
|
|
|
43
56
|
// the plugin's version, for the canvas's update dialog and release history
|
|
44
57
|
let pluginVersion = null
|
|
45
58
|
fetch('/thoughtdag/api/version').then(r => (r.ok ? r.json() : null)).then(j => { if (j && typeof j.version === 'string') pluginVersion = j.version }).catch(() => {})
|
|
46
59
|
|
|
47
|
-
const switchEl = host.querySelector('.dsh-td-switch')
|
|
60
|
+
const switchEl = host.querySelector(':scope > .dsh-td-switch')
|
|
61
|
+
const bar = host.querySelector('.dsh-td-bar')
|
|
62
|
+
const barTitle = bar.querySelector('.dsh-td-title')
|
|
63
|
+
// the desktop host marks its root; the band exists only there, and only while the strip has height (not in native fullscreen)
|
|
64
|
+
const desktop = () => document.documentElement.dataset.platform !== undefined || document.documentElement.hasAttribute('data-windows-titlebar')
|
|
65
|
+
const strip = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dsh-frame-top-clearance')) || 0
|
|
66
|
+
const barUp = () => desktop() && strip() > 0
|
|
48
67
|
let spaReady = false
|
|
49
68
|
const dialogBtn = host.querySelector('[data-view="dialog"]')
|
|
50
69
|
const mapBtn = host.querySelector('[data-view="map"]')
|
|
@@ -57,7 +76,10 @@ window.__ModuleLoader__.load({
|
|
|
57
76
|
mapBtn.classList.toggle('active', map)
|
|
58
77
|
mapBtn.setAttribute('aria-pressed', String(map))
|
|
59
78
|
}
|
|
60
|
-
const close = () => { overlay.hidden = true; switchEl.hidden = false; setView(false); send('td:view', { shown: false }) }
|
|
79
|
+
const close = () => { overlay.hidden = true; bar.hidden = true; switchEl.hidden = false; setView(false); send('td:view', { shown: false, bar: false, desktop: desktop() }) }
|
|
80
|
+
const showBand = () => { bar.hidden = !barUp(); send('td:view', { shown: true, bar: !bar.hidden, desktop: desktop() }) }
|
|
81
|
+
window.addEventListener('resize', () => { if (!overlay.hidden) showBand() })
|
|
82
|
+
bar.querySelector('[data-view="dialog"]').addEventListener('click', () => close())
|
|
61
83
|
const send = (type, payload) => frame.contentWindow?.postMessage({ source: 'dsh-thoughtdag', type, ...payload }, location.origin)
|
|
62
84
|
|
|
63
85
|
const syncCurrent = () => {
|
|
@@ -68,15 +90,16 @@ window.__ModuleLoader__.load({
|
|
|
68
90
|
mapBtn.addEventListener('click', () => {
|
|
69
91
|
overlay.hidden = false
|
|
70
92
|
setView(true)
|
|
71
|
-
|
|
93
|
+
bar.hidden = !barUp()
|
|
94
|
+
if (spaReady || !bar.hidden) switchEl.hidden = true
|
|
72
95
|
// the SPA boots on first open, never while hidden: a canvas that
|
|
73
96
|
// measures itself inside a display:none frame fits its view to a 0×0
|
|
74
97
|
// box and shows nothing when revealed
|
|
75
98
|
if (!frame.src) frame.src = frame.dataset.src + (pluginVersion ? (frame.dataset.src.includes('?') ? '&' : '?') + 'dv=' + encodeURIComponent(pluginVersion) : '')
|
|
76
99
|
syncCurrent()
|
|
77
|
-
|
|
100
|
+
showBand()
|
|
78
101
|
// let the SPA boot, then re-sync so its listener is ready
|
|
79
|
-
window.setTimeout(() => { syncCurrent();
|
|
102
|
+
window.setTimeout(() => { syncCurrent(); showBand() }, 400)
|
|
80
103
|
})
|
|
81
104
|
dialogBtn.addEventListener('click', close)
|
|
82
105
|
|
|
@@ -101,9 +124,11 @@ window.__ModuleLoader__.load({
|
|
|
101
124
|
if (event.data.type === 'td:request-current') {
|
|
102
125
|
// the SPA has booted and shows its own switch: the pill steps aside
|
|
103
126
|
spaReady = true
|
|
104
|
-
if (!overlay.hidden) switchEl.hidden = true
|
|
127
|
+
if (!overlay.hidden) { switchEl.hidden = true; showBand() }
|
|
105
128
|
return syncCurrent()
|
|
106
129
|
}
|
|
130
|
+
// the canvas names its project: the band shows it
|
|
131
|
+
if (event.data.type === 'td:title') { barTitle.textContent = typeof event.data.name === 'string' ? event.data.name : ''; return }
|
|
107
132
|
// the canvas forked or continued a session: stage it and go back to the
|
|
108
133
|
// chat, which now shows exactly the context the canvas produced
|
|
109
134
|
if (event.data.type === 'td:select-session' && typeof event.data.session === 'string') {
|
package/lib/index.js
CHANGED
|
@@ -293,7 +293,11 @@ function decompressZstdToText(raw) {
|
|
|
293
293
|
* `session.jsonl.zstd`, while session format V3 (0.1.5-rc.2 and later) uses
|
|
294
294
|
* `session.v3.jsonl.zstd`. Probe the known names, newest first, so a listing
|
|
295
295
|
* keeps working across both. */
|
|
296
|
-
|
|
296
|
+
// `session.jsonl.zstd` (≤ 0.1.4), `session.v3.jsonl.zstd` (0.1.5), `session.v4.jsonl.zstd` (the 0.2
|
|
297
|
+
// desktop) …: the session directory is listed and the highest version wins, so the next format
|
|
298
|
+
// version is found before this file learns its name (the 0.2 desktop's own sessions were invisible
|
|
299
|
+
// while only v3 was probed)
|
|
300
|
+
const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl\.zstd?$/
|
|
297
301
|
async function findSessionFiles(dshHome) {
|
|
298
302
|
const root = join(dshHome, 'sessions')
|
|
299
303
|
const out = []
|
|
@@ -305,13 +309,13 @@ async function findSessionFiles(dshHome) {
|
|
|
305
309
|
try { sessions = await readdir(join(root, ws.name), { withFileTypes: true }) } catch { continue }
|
|
306
310
|
for (const s of sessions) {
|
|
307
311
|
if (!s.isDirectory()) continue
|
|
308
|
-
let
|
|
312
|
+
let names
|
|
313
|
+
try { names = await readdir(join(root, ws.name, s.name)) } catch { continue }
|
|
314
|
+
const best = names.map(n => ({ n, m: SESSION_LOG_RE.exec(n) })).filter(x => x.m !== null).sort((a, b) => Number(b.m[1] ?? 0) - Number(a.m[1] ?? 0))[0]
|
|
315
|
+
if (best === undefined) continue
|
|
316
|
+
const log = join(root, ws.name, s.name, best.n)
|
|
309
317
|
let st
|
|
310
|
-
|
|
311
|
-
const candidate = join(root, ws.name, s.name, name)
|
|
312
|
-
try { st = await stat(candidate); log = candidate; break } catch { /* try the next format */ }
|
|
313
|
-
}
|
|
314
|
-
if (log === undefined || st === undefined) continue
|
|
318
|
+
try { st = await stat(log) } catch { continue }
|
|
315
319
|
out.push({ dir: ws.name, sessionDir: s.name, log, size: st.size, mtime: st.mtimeMs })
|
|
316
320
|
}
|
|
317
321
|
}
|
|
@@ -534,7 +538,7 @@ async function visionIdsOf(ctx, providerId, catalogModels) {
|
|
|
534
538
|
return out
|
|
535
539
|
}
|
|
536
540
|
|
|
537
|
-
async function modelsPayload(ctx) {
|
|
541
|
+
async function modelsPayload(ctx, ownProviders = []) {
|
|
538
542
|
const cat = await ctx.sessionController.modelCatalog()
|
|
539
543
|
// the harness itself, as an entry: the agent loop with tools, not a bare model
|
|
540
544
|
// the harness's agent loop with tools, once per model it can run on —
|
|
@@ -549,15 +553,137 @@ async function modelsPayload(ctx) {
|
|
|
549
553
|
}
|
|
550
554
|
}
|
|
551
555
|
models.push(...agents)
|
|
556
|
+
// the person's own interfaces follow, under their own names; a harness id wins a collision
|
|
557
|
+
const own = ownModelEntries(ownProviders, new Set(models.map(m => m.id)))
|
|
558
|
+
models.push(...own)
|
|
552
559
|
const def = cat.default ? `${cat.default.provider}/${cat.default.model}` : null
|
|
553
560
|
return {
|
|
554
561
|
models,
|
|
555
562
|
default: def && models.some(m => m.id === def) ? def : (models[0]?.id ?? null),
|
|
556
|
-
capabilities: { webSearch:
|
|
563
|
+
capabilities: { webSearch: ownProviders.some(p => isOpenRouterURL(p.baseURL)), searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
|
|
557
564
|
harness: { routableProviders: cat.routableProviders ?? [], failures: (cat.failures ?? []).map(f => ({ provider: f.id, message: f.message })) },
|
|
558
565
|
}
|
|
559
566
|
}
|
|
560
567
|
|
|
568
|
+
// ── the person's own interfaces ────────────────────────────────────────
|
|
569
|
+
// The canvas keeps API interfaces in the browser (the same list the desktop
|
|
570
|
+
// app has) and sends them along with every call and with a registration
|
|
571
|
+
// at boot (POST /runtime-providers). Here they are spoken to directly over
|
|
572
|
+
// the OpenAI-compatible protocol, so a key added inside the harness works
|
|
573
|
+
// the way it does in the app; the harness's own models are untouched.
|
|
574
|
+
const OWN_PROVIDER_CAP = 12
|
|
575
|
+
const OWN_MODEL_CAP = 60
|
|
576
|
+
const isOpenRouterURL = (u) => /openrouter\.ai/i.test(String(u ?? ''))
|
|
577
|
+
|
|
578
|
+
/** The providers a request or a registration carries, sanitised. */
|
|
579
|
+
function ownProvidersOf(list) {
|
|
580
|
+
const out = []
|
|
581
|
+
for (const p of (Array.isArray(list) ? list : []).slice(0, OWN_PROVIDER_CAP)) {
|
|
582
|
+
const baseURL = String(p?.baseURL ?? '').trim().replace(/\/+$/, '')
|
|
583
|
+
if (!/^https?:\/\//i.test(baseURL)) continue
|
|
584
|
+
const name = String(p?.name || 'Custom').slice(0, 40)
|
|
585
|
+
const models = (Array.isArray(p?.models) ? p.models : [])
|
|
586
|
+
.map(m => (typeof m === 'string' ? { id: m } : m))
|
|
587
|
+
.filter(m => m && typeof m.id === 'string' && m.id)
|
|
588
|
+
.slice(0, OWN_MODEL_CAP)
|
|
589
|
+
.map(m => ({ id: m.id, ...(typeof m.vision === 'boolean' ? { vision: m.vision } : {}) }))
|
|
590
|
+
if (models.length === 0) continue
|
|
591
|
+
out.push({ name, baseURL, apiKey: typeof p?.apiKey === 'string' ? p.apiKey : '', models })
|
|
592
|
+
}
|
|
593
|
+
return out
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Model entries for the picker: the model's short id and its interface's name, like the app shows them. */
|
|
597
|
+
function ownModelEntries(providers, taken) {
|
|
598
|
+
const entries = []
|
|
599
|
+
for (const p of providers) {
|
|
600
|
+
for (const m of p.models) {
|
|
601
|
+
if (taken.has(m.id)) continue
|
|
602
|
+
taken.add(m.id)
|
|
603
|
+
const shortId = m.id.includes('/') ? m.id.split('/').slice(1).join('/') : m.id
|
|
604
|
+
entries.push({ id: m.id, name: `${shortId} (${p.name})`, provider: p.name, vision: m.vision })
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return entries
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The interface a requested model belongs to, from the request's own list first, then the registered list. */
|
|
611
|
+
function ownTargetOf(requested, fromBody, registered) {
|
|
612
|
+
if (typeof requested !== 'string' || !requested) return null
|
|
613
|
+
for (const p of [...fromBody, ...registered]) if (p.models.some(m => m.id === requested)) return { provider: p, model: requested }
|
|
614
|
+
return null
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The canvas's { role, content } messages and images as OpenAI-compatible messages. */
|
|
618
|
+
function ownMessagesOf(body) {
|
|
619
|
+
const messages = []
|
|
620
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
621
|
+
if (!m || typeof m.content !== 'string') continue
|
|
622
|
+
messages.push({ role: m.role === 'system' ? 'system' : m.role === 'assistant' ? 'assistant' : 'user', content: m.content })
|
|
623
|
+
}
|
|
624
|
+
if (messages.length === 0) throw new HttpError(400, 'messages: nothing to send')
|
|
625
|
+
const images = Array.isArray(body?.images) ? body.images.filter(i => i && typeof i.data === 'string' && IMAGE_MEDIA.has(i.mimeType)) : []
|
|
626
|
+
if (images.length > 0) {
|
|
627
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
628
|
+
if (messages[i].role !== 'user') continue
|
|
629
|
+
messages[i] = { role: 'user', content: [{ type: 'text', text: messages[i].content }, ...images.map(img => ({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${img.data}` } }))] }
|
|
630
|
+
break
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return messages
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** One chat-completions request to the interface; the response as fetched (the caller reads it). */
|
|
637
|
+
async function ownRequest(target, body, stream) {
|
|
638
|
+
const { provider, model } = target
|
|
639
|
+
// OpenRouter searches through its `:online` variant and takes a reasoning switch; a fast call never thinks out loud
|
|
640
|
+
const openrouter = isOpenRouterURL(provider.baseURL)
|
|
641
|
+
const id = openrouter && body?.webSearch && !model.endsWith(':online') ? `${model}:online` : model
|
|
642
|
+
const payload = { model: id, messages: ownMessagesOf(body), stream, ...(openrouter ? { reasoning: { enabled: !body?.fast } } : {}) }
|
|
643
|
+
const headers = { 'content-type': 'application/json', ...(provider.apiKey ? { authorization: `Bearer ${provider.apiKey}` } : {}), ...(openrouter ? { 'HTTP-Referer': 'https://github.com/chenxiachan/thoughtdag', 'X-Title': 'ThoughtDAG' } : {}) }
|
|
644
|
+
const res = await fetch(`${provider.baseURL}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(10 * 60 * 1000) })
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
const text = await res.text().catch(() => '')
|
|
647
|
+
let detail = text.slice(0, 300)
|
|
648
|
+
try { const j = JSON.parse(text); detail = j?.error?.message ?? j?.message ?? detail } catch { /* not json */ }
|
|
649
|
+
throw new HttpError(res.status === 401 || res.status === 403 ? 401 : 502, `${provider.name}: HTTP ${res.status}${detail ? ' · ' + detail : ''}`)
|
|
650
|
+
}
|
|
651
|
+
return res
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Stream one answer from the interface as the canvas's frames: { text }, { reasoning }, [DONE]. */
|
|
655
|
+
async function ownStream(res, target, body, isClosed) {
|
|
656
|
+
const upstream = await ownRequest(target, body, true)
|
|
657
|
+
const decoder = new TextDecoder()
|
|
658
|
+
let buffer = ''
|
|
659
|
+
for await (const chunk of upstream.body) {
|
|
660
|
+
if (isClosed()) break
|
|
661
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
662
|
+
let nl
|
|
663
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
664
|
+
const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1)
|
|
665
|
+
if (!line.startsWith('data:')) continue
|
|
666
|
+
const data = line.slice(5).trim()
|
|
667
|
+
if (data === '[DONE]') return
|
|
668
|
+
let parsed
|
|
669
|
+
try { parsed = JSON.parse(data) } catch { continue }
|
|
670
|
+
if (parsed?.error) { res.write(`data: ${JSON.stringify({ error: parsed.error.message ?? String(parsed.error) })}\n\n`); return }
|
|
671
|
+
const delta = parsed?.choices?.[0]?.delta ?? {}
|
|
672
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning
|
|
673
|
+
if (typeof reasoning === 'string' && reasoning) res.write(`data: ${JSON.stringify({ reasoning })}\n\n`)
|
|
674
|
+
if (typeof delta.content === 'string' && delta.content) res.write(`data: ${JSON.stringify({ text: delta.content })}\n\n`)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** One whole answer from the interface (the canvas's background calls). */
|
|
680
|
+
async function ownCall(target, body) {
|
|
681
|
+
const upstream = await ownRequest(target, body, false)
|
|
682
|
+
const j = await upstream.json()
|
|
683
|
+
const c = j?.choices?.[0]?.message?.content
|
|
684
|
+
return typeof c === 'string' ? c : Array.isArray(c) ? c.map(x => (typeof x?.text === 'string' ? x.text : '')).join('') : ''
|
|
685
|
+
}
|
|
686
|
+
|
|
561
687
|
const IMAGE_MEDIA = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
|
562
688
|
|
|
563
689
|
/** The SPA's { role, content } messages as harness Messages; system lines
|
|
@@ -834,7 +960,7 @@ async function visionModel(ctx) {
|
|
|
834
960
|
|
|
835
961
|
/** Resolve the requested model against the catalog, falling back to its default. */
|
|
836
962
|
async function targetOf(ctx, requested) {
|
|
837
|
-
const payload = await modelsPayload(ctx)
|
|
963
|
+
const payload = await modelsPayload(ctx) // the harness's own; the person's interfaces are resolved before this is asked
|
|
838
964
|
const id = requested && payload.models.some(m => m.id === requested) ? requested : payload.default
|
|
839
965
|
const t = splitModelId(id)
|
|
840
966
|
if (!t) throw new HttpError(503, 'no model is routable in this harness')
|
|
@@ -943,6 +1069,8 @@ export async function apply(ctx, config) {
|
|
|
943
1069
|
: '/thoughtdag'
|
|
944
1070
|
const trustedHosts = new Set(['localhost', '127.0.0.1', ...[...(config?.trustedHosts ?? [])].map(h => String(h).trim().toLowerCase()).filter(Boolean)])
|
|
945
1071
|
|
|
1072
|
+
// the interfaces the canvas registered (memory only: a restart forgets them, the canvas registers again at boot)
|
|
1073
|
+
let registeredProviders = []
|
|
946
1074
|
const api = async (req, res) => {
|
|
947
1075
|
try {
|
|
948
1076
|
const hostname = (typeof req.headers.host === 'string' ? req.headers.host : '').replace(/:\d+$/, '').toLowerCase()
|
|
@@ -1010,6 +1138,7 @@ export async function apply(ctx, config) {
|
|
|
1010
1138
|
const cwd = url.searchParams.get('cwd') ?? undefined
|
|
1011
1139
|
return sendJson(res, 200, await whyLib.findJson(phrase, { scope, limit, ...(cwd ? { cwd } : {}) }))
|
|
1012
1140
|
}
|
|
1141
|
+
if (path === '/why/turns') return sendJson(res, 200, await whyLib.turnsJson({ offset: Number(url.searchParams.get('offset') ?? 0) || 0, ...(url.searchParams.get('limit') === null ? {} : { limit: Number(url.searchParams.get('limit')) || 0 }), head: Number(url.searchParams.get('head') ?? 300) || 300 }))
|
|
1013
1142
|
if (path === '/why/recall') {
|
|
1014
1143
|
try { return sendJson(res, 200, await whyLib.recallJson(url.searchParams.get('session') ?? '', Number(url.searchParams.get('turn') ?? 0))) }
|
|
1015
1144
|
catch (e) { return sendJson(res, 404, { error: e instanceof Error ? e.message : String(e) }) }
|
|
@@ -1089,7 +1218,34 @@ export async function apply(ctx, config) {
|
|
|
1089
1218
|
if (await agentsHttp().handle(req, res, path, body)) return
|
|
1090
1219
|
}
|
|
1091
1220
|
// ── model connection (the SPA's proxy protocol, on the harness's providers) ──
|
|
1092
|
-
if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx))
|
|
1221
|
+
if (path === '/models' && req.method === 'GET') return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
|
|
1222
|
+
// the canvas registers its browser-stored interfaces at boot (and after a change): the list answers with them in it
|
|
1223
|
+
// the interface dialog asks an endpoint what it serves (the /models protocol standard), through this host
|
|
1224
|
+
if (path === '/probe-models' && req.method === 'POST') {
|
|
1225
|
+
const body = await readJson(req, MAX_WRITE_BODY_BYTES)
|
|
1226
|
+
const baseURL = String(body?.baseURL ?? '').trim().replace(/\/+$/, '')
|
|
1227
|
+
if (!/^https?:\/\//i.test(baseURL)) return sendJson(res, 400, { error: 'baseURL required' })
|
|
1228
|
+
try {
|
|
1229
|
+
const r = await fetch(`${baseURL}/models`, { headers: body?.apiKey ? { authorization: `Bearer ${body.apiKey}` } : {}, signal: AbortSignal.timeout(15000) })
|
|
1230
|
+
if (!r.ok) return sendJson(res, r.status, { error: `endpoint answered HTTP ${r.status}` })
|
|
1231
|
+
const j = await r.json()
|
|
1232
|
+
const list = Array.isArray(j?.data) ? j.data : Array.isArray(j?.models) ? j.models : []
|
|
1233
|
+
const models = list.map(m => ({
|
|
1234
|
+
id: typeof (m?.id ?? m?.name) === 'string' ? (m.id ?? m.name).replace(/^models\//, '') : '',
|
|
1235
|
+
...(typeof m?.created === 'number' ? { created: m.created } : {}),
|
|
1236
|
+
...(Array.isArray(m?.architecture?.input_modalities) ? { vision: m.architecture.input_modalities.includes('image') } : {}),
|
|
1237
|
+
...(typeof m?.context_length === 'number' ? { contextLength: m.context_length } : {}),
|
|
1238
|
+
})).filter(m => m.id)
|
|
1239
|
+
return sendJson(res, 200, { models })
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
return sendJson(res, 502, { error: 'could not reach the endpoint: ' + (error instanceof Error ? error.message : String(error)) })
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (path === '/runtime-providers' && req.method === 'POST') {
|
|
1245
|
+
const body = await readJson(req, MAX_CALL_BODY_BYTES)
|
|
1246
|
+
registeredProviders = ownProvidersOf(body?.providers)
|
|
1247
|
+
return sendJson(res, 200, await modelsPayload(ctx, registeredProviders))
|
|
1248
|
+
}
|
|
1093
1249
|
const approvalRoute = /^\/approvals\/([^/]+)$/.exec(path)
|
|
1094
1250
|
if (approvalRoute !== null && req.method === 'POST') {
|
|
1095
1251
|
const id = decodeURIComponent(approvalRoute[1])
|
|
@@ -1119,6 +1275,22 @@ export async function apply(ctx, config) {
|
|
|
1119
1275
|
res.end()
|
|
1120
1276
|
return
|
|
1121
1277
|
}
|
|
1278
|
+
// the person's own interface, when the model is one of theirs
|
|
1279
|
+
const own = ownTargetOf(body?.model, ownProvidersOf(body?.providers), registeredProviders)
|
|
1280
|
+
if (own) {
|
|
1281
|
+
if (path === '/claude') return sendJson(res, 200, { text: await ownCall(own, body), model: own.model })
|
|
1282
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
|
|
1283
|
+
let closed = false
|
|
1284
|
+
req.on('close', () => { closed = true })
|
|
1285
|
+
try {
|
|
1286
|
+
await ownStream(res, own, body, () => closed)
|
|
1287
|
+
if (!closed) res.write('data: [DONE]\n\n')
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
if (!closed) res.write(`data: ${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n\n`)
|
|
1290
|
+
}
|
|
1291
|
+
res.end()
|
|
1292
|
+
return
|
|
1293
|
+
}
|
|
1122
1294
|
const target = await targetOf(ctx, body?.model)
|
|
1123
1295
|
const options = await callOf(ctx, body, target)
|
|
1124
1296
|
if (path === '/claude') {
|
package/lib/why.mjs
CHANGED
|
@@ -3093,6 +3093,61 @@ async function findJson(phrase, opts = {}) {
|
|
|
3093
3093
|
hits: shown.map((h) => ({ kind: h.kind, session: h.session.id, runner: h.session.runner, title: h.session.title, cwd: h.turn.cwd ?? h.session.cwd, file: h.session.file, turn: turnNumber(facts, h), at: h.turn.at ?? null, where: h.where, snippet: h.snippet, open: openLink(h) }))
|
|
3094
3094
|
};
|
|
3095
3095
|
}
|
|
3096
|
+
async function turnsJson(opts = {}) {
|
|
3097
|
+
if (opts.limit === 0) {
|
|
3098
|
+
const text = await loadText();
|
|
3099
|
+
return { total: Object.values(text.sessions).reduce((a, b) => a + b, 0), turns: [] };
|
|
3100
|
+
}
|
|
3101
|
+
const facts = await ensureFresh();
|
|
3102
|
+
const head = Math.max(40, Math.min(1e3, opts.head ?? 300));
|
|
3103
|
+
const turnsOf = /* @__PURE__ */ new Map();
|
|
3104
|
+
const lookup = (k, i) => {
|
|
3105
|
+
let m = turnsOf.get(k);
|
|
3106
|
+
if (!m) {
|
|
3107
|
+
m = new Map(facts.sessions[k]?.turns.map((t) => [t.i, t]) ?? []);
|
|
3108
|
+
turnsOf.set(k, m);
|
|
3109
|
+
}
|
|
3110
|
+
return m.get(i);
|
|
3111
|
+
};
|
|
3112
|
+
const clipHead = (text) => (text ?? "").replace(/\s+/g, " ").trim().slice(0, head);
|
|
3113
|
+
const raw = [];
|
|
3114
|
+
let lines = null;
|
|
3115
|
+
try {
|
|
3116
|
+
await fsp.access(TEXT_LINES);
|
|
3117
|
+
lines = createInterface({ input: createReadStream(TEXT_LINES, { encoding: "utf8", highWaterMark: 1 << 20 }) });
|
|
3118
|
+
} catch {
|
|
3119
|
+
return { total: 0, turns: [] };
|
|
3120
|
+
}
|
|
3121
|
+
for await (const line of lines) {
|
|
3122
|
+
let t;
|
|
3123
|
+
try {
|
|
3124
|
+
t = JSON.parse(line);
|
|
3125
|
+
} catch {
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
const session = facts.sessions[t.k];
|
|
3129
|
+
const turn = lookup(t.k, t.i);
|
|
3130
|
+
if (!session || !turn) continue;
|
|
3131
|
+
const memory = session.kind === "memory";
|
|
3132
|
+
raw.push({ session, sourceKey: t.k, turn, q: memory ? "" : clipHead(t.q), a: clipHead(memory ? t.m : t.a), kind: memory ? "memory" : "turn" });
|
|
3133
|
+
}
|
|
3134
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3135
|
+
const all = [];
|
|
3136
|
+
for (const h of raw.sort((a, b) => a.session.mtime - b.session.mtime)) {
|
|
3137
|
+
if (h.turn.item) {
|
|
3138
|
+
if (seen.has(h.turn.item)) continue;
|
|
3139
|
+
seen.add(h.turn.item);
|
|
3140
|
+
}
|
|
3141
|
+
all.push(h);
|
|
3142
|
+
}
|
|
3143
|
+
all.sort((a, b) => (b.turn.at ?? "").localeCompare(a.turn.at ?? ""));
|
|
3144
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
3145
|
+
const limit = Math.max(0, opts.limit ?? all.length);
|
|
3146
|
+
return {
|
|
3147
|
+
total: all.length,
|
|
3148
|
+
turns: all.slice(offset, offset + limit).map((h) => ({ kind: h.kind, session: h.session.id, runner: h.session.runner, title: h.session.title, cwd: h.turn.cwd ?? h.session.cwd, file: h.session.file, turn: turnNumber(facts, h), at: h.turn.at ?? null, q: h.q, a: h.a, open: openLink(h) }))
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3096
3151
|
async function recallJson(session, n) {
|
|
3097
3152
|
return recallTurn(await ensureFresh(), session, n);
|
|
3098
3153
|
}
|
|
@@ -3534,6 +3589,7 @@ export {
|
|
|
3534
3589
|
suggestJson,
|
|
3535
3590
|
summarize,
|
|
3536
3591
|
topicsJson,
|
|
3592
|
+
turnsJson,
|
|
3537
3593
|
when,
|
|
3538
3594
|
zstdSkipped
|
|
3539
3595
|
};
|
package/package.json
CHANGED