dsh-thoughtdag 0.5.5 → 0.5.6

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.
Files changed (28) hide show
  1. package/dist-app/assets/{canonical-DyO4cfs8.js → canonical-CSoZNuPg.js} +2 -2
  2. package/dist-app/assets/{canvas-record-B106EgJV.js → canvas-record-wnrN3Xiq.js} +1 -1
  3. package/dist-app/assets/{claude-code-session-COqNVArs.js → claude-code-session-Cjh9ZQd-.js} +1 -1
  4. package/dist-app/assets/{codex-session-CVhXEocv.js → codex-session-Dgq2Nsm-.js} +1 -1
  5. package/dist-app/assets/{dsh-session-y6pdDHlg.js → dsh-session-B9EsawUx.js} +1 -1
  6. package/dist-app/assets/{experiment-loop-Bj2wGvJp.js → experiment-loop-CW1DebVY.js} +1 -1
  7. package/dist-app/assets/{http-bridge-TYkQm1R3.js → http-bridge-DG_nZVjI.js} +1 -1
  8. package/dist-app/assets/index-B1IC85BS.js +914 -0
  9. package/dist-app/assets/index-CgnmeNGy.css +1 -0
  10. package/dist-app/assets/{index-BndAg25p.js → index-D7EuGCbC.js} +1 -1
  11. package/dist-app/assets/{index-D2RofBbL.js → index-Fb-Q079K.js} +1 -1
  12. package/dist-app/assets/{index-BZP0zxsA.js → index-rGP3NmkV.js} +3 -3
  13. package/dist-app/assets/local-ladder.worker-Dj3KyWai.js +1 -0
  14. package/dist-app/assets/{pi-session-fSCIsceL.js → pi-session-1yVM3dMC.js} +1 -1
  15. package/dist-app/assets/{sensitive-scan-_-YPfT-P.js → sensitive-scan-CROt_JlT.js} +1 -1
  16. package/dist-app/assets/{session-handoff-rVZFB7ll.js → session-handoff-r3h38IOI.js} +2 -2
  17. package/dist-app/assets/{shared-svBDp9_R.js → shared-ABEucpDy.js} +1 -1
  18. package/dist-app/assets/{turndown-plugin-gfm.cjs-DBiLx1SX.js → turndown-plugin-gfm.cjs-C6ig148J.js} +1 -1
  19. package/dist-app/assets/update-check-Bo9R7uYE.js +1 -0
  20. package/dist-app/index.html +2 -2
  21. package/lib/client.js +33 -8
  22. package/lib/index.js +183 -11
  23. package/lib/why.mjs +56 -0
  24. package/package.json +1 -1
  25. package/dist-app/assets/index-CqebPxzE.css +0 -1
  26. package/dist-app/assets/index-DR0C_9n-.js +0 -912
  27. package/dist-app/assets/local-ladder.worker-BkmzXrqq.js +0 -1
  28. package/dist-app/assets/update-check-n2hvSdhA.js +0 -1
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
- const SESSION_LOG_NAMES = ['session.v3.jsonl.zstd', 'session.jsonl.zstd']
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 log
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
- for (const name of SESSION_LOG_NAMES) {
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: false, searchEngine: 'none', scholarSearch: false, vision: models.some(m => m.vision) },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-thoughtdag",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "ThoughtDAG for DeepSeek Harness: turn the current DSH session into an editable thought graph on an infinite canvas.",
5
5
  "author": "Xia Chen",
6
6
  "repository": {