SLOPSHOPPER

threads

Research threads: /branch into a new VS Code window with a scoped brief, /find threads from verbatim hooks, /threads tree, bookmarks, /connect pass

commandpromptmodelprocess
v0.1.0no licenseupdated 2026-10-19mshark1/research-threads
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · threads
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /split ⎿ threads: Bookmarked: OK (no bookmark: the fork call failed; run /bookmark) ⎿ threads: New thread: OK [ok] ← from OK ⎿ threads: "fix the failing auth test" ⎿ threads: brief: spark + 0 ancestor bookmark(s), ~25 tokens ⎿ threads: Started "thread: OK" in the background. Open the agent view (claude agents) to switch to it, or: claude attach ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

research-threads

A Claude Code mod for discursive, multi-agent research. You go deep on a tangent, wander off, and later want to reconnect it to an earlier idea. This mod keeps each line of thought as a thread you can branch, pause, find again and pick back up, without one long conversation swallowing everything.

  • Threads pause; they never end. Each thread keeps a bookmark: where it left off, what was next, and the key result in mind.
  • Hooks are verbatim. Your own messages are saved word for word, because you'll recognize your own phrasing faster than any summary.
  • Branches stay loosely coupled. A new branch starts from a short brief: its spark, plus the bookmarks of the threads above it. It never gets sibling branches or whole transcripts.
  • You make the connections. The mod only suggests links between threads when you ask.

Install

At the prompt of a terminal session:

/plugin install threads --marketplace mshark1/research-threads

From a shell:

claude plugin install threads --marketplace mshark1/research-threads

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with mshark1/research-threads, then Add plugin.

Commands

CommandWhat it does
/threadsThe whole thread tree, numbered, with your opening line under each thread
/find <fragment>Find a thread from something you half-remember; shows your own matching lines
`/thread <n\id>`One thread: how you got there, where it went, bookmark, latest hooks
/split <spark>Bookmark this thread, write a brief, and start the new thread in its own session (--new skips the "looks like an existing thread" check)
`/pickup <n\id> [full]`Switch to a thread: a fresh agent from its bookmark, or full to resume its last session
`/thread-here [title] [--under <n\id>]`Turn the current session into a thread
/bookmarkRewrite where this thread left off
/pin [text]Save the selection (or text) as a hook
/link <from> <to> · /link #<n>Link two threads, or accept a /connect suggestion
/connectOn demand only: a model suggests links between threads as questions; also lists threads paused for over two weeks
`/thread-archive <n\id>`Move a thread to Threads/.archive (its branches move up a level)
`/thread-open agent\tab\window`Where /split and /pickup start the new session

<n> is a position in the last /threads or /find list.

How it works

  • Thread store: one folder per thread under ~/Documents/Projects/Threads/<id>/. Each folder holds thread.json (parent, links, sessions), hooks.jsonl (verbatim lines), bookmark.md, BRIEF.md, and a CLAUDE.md that keeps the thread's agent scoped to the thread.
  • Where /split starts the new session:
  • agent (default): a background session (claude --bg), which you switch to in the agent view (claude agents).
  • window: a new VS Code window on the thread folder. Claude starts there through a folder-open task, which needs "task.allowAutomaticTasks": "on".
  • tab: a Claude Code extension tab. This was never seen working.
  • First run in agent mode: run claude once in the Threads folder and accept the trust prompt.
  • Platform: written and tested on Windows. The window and tab modes call cmd, code and explorer.exe.

Requirements

Claude Code with function-hook mods (2.1.30x or later).

Source 2 files
hooks/register.tsx 634 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  ancestry,
5  bestPerThread,
6  briefFor,
7  children,
8  CLAUDE_MD,
9  clip,
10  type Hook,
11  mark,
12  parseJson,
13  search,
14  slug,
15  type Thread,
16  tree,
17} from './lib'
18
19type $ = EngineInterface
20type Suggestion = { a: string; b: string; question: string }
21
22const COMMANDS = {
23  split: ['Split a new thread off this one into its own Claude tab or window', '<spark> [--new]'],
24  find: ['Find a thread from a half-remembered fragment', '<fragment>'],
25  threads: ['Show the thread tree', ''],
26  thread: ['Show one thread: path, bookmark, hooks', '<id>'],
27  pickup: ['Pick a paused thread back up in a new window', '<id> [full]'],
28  bookmark: ["Refresh this thread's where-I-left-off", ''],
29  pin: ['Pin the selection (or text) as a verbatim hook', '[text]'],
30  link: ['Link two threads (or accept a /connect suggestion)', '<from> <to> | #<n>'],
31  connect: ['Run a connections pass over all bookmarks', ''],
32  'thread-open': ['Where /split and /pickup open: background agent, Claude tab, or new window', 'agent|tab|window'],
33  'thread-archive': ['Archive a thread (moved to Threads/.archive; its children move up a level)', '<n|id>'],
34  'thread-here': ['Make this session a thread (optionally under another)', '[title] [--under <n|id>]'],
35} as const
36
37// A suggested-restart match has to beat this score (see lib.search).
38const RESTART_SCORE = 2.2
39
40const BOOKMARK_ASK = `Write a bookmark for this conversation so a fresh agent (or the user, weeks from now) can pick it back up exactly where it stands. Plain markdown, at most 12 short lines, under these bold labels:
41**In flight:** the question or idea being worked on right now.
42**Next:** what was about to be tried.
43**Picture:** the key image, result or claim in mind (say whether it is proven, observed in a sim, or a hunch).
44**Artifacts:** files and artifact links that matter, with one-line purposes.
45Do not summarise the whole history.`
46
47export const register: Register = on => {
48  on('session.start', async ($, e, next) => {
49    const started = await next(e)
50    const names: Record<string, string> = {}
51    for (const [name, [description, argumentHint]] of Object.entries(COMMANDS)) {
52      const spec = argumentHint ? { name, description, argumentHint } : { name, description }
53      // A built-in may own the plain name; fall back to a prefixed one.
54      names[name] = await $.command
55        .register(spec)
56        .then(r => r.command)
57        .catch(() => $.command.register({ ...spec, name: `t-${name}` }).then(r => r.command))
58    }
59    await $.store.set('commandNames', names)
60    await adopt($, e.cwd).catch(() => undefined)
61    return started
62  })
63
64  // The user's own messages are the default hooks, kept verbatim.
65  on('prompt.submit', async ($, e, next) => {
66    if (!e.command && !e.isMeta && e.text.trim()) {
67      const id = await currentId($)
68      if (id) await addHook($, id, { at: now(), text: e.text, by: 'user' }).catch(() => undefined)
69      // A tab opened by /split or /pickup starts in the project folder; its first prompt names the brief.
70      else await adoptByBrief($, e.text).catch(() => undefined)
71    }
72    return next(e)
73  }).catch(($, e, next) => next(e))
74
75  on('session.end', async ($, e, next) => {
76    const id = await currentId($)
77    if (id && e.reason !== 'clear') {
78      const t = await load($, id)
79      if (t) {
80        await bookmarkFromFork($, id).catch(() => undefined)
81        await save($, { ...t, status: 'paused' })
82      }
83    }
84    return next(e)
85  })
86
87  on('command.run', { command: ['split', 't-split'] }, async ($, e) => ({ text: await split($, e.args) })).catch(oops)
88  on('command.run', { command: ['find', 't-find'] }, async ($, e) => ({ text: await find($, e.args, e.presentation.columns) })).catch(oops)
89  on('command.run', { command: ['threads', 't-threads'] }, async ($, e) => ({ text: await showTree($, e.presentation.columns) })).catch(oops)
90  on('command.run', { command: ['thread', 't-thread'] }, async ($, e) => ({ text: await showThread($, e.args.trim(), e.presentation.columns) })).catch(oops)
91  on('command.run', { command: ['pickup', 't-pickup'] }, async ($, e) => ({ text: await pickup($, e.args) })).catch(oops)
92  on('command.run', { command: ['bookmark', 't-bookmark'] }, async $ => ({ text: await refreshBookmark($) })).catch(oops)
93  on('command.run', { command: ['thread-open', 't-thread-open'] }, async ($, e) => ({ text: await setOpenIn($, e.args.trim()) })).catch(oops)
94  on('command.run', { command: ['thread-here', 't-thread-here'] }, async ($, e) => ({ text: await threadHere($, e.args.trim()) })).catch(oops)
95  on('command.run', { command: ['thread-archive', 't-thread-archive'] }, async ($, e) => ({ text: await archive($, e.args.trim()) })).catch(oops)
96  on('command.run', { command: ['pin', 't-pin'] }, async ($, e) => ({ text: await pin($, e.args) })).catch(oops)
97  on('command.run', { command: ['link', 't-link'] }, async ($, e) => ({ text: await link($, e.args) })).catch(oops)
98  on('command.run', { command: ['connect', 't-connect'] }, async $ => ({ text: await connect($) })).catch(oops)
99}
100
101// A failed command still answers, with the reason.
102const oops = (_$: $, _e: unknown, next: { error: { kind: string; message?: string } }) => ({
103  text: `threads: that failed (${next.error.message ?? next.error.kind}).`,
104})
105
106// ---- storage ----
107
108async function root($: $): Promise<string> {
109  const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.').replace(/\\/g, '/')
110  return `${home}/Documents/Projects/Threads`
111}
112
113async function dir($: $, id: string): Promise<string> {
114  return `${await root($)}/${id}`
115}
116
117async function load($: $, id: string): Promise<Thread | undefined> {
118  const text = await $.fs.read(`${await dir($, id)}/thread.json`).catch(() => undefined)
119  return text === undefined ? undefined : (JSON.parse(text) as Thread)
120}
121
122async function save($: $, t: Thread): Promise<void> {
123  await $.fs.write(`${await dir($, t.id)}/thread.json`, JSON.stringify({ ...t, updated: now() }, null, 2) + '\n')
124}
125
126async function all($: $): Promise<Record<string, Thread>> {
127  const base = await root($)
128  const entries = await $.fs.list(base).catch(() => [])
129  const out: Record<string, Thread> = {}
130  for (const entry of entries) {
131    if (entry.kind !== 'dir') continue
132    const t = await load($, entry.name)
133    if (t) out[t.id] = t
134  }
135  return out
136}
137
138async function hooks($: $, id: string): Promise<Hook[]> {
139  const text = await $.fs.read(`${await dir($, id)}/hooks.jsonl`).catch(() => '')
140  return text
141    .split('\n')
142    .filter(Boolean)
143    .map(line => JSON.parse(line) as Hook)
144}
145
146async function addHook($: $, id: string, hook: Hook): Promise<void> {
147  const path = `${await dir($, id)}/hooks.jsonl`
148  const prior = await $.fs.read(path).catch(() => '')
149  await $.fs.write(path, prior + JSON.stringify(hook) + '\n')
150}
151
152async function bookmarkOf($: $, id: string): Promise<string | undefined> {
153  return $.fs.read(`${await dir($, id)}/bookmark.md`).catch(() => undefined)
154}
155
156async function bookmarks($: $, ids: string[]): Promise<Record<string, string>> {
157  const out: Record<string, string> = {}
158  for (const id of ids) {
159    const b = await bookmarkOf($, id)
160    if (b) out[id] = b
161  }
162  return out
163}
164
165async function sessionMap($: $): Promise<Record<string, string>> {
166  return ((await $.store.get('sessions')) as Record<string, string> | undefined) ?? {}
167}
168
169async function bind($: $, sessionId: string, threadId: string): Promise<void> {
170  await $.store.set('sessions', { ...(await sessionMap($)), [sessionId]: threadId })
171}
172
173async function currentId($: $): Promise<string | undefined> {
174  const sessionId = await $.session.id()
175  const bound = (await sessionMap($))[sessionId]
176  if (bound) return bound
177  // Otherwise the most recently touched thread that lists this session.
178  const owner = Object.values(await all($))
179    .filter(t => t.sessions.some(s => s.id === sessionId))
180    .sort((a, b) => b.updated.localeCompare(a.updated))[0]
181  if (owner) await bind($, sessionId, owner.id)
182  return owner?.id
183}
184
185// A session whose first prompt reads a thread's BRIEF.md belongs to that thread.
186async function adoptByBrief($: $, text: string): Promise<void> {
187  const base = (await root($)).toLowerCase()
188  const flat = text.replace(/\\/g, '/')
189  const at = flat.toLowerCase().indexOf(base + '/')
190  if (at < 0) return
191  const m = flat.slice(at + base.length + 1).match(/^([^/\s]+)\/BRIEF\.md/i)
192  const t = m ? await load($, m[1]!) : undefined
193  if (!t) return
194  const sessionId = await $.session.id()
195  await bind($, sessionId, t.id)
196  await save($, { ...t, status: 'active', sessions: [...t.sessions, { id: sessionId, cwd: await $.session.cwd(), at: now() }] })
197}
198
199// A session started inside a thread folder belongs to that thread.
200async function adopt($: $, cwd: string): Promise<void> {
201  const base = (await root($)).toLowerCase()
202  const here = cwd.replace(/\\/g, '/')
203  if (!here.toLowerCase().startsWith(base + '/')) return
204  const id = here.slice(base.length + 1).split('/')[0]!
205  const t = await load($, id)
206  if (!t) return
207  const sessionId = await $.session.id()
208  await bind($, sessionId, id)
209  const sessions = t.sessions.some(s => s.id === sessionId) ? t.sessions : [...t.sessions, { id: sessionId, cwd, at: now() }]
210  await save($, { ...t, status: 'active', sessions })
211}
212
213async function uniqueId($: $, title: string): Promise<string> {
214  const base = slug(title)
215  let id = base
216  for (let n = 2; await $.fs.exists(`${await dir($, id)}/thread.json`); n++) id = `${base}-${n}`
217  return id
218}
219
220async function create($: $, fields: Omit<Thread, 'id' | 'links' | 'created' | 'updated' | 'status'>): Promise<Thread> {
221  const id = await uniqueId($, fields.title)
222  const t: Thread = { id, links: [], created: now(), updated: now(), status: 'active', ...fields }
223  const folder = await dir($, id)
224  if (!t.workDir) t.workDir = folder
225  await save($, t)
226  await $.fs.write(`${folder}/CLAUDE.md`, CLAUDE_MD)
227  return t
228}
229
230// ---- model calls ----
231
232async function title($: $, text: string): Promise<string> {
233  const r = await $.model.complete({
234    model: 'haiku',
235    effort: 'low',
236    maxTokens: 40,
237    timeoutMs: 15000,
238    prompt: `Give a 2-4 word title for a research thread that starts with this message. Reply with the title alone, no quotes.\n\n<message>\n${text}\n</message>`,
239  })
240  return r.isAnswered && r.text.trim() ? clip(r.text.trim().replace(/^["']|["']$/g, ''), 40) : clip(text, 30)
241}
242
243async function bookmarkFromFork($: $, id: string): Promise<string | undefined> {
244  const r = await $.model.fork({ prompt: BOOKMARK_ASK })
245  if (!r.isAnswered) return undefined
246  await $.fs.write(`${await dir($, id)}/bookmark.md`, r.text.trim() + '\n')
247  return r.text.trim()
248}
249
250// ---- split: start an agent on the thread, in a Claude tab here or in a new VS Code window ----
251
252type Launch = { prompt: string } | { session: string }
253
254type OpenIn = 'agent' | 'tab' | 'window'
255
256const OPEN_IN: Record<OpenIn, string> = {
257  agent: 'a background session in the agent view (claude agents)',
258  tab: 'a Claude Code tab in the current VS Code window',
259  window: "a new VS Code window on the thread's folder",
260}
261
262async function openIn($: $): Promise<OpenIn> {
263  const mode = await $.store.get('openIn')
264  return mode === 'tab' || mode === 'window' ? mode : 'agent'
265}
266
267async function setOpenIn($: $, mode: string): Promise<string> {
268  if (!(mode in OPEN_IN)) return `/split and /pickup open ${OPEN_IN[await openIn($)]}. Change it with /thread-open agent|tab|window.`
269  await $.store.set('openIn', mode)
270  return `/split and /pickup now open ${OPEN_IN[mode as OpenIn]}.`
271}
272
273async function openSplit($: $, t: Thread, launch: Launch, cwd: string): Promise<string> {
274  const folder = await dir($, t.id)
275  const win = (p: string) => p.replace(/\//g, '\\')
276  const claudeArgs = 'session' in launch ? ['--resume', launch.session] : [launch.prompt]
277  if (cwd !== folder && !('session' in launch)) claudeArgs.unshift('--add-dir', win(folder))
278  const manual = `cd "${win(cwd)}" && claude ${claudeArgs.map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ')}`
279
280  const mode = await openIn($)
281  if (mode === 'agent') {
282    // A background session shows up in `claude agents`; switch to it there.
283    const name = `thread: ${t.title}`
284    const bg = await $.process
285      .run(['claude', '--bg', '-n', name, ...claudeArgs], { cwd: win(cwd), timeoutMs: 60000 })
286      .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: String(error) }))
287    const said = `${bg.stdout}\n${bg.stderr}`.trim()
288    if (/not trusted/i.test(said)) {
289      return `Claude Code hasn't trusted ${win(cwd)} yet. Run claude there once and accept the trust prompt, then /pickup ${t.id}.`
290    }
291    return bg.exitCode === 0
292      ? `Started "${name}" in the background. Open the agent view (claude agents) to switch to it, or: claude attach "${name}"`
293      : `Couldn't start a background session (${clip(said, 200)}). Start it yourself:\n  ${manual}`
294  }
295
296  if (mode === 'tab') {
297    // The Claude Code extension's own link opens a new Claude tab in the focused window.
298    const query = 'session' in launch ? `session=${encodeURIComponent(launch.session)}` : `prompt=${encodeURIComponent(launch.prompt)}`
299    const opened = await $.process.run(['explorer.exe', `vscode://anthropic.claude-code/open?${query}`], { timeoutMs: 20000 }).catch(() => undefined)
300    return opened
301      ? `Opened a Claude tab in this VS Code window. If the prompt is only filled in, press Enter.\n  /thread-open window switches back to separate windows. If no tab appeared, run:\n  ${manual}`
302      : `Couldn't open a Claude tab. Start it yourself:\n  ${manual}`
303  }
304
305  const tasks = {
306    version: '2.0.0',
307    tasks: [
308      {
309        label: `claude: ${t.title}`,
310        type: 'process',
311        command: 'claude',
312        args: claudeArgs,
313        options: { cwd: win(cwd) },
314        runOptions: { runOn: 'folderOpen' },
315        presentation: { reveal: 'always', panel: 'dedicated', focus: true },
316        problemMatcher: [],
317      },
318    ],
319  }
320  await $.fs.write(`${folder}/.vscode/tasks.json`, JSON.stringify(tasks, null, 2) + '\n')
321  const opened = await $.process.run(['cmd', '/c', 'code', win(folder)], { timeoutMs: 20000 }).catch(() => undefined)
322  return opened?.exitCode === 0
323    ? `Opened a new VS Code window on ${win(folder)}.\n  If claude doesn't start there, set "task.allowAutomaticTasks": "on" or run:\n  ${manual}`
324    : `Couldn't open VS Code. Start it yourself:\n  ${manual}`
325}
326
327async function startPrompt($: $, t: Thread): Promise<string> {
328  return `Read ${await dir($, t.id)}/BRIEF.md, then tell me in a few lines where this thread starts from and what you would look at first.`
329}
330
331// Make this session a thread: its first message is the spark, all of the user's messages its hooks.
332async function threadFromSession($: $, opts: { title?: string; parent?: string; fallbackSpark?: string }): Promise<Thread> {
333  const mine = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() && !m.text.startsWith('<'))
334  const first = mine[0]?.text ?? opts.fallbackSpark ?? ''
335  const cwd = await $.session.cwd()
336  const t = await create($, {
337    title: opts.title || (await title($, first)),
338    spark: first,
339    parent: opts.parent ?? null,
340    workDir: cwd.replace(/\\/g, '/'),
341    sessions: [{ id: await $.session.id(), cwd, at: now() }],
342  })
343  for (const m of mine) await addHook($, t.id, { at: now(), text: m.text, by: 'user' })
344  await bind($, await $.session.id(), t.id)
345  return t
346}
347
348// ---- commands ----
349
350async function threadHere($: $, args: string): Promise<string> {
351  const already = await currentId($)
352  if (already) return `This session is already the thread [${already}]. /thread for its details.`
353  const under = args.match(/(?:^|\s)--under\s+(\S+)/)
354  const threads = await all($)
355  const parent = under ? await resolve($, under[1]!) : undefined
356  if (parent && !threads[parent]) return `No thread "${under![1]}" to put it under. /threads to look.`
357  const name = args.replace(/(?:^|\s)--under\s+\S+/, '').trim()
358  const t = await threadFromSession($, { title: name || undefined, parent })
359  const hookCount = (await hooks($, t.id)).length
360  const marked = await bookmarkFromFork($, t.id).catch(() => undefined)
361  return [
362    `This session is now the thread ${t.title}  [${t.id}]${parent ? `  ← under ${threads[parent]!.title}` : ''}`,
363    `  spark: "${clip(t.spark, 90)}"`,
364    `  ${hookCount} of your messages saved as hooks; ${marked ? 'bookmark written' : 'no bookmark yet (run /bookmark)'}.`,
365  ].join('\n')
366}
367
368async function split($: $, args: string): Promise<string> {
369  const isForced = /(^|\s)--new(\s|$)/.test(args)
370  let spark = args.replace(/(^|\s)--new(\s|$)/, ' ').trim()
371  if (!spark) spark = (await $.ui.selection())?.text.trim() ?? ''
372  if (!spark) {
373    const mine = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() && !m.text.startsWith('<'))
374    spark = mine.at(-1)?.text.trim() ?? ''
375  }
376  if (!spark) return 'Give the spark: /split <what started this>'
377
378  const threads = await all($)
379  if (!isForced) {
380    const match = await restartMatch($, spark, threads)
381    if (match) return match
382  }
383
384  // The thread we're leaving: this session's, or a new one for an untracked session.
385  let parentId = await currentId($)
386  if (!parentId) {
387    const parent = await threadFromSession($, { fallbackSpark: spark })
388    parentId = parent.id
389    threads[parentId] = parent
390  }
391
392  const fork = await $.model.fork({
393    prompt: `The user is splitting a new research thread off this conversation. Its spark, in their words:\n<spark>\n${spark}\n</spark>\nReply with JSON only: {"bookmark": "...", "handoff": "..."}.\n"bookmark": ${BOOKMARK_ASK}\n"handoff": what a fresh agent starting the new thread needs from THIS conversation to pursue the spark, at most 10 lines of markdown. Only what bears on the spark.`,
394  })
395  const parsed = fork.isAnswered ? parseJson<{ bookmark?: string; handoff?: string }>(fork.text) : undefined
396  if (parsed?.bookmark) await $.fs.write(`${await dir($, parentId)}/bookmark.md`, parsed.bookmark.trim() + '\n')
397
398  const child = await create($, { title: await title($, spark), spark, parent: parentId, workDir: '', sessions: [] })
399  threads[child.id] = child
400  await addHook($, child.id, { at: now(), text: spark, by: 'user' })
401  const path = ancestry(child.id, threads)
402  const brief = briefFor(child, threads, await bookmarks($, path.map(p => p.id)), parsed?.handoff)
403  await $.fs.write(`${await dir($, child.id)}/BRIEF.md`, brief)
404  const opened = await openSplit($, child, { prompt: await startPrompt($, child) }, await dir($, child.id))
405
406  return [
407    `Bookmarked: ${threads[parentId]!.title}${parsed?.bookmark ? '' : ' (no bookmark: the fork call failed; run /bookmark)'}`,
408    `New thread: ${child.title}  [${child.id}]  ← from ${threads[parentId]!.title}`,
409    `  "${clip(spark, 90)}"`,
410    `  brief: spark + ${path.length - 1} ancestor bookmark(s), ~${Math.round(brief.length / 4)} tokens`,
411    opened,
412  ].join('\n')
413}
414
415async function restartMatch($: $, spark: string, threads: Record<string, Thread>): Promise<string | undefined> {
416  const corpus = await corpusOf($, threads)
417  const titles = Object.fromEntries(Object.values(threads).map(t => [t.id, t.title]))
418  // One shared word ("test") is a coincidence, not a restart.
419  const top = bestPerThread(search(spark, corpus, titles), 2).filter(h => h.score >= RESTART_SCORE && h.shared >= 3)
420  if (!top.length) return undefined
421  const names = (await $.store.get('commandNames')) as Record<string, string> | undefined
422  const lines = ['This looks like a thread you already started:']
423  for (const h of top) {
424    const t = threads[h.thread]!
425    lines.push(`  ${t.title} ${mark(t)}  [${t.id}]`, `    "${clip(h.hook.text, 90)}"`)
426  }
427  lines.push(
428    '',
429    `/${names?.pickup ?? 'pickup'} ${top[0]!.thread} to resume it, /${names?.link ?? 'link'} to connect, or /${names?.split ?? 'split'} --new ${clip(spark, 30)} to start fresh anyway.`,
430  )
431  return lines.join('\n')
432}
433
434async function corpusOf($: $, threads: Record<string, Thread>): Promise<{ thread: string; hook: Hook }[]> {
435  const corpus: { thread: string; hook: Hook }[] = []
436  for (const id of Object.keys(threads)) for (const hook of await hooks($, id)) corpus.push({ thread: id, hook })
437  return corpus
438}
439
440// `/thread 3` and `/pickup 3` mean the 3rd entry of the last /threads or /find listing.
441async function resolve($: $, ref: string): Promise<string> {
442  const n = ref.match(/^#?(\d+)$/)
443  if (!n) return ref
444  const listing = ((await $.store.get('listing')) as string[] | undefined) ?? []
445  return listing[Number(n[1]) - 1] ?? ref
446}
447
448async function find($: $, fragment: string, columns: number): Promise<string> {
449  if (!fragment.trim()) return 'What do you remember? /find <fragment>'
450  const threads = await all($)
451  const titles = Object.fromEntries(Object.values(threads).map(t => [t.id, t.title]))
452  const hits = bestPerThread(search(fragment, await corpusOf($, threads), titles), 6)
453  if (!hits.length) return `Nothing matched "${fragment}". Try other words you remember, or /threads for the whole tree.`
454  await $.store.set('listing', hits.map(h => h.thread))
455  const room = Math.max(40, columns - 8)
456  const lines = [`Matches for "${clip(fragment, 40)}":`]
457  hits.forEach((h, i) => {
458    const t = threads[h.thread]!
459    lines.push(clip(`${i + 1}. ${t.title} ${mark(t)}  [${t.id}]`, room), `   "${clip(h.hook.text, room - 5)}"`)
460  })
461  lines.push('', '/thread <n> for its path and bookmark, /pickup <n> to switch to it.')
462  return lines.join('\n')
463}
464
465async function showTree($: $, columns: number | undefined): Promise<string> {
466  const threads = await all($)
467  if (!Object.keys(threads).length) return 'No threads yet. /split <spark> starts one.'
468  const first: Record<string, string> = {}
469  for (const id of Object.keys(threads)) first[id] = (await hooks($, id))[0]?.text ?? threads[id]!.spark
470  const { text, order } = tree(threads, first, columns ?? 100)
471  await $.store.set('listing', order)
472  return [
473    `${order.length} threads. /thread <n> to look closer, /pickup <n> to switch to one.`,
474    '',
475    text,
476    '',
477    '● active  ⏸ paused  ↩ your links (* = from a connections pass)',
478  ].join('\n')
479}
480
481async function showThread($: $, ref: string, columns: number): Promise<string> {
482  const threads = await all($)
483  const id = await resolve($, ref)
484  const room = Math.max(40, columns - 8)
485  const t = threads[id || ((await currentId($)) ?? '')]
486  if (!t) return id ? `No thread "${id}". /find or /threads to look.` : 'This session is not in a thread. /thread <id>'
487  const path = ancestry(t.id, threads)
488  const kids = children(t.id, threads)
489  const backs = Object.values(threads).filter(o => o.links.some(l => l.to === t.id))
490  const hs = await hooks($, t.id)
491  return [
492    `${t.title} ${mark(t)}  [${t.id}]`,
493    `  how you got here:  ${path.map(p => p.title).join(' → ')}`,
494    `  where it went:     ${kids.length ? kids.map(k => `→ ${k.title}`).join(', ') : '(nothing yet)'}`,
495    ...(t.links.length || backs.length
496      ? [`  links:             ${[...t.links.map(l => threads[l.to]?.title ?? l.to), ...backs.map(b => b.title)].join(', ')}`]
497      : []),
498    '',
499    'Left off:',
500    ((await bookmarkOf($, t.id)) ?? '(no bookmark yet)').trim().replace(/^/gm, '  '),
501    '',
502    `Hooks (${hs.length}, latest last):`,
503    ...hs.slice(-5).map(h => `  ${h.by === 'pin' ? '📌' : '›'} "${clip(h.text, room - 6)}"`),
504    '',
505    `/pickup ${t.id} to resume here (fresh agent from the bookmark), /pickup ${t.id} full to reopen its last session.`,
506  ].join('\n')
507}
508
509async function pickup($: $, args: string): Promise<string> {
510  const [ref = '', mode] = args.trim().split(/\s+/)
511  const threads = await all($)
512  const id = await resolve($, ref)
513  const t = threads[id]
514  if (!t) return `No thread "${id}". /find <fragment> to look for it.`
515  if (mode === 'full') {
516    const last = t.sessions.at(-1)
517    if (!last) return `${t.title} has no recorded session. /pickup ${t.id} starts a fresh agent from its bookmark.`
518    return openSplit($, t, { session: last.id }, last.cwd)
519  }
520  const path = ancestry(t.id, threads)
521  await $.fs.write(`${await dir($, t.id)}/BRIEF.md`, briefFor(t, threads, await bookmarks($, path.map(p => p.id))))
522  const opened = await openSplit($, t, { prompt: await startPrompt($, t) }, t.workDir || (await dir($, t.id)))
523  return `Picking up ${t.title}.\n${opened}`
524}
525
526async function refreshBookmark($: $): Promise<string> {
527  const id = await currentId($)
528  if (!id) return 'This session is not in a thread yet. /split makes it one.'
529  const text = await bookmarkFromFork($, id)
530  return text ? `Bookmark saved for ${id}:\n${text.replace(/^/gm, '  ')}` : 'The bookmark call failed; try again.'
531}
532
533async function pin($: $, args: string): Promise<string> {
534  const id = await currentId($)
535  if (!id) return 'This session is not in a thread yet. /split makes it one.'
536  const text = args.trim() || (await $.ui.selection())?.text.trim()
537  if (!text) return 'Select a line (or pass text) to pin.'
538  await addHook($, id, { at: now(), text, by: 'pin' })
539  return `Pinned to ${id}: "${clip(text, 100)}"`
540}
541
542// Archive rather than delete: the folder moves to Threads/.archive, so it can come back.
543async function archive($: $, ref: string): Promise<string> {
544  if (!ref) return 'Usage: /thread-archive <n|id>. Numbers come from the last /threads or /find.'
545  const threads = await all($)
546  const id = await resolve($, ref)
547  const t = threads[id]
548  if (!t) return `No thread "${id}". /threads to look.`
549
550  const from = (await dir($, id)).replace(/\//g, '\\')
551  const shelf = `${await root($)}/.archive`
552  await $.fs.write(`${shelf}/README.md`, 'Archived threads. Move a folder back up one level to restore it.\n')
553  const to = `${shelf}/${id}-${now().slice(0, 10)}`.replace(/\//g, '\\')
554  const moved = await $.process.run(['cmd', '/c', 'move', from, to], { timeoutMs: 20000 }).catch(() => undefined)
555  if (moved?.exitCode !== 0) {
556    return `Couldn't move ${from} (${clip(`${moved?.stdout ?? ''} ${moved?.stderr ?? ''}`.trim() || 'unknown error', 120)}). If a session is running in that folder, stop it first (claude agents).`
557  }
558
559  // Keep the tree whole: children move up to the archived thread's parent; links to it go.
560  const kids = children(id, threads)
561  for (const other of Object.values(threads)) {
562    if (other.id === id) continue
563    const isKid = other.parent === id
564    const links = other.links.filter(l => l.to !== id)
565    if (isKid || links.length !== other.links.length) await save($, { ...other, parent: isKid ? t.parent : other.parent, links })
566  }
567  const map = await sessionMap($)
568  await $.store.set('sessions', Object.fromEntries(Object.entries(map).filter(([, tid]) => tid !== id)))
569
570  const up = t.parent ? threads[t.parent]?.title : 'the top level'
571  return [
572    `Archived ${t.title} [${id}] to ${to}.`,
573    ...(kids.length ? [`  ${kids.map(k => k.title).join(', ')} moved up to ${up}.`] : []),
574    '  To restore it, move the folder back into Threads.',
575  ].join('\n')
576}
577
578async function link($: $, args: string): Promise<string> {
579  const threads = await all($)
580  const pick = args.trim().match(/^#(\d+)$/)
581  let from: string, to: string, by: 'user' | 'pass', note: string | undefined
582  if (pick) {
583    const s = (((await $.store.get('suggestions')) as Suggestion[] | undefined) ?? [])[Number(pick[1]) - 1]
584    if (!s) return `No suggestion #${pick[1]}. /connect lists them.`
585    ;({ a: from, b: to, question: note } = s)
586    by = 'pass'
587  } else {
588    const parts = args.trim().split(/\s+/)
589    if (parts.length < 2) return 'Usage: /link <from-id> <to-id>, or /link #<n> to accept a /connect suggestion.'
590    ;[from, to] = parts as [string, string]
591    by = 'user'
592  }
593  const a = threads[from]
594  if (!a || !threads[to]) return `Unknown thread: ${!a ? from : to}`
595  if (!a.links.some(l => l.to === to)) await save($, { ...a, links: [...a.links, { to, by, ...(note ? { note } : {}) }] })
596  return `Linked ${a.title} ↩ ${threads[to]!.title}${by === 'pass' ? ' (from a connections pass)' : ''}`
597}
598
599async function connect($: $): Promise<string> {
600  const threads = await all($)
601  const ids = Object.keys(threads)
602  if (ids.length < 2) return 'Need at least two threads.'
603  const marks = await bookmarks($, ids)
604  const cards: string[] = []
605  for (const t of Object.values(threads)) {
606    const hs = (await hooks($, t.id)).filter(h => h.by === 'pin' || h.text.length > 40)
607    const quotes = [hs[0], ...hs.slice(1).filter(h => h.by === 'pin').slice(0, 2)].filter(Boolean) as Hook[]
608    const edges = [t.parent ? `parent ${t.parent}` : '', ...t.links.map(l => `linked ${l.to}`)].filter(Boolean).join('; ')
609    cards.push(`## ${t.id}: ${t.title}${edges ? ` (${edges})` : ''}\n${quotes.map(q => `> ${clip(q.text, 300)}`).join('\n')}\n${clip(marks[t.id] ?? '', 900)}`)
610  }
611  const r = await $.model.complete({
612    model: 'sonnet',
613    maxTokens: 1500,
614    timeoutMs: 90000,
615    prompt: `These are threads from the user's research graph. Find up to 5 connections between threads that are NOT already parent/child or linked: places where one thread's idea, mechanism or result might bear on another. Phrase each as a short question to the user, not a conclusion; they decide. Reply with a JSON array only: [{"a": "<id>", "b": "<id>", "question": "..."}].\n\n${cards.join('\n\n')}`,
616  })
617  const list = (r.isAnswered ? parseJson<Suggestion[]>(r.text) : undefined)?.filter(s => threads[s.a] && threads[s.b]) ?? []
618  if (!list.length) return r.isAnswered ? 'No new connections suggested.' : `The connections pass failed (${r.reason}).`
619  await $.store.set('suggestions', list)
620  const stale = Object.values(threads)
621    .filter(t => t.status === 'paused' && Date.now() - Date.parse(t.updated) > 14 * 864e5)
622    .map(t => t.title)
623  return [
624    ...list.map((s, i) => `${i + 1}. ${threads[s.a]!.title} ↔ ${threads[s.b]!.title}\n   ${s.question}`),
625    '',
626    '/link #<n> to accept one; the rest are dropped at the next pass.',
627    ...(stale.length ? ['', `Paused for over two weeks: ${stale.join(', ')}`] : []),
628  ].join('\n')
629}
630
631function now(): string {
632  return new Date().toISOString()
633}
634
hooks/lib.ts 191 lines
1// Pure helpers for the threads mod: no `$`, so tests can import them.
2
3export type Link = { to: string; by: 'user' | 'pass'; note?: string }
4
5export type SessionRef = { id: string; cwd: string; at: string }
6
7export type Thread = {
8  id: string
9  title: string
10  spark: string
11  parent: string | null
12  links: Link[]
13  status: 'active' | 'paused'
14  created: string
15  updated: string
16  workDir: string
17  sessions: SessionRef[]
18}
19
20export type Hook = { at: string; text: string; by: 'user' | 'pin' }
21
22export type Hit = { thread: string; hook: Hook; score: number; shared: number }
23
24const STOP = new Set(
25  ('a an and are as at be been but by can could do does did for from had has have how i if in into is it its ' +
26    'just like me more my not of on or so some that the their then there these they this to was we were what ' +
27    'when where which while who why will with would you your about also really think want get going one')
28    .split(' '),
29)
30
31export function tokens(text: string): string[] {
32  return (text.toLowerCase().match(/[a-z0-9]+/g) ?? [])
33    .filter(t => t.length > 2 && !STOP.has(t))
34    .map(stem)
35}
36
37// Crude stem so "circles"/"circle", "coupled"/"coupling" meet.
38function stem(t: string): string {
39  return t.replace(/(ings|ing|ed|es|s)$/, '') || t
40}
41
42export function slug(title: string): string {
43  const base = title
44    .toLowerCase()
45    .replace(/[^a-z0-9]+/g, '-')
46    .replace(/^-+|-+$/g, '')
47    .slice(0, 40)
48  return base || 'thread'
49}
50
51// Rank verbatim hooks against a half-remembered fragment, by shared rare words.
52export function search(fragment: string, corpus: { thread: string; hook: Hook }[], titles: Record<string, string> = {}): Hit[] {
53  const query = new Set(tokens(fragment))
54  if (query.size === 0) return []
55  const df = new Map<string, number>()
56  const docs = corpus.map(c => new Set(tokens(c.hook.text + ' ' + (titles[c.thread] ?? ''))))
57  for (const d of docs) for (const t of d) df.set(t, (df.get(t) ?? 0) + 1)
58  const n = corpus.length
59  const hits: Hit[] = []
60  corpus.forEach((c, i) => {
61    let score = 0
62    let shared = 0
63    for (const t of query) {
64      if (!docs[i]!.has(t)) continue
65      score += Math.log(1 + n / (df.get(t) ?? 1))
66      shared++
67    }
68    if (score > 0) hits.push({ ...c, score: score / Math.sqrt(query.size), shared })
69  })
70  return hits.sort((a, b) => b.score - a.score)
71}
72
73// Best hit per thread, so one chatty thread doesn't crowd the list.
74export function bestPerThread(hits: Hit[], limit: number): Hit[] {
75  const seen = new Set<string>()
76  const out: Hit[] = []
77  for (const h of hits) {
78    if (seen.has(h.thread)) continue
79    seen.add(h.thread)
80    out.push(h)
81    if (out.length === limit) break
82  }
83  return out
84}
85
86export function clip(text: string, width: number): string {
87  const flat = text.replace(/\s+/g, ' ').trim()
88  return flat.length <= width ? flat : flat.slice(0, width - 1) + '…'
89}
90
91export function ancestry(id: string, all: Record<string, Thread>): Thread[] {
92  const path: Thread[] = []
93  let at: Thread | undefined = all[id]
94  while (at && !path.includes(at)) {
95    path.unshift(at)
96    at = at.parent ? all[at.parent] : undefined
97  }
98  return path
99}
100
101export function children(id: string, all: Record<string, Thread>): Thread[] {
102  return Object.values(all)
103    .filter(t => t.parent === id)
104    .sort((a, b) => a.created.localeCompare(b.created))
105}
106
107export function mark(t: Thread): string {
108  return t.status === 'active' ? '●' : '⏸'
109}
110
111// Room the transcript takes for its own gutter beside a command's output.
112const GUTTER = 8
113
114// Each node gets a number (its place in this listing) so `/pickup 3` works.
115export function tree(all: Record<string, Thread>, firstHook: Record<string, string>, width = 100): { text: string; order: string[] } {
116  const roots = Object.values(all)
117    .filter(t => !t.parent || !all[t.parent])
118    .sort((a, b) => a.created.localeCompare(b.created))
119  const room = Math.max(40, width - GUTTER)
120  const lines: string[] = []
121  const order: string[] = []
122  const walk = (t: Thread, prefix: string, isLast: boolean, isRoot: boolean) => {
123    order.push(t.id)
124    const branch = isRoot ? '' : isLast ? '└─ ' : '├─ '
125    const links = t.links.length ? `  ↩ ${t.links.map(l => l.to + (l.by === 'pass' ? '*' : '')).join(', ')}` : ''
126    const head = `${prefix}${branch}${order.length}. ${t.title} ${mark(t)}  [${t.id}]${links}`
127    lines.push(head.length <= room ? head : head.slice(0, room - 1) + '…')
128    const childPrefix = prefix + (isRoot ? '' : isLast ? '   ' : '│  ')
129    const kids = children(t.id, all)
130    const quote = firstHook[t.id]
131    if (quote) {
132      const lead = `${childPrefix}${kids.length ? '│  ' : '   '}`
133      lines.push(`${lead}"${clip(quote, Math.max(20, room - lead.length - 2))}"`)
134    }
135    kids.forEach((k, i) => walk(k, childPrefix, i === kids.length - 1, false))
136  }
137  roots.forEach(r => walk(r, '', true, true))
138  return { text: lines.join('\n'), order }
139}
140
141// Pull the first JSON object or array out of a model reply.
142export function parseJson<T>(text: string): T | undefined {
143  const start = text.search(/[[{]/)
144  if (start < 0) return undefined
145  for (let end = text.length; end > start; end--) {
146    const ch = text[end - 1]
147    if (ch !== '}' && ch !== ']') continue
148    try {
149      return JSON.parse(text.slice(start, end)) as T
150    } catch {
151      // keep shrinking
152    }
153  }
154  return undefined
155}
156
157export function briefFor(t: Thread, all: Record<string, Thread>, bookmarks: Record<string, string>, handoff?: string): string {
158  const path = ancestry(t.id, all)
159  const out = [
160    `# Brief: ${t.title}`,
161    '',
162    `Thread \`${t.id}\`. Path: ${path.map(p => p.title).join(' → ')}`,
163    '',
164    '## Spark (verbatim, from the user)',
165    '',
166    t.spark
167      .split('\n')
168      .map(l => `> ${l}`)
169      .join('\n'),
170    '',
171  ]
172  if (handoff) out.push('## Handed over from the parent conversation', '', handoff.trim(), '')
173  if (bookmarks[t.id]) out.push('## Where this thread left off', '', bookmarks[t.id]!.trim(), '')
174  const elders = path.slice(0, -1).reverse()
175  if (elders.length) {
176    out.push('## Where the threads it came from left off', '')
177    for (const e of elders) out.push(`### ${e.title} (\`${e.id}\`)`, '', (bookmarks[e.id] ?? '(no bookmark yet)').trim(), '')
178  }
179  return out.join('\n')
180}
181
182export const CLAUDE_MD = `# Research thread
183
184This folder is one thread in the user's research graph. Start by reading BRIEF.md.
185
186- Stay inside this thread. Don't read sibling thread folders (\`../*\`) unless the user asks; that's the point of the split.
187- The user likes discursive tangents and makes the connections between threads themselves. Don't volunteer links to other threads.
188- Put work files for this thread in this folder.
189- \`/bookmark\` refreshes where-I-left-off, \`/split <spark>\` splits off a new thread, \`/find <fragment>\` locates an old one.
190`
191