@maci0/dsh-feynman 0.0.0-stage → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Research workflow commands for DeepSeek Harness.
3
+ *
4
+ * One `/feynman` command: 14 workflow subcommands (deepresearch, lit, review,
5
+ * review-loop, audit, replicate, recipe, compare, draft, autoresearch, watch,
6
+ * rank, paper, preview) that steer the model with workflow briefs, plus session
7
+ * subcommands (log, jobs, help, feynman-model, init, outputs, btw, thinking,
8
+ * search, web-results, keys, doctor, status).
9
+ *
10
+ * For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
11
+ */
12
+ import { WORKFLOWS, SESSION_COMMANDS, THINKING_LEVELS, buildPrompt, parseLoopArgs, parseRankArgs, parsePaperArgs, loopFollowupPrompt } from './prompts.js'
13
+ import Schema from '@deepseek-ai/schemastery'
14
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
15
+
16
+ export const name = 'feynman'
17
+ // The review-loop driver resolves agents from the registry on turn/end;
18
+ // without 'agents' the lookup misses and rounds after the first never queue.
19
+ export const inject = ['commands', 'agents']
20
+
21
+ /**
22
+ * The settings entry the browser card reads: the settings domain keys one form
23
+ * per mounted row, so this is the row id in `cordis.patch.yml`, not a namespace
24
+ * the host registers itself.
25
+ */
26
+ const RESEARCH_KEYS_NAMESPACE = 'feynman'
27
+
28
+ /**
29
+ * The two env refs, also the settings-card schema. The loop/rank bounds are
30
+ * destructured defaults in prompts.js, not config rows: nobody tunes them.
31
+ */
32
+ export const Config = Schema.object({
33
+ hfTokenEnv: Schema.string().role('credential-ref').default('HF_TOKEN').volatile(),
34
+ alphaxivTokenEnv: Schema.string().role('credential-ref').default('ALPHAXIV_API_KEY').volatile(),
35
+ })
36
+
37
+ // --- key configuration (Hugging Face + AlphaXiv) ---
38
+ //
39
+ // Secrets live in the credentials seam ($DSH_HOME/.credentials.yaml, env, .env
40
+ // fallbacks), never in row config. Row config names only the env refs, so one
41
+ // deployment can point at different vault names without touching code.
42
+ // Zero-click path: `export HF_TOKEN=… ALPHAXIV_API_KEY=…`; managed path: /keys.
43
+
44
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
45
+
46
+ /** Unwrap a volatile config ref (anything with `get()`) to its current value. */
47
+ function plainConfig(value) {
48
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') return plainConfig(value.get())
49
+ return value
50
+ }
51
+
52
+ /** Schema input: volatile refs become their current snapshots. The live refs stay on the row. */
53
+ function detachConfig(value) {
54
+ const plain = plainConfig(value)
55
+ if (plain !== value) return plain
56
+ if (Array.isArray(value)) return value.map(detachConfig)
57
+ if (value !== null && typeof value === 'object') {
58
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, detachConfig(child)]))
59
+ }
60
+ return value
61
+ }
62
+
63
+ /**
64
+ * Per-instance plugin state. Module-level state outlives plugin unload and is
65
+ * shared by every instance, so a patch reload would re-run apply on top of the
66
+ * previous instance's loops.
67
+ */
68
+ function createState(rawConfig) {
69
+ const checked = resolveConfig(rawConfig)
70
+ const read = (field) => {
71
+ const source = rawConfig !== null && typeof rawConfig === 'object' && Object.hasOwn(rawConfig, field)
72
+ ? rawConfig[field]
73
+ : checked[field]
74
+ const live = plainConfig(source)
75
+ return typeof live === 'string' ? live : plainConfig(checked[field])
76
+ }
77
+ const refs = {
78
+ hfTokenEnv: read('hfTokenEnv'),
79
+ alphaxivTokenEnv: read('alphaxivTokenEnv'),
80
+ }
81
+ for (const [field, value] of Object.entries(refs)) {
82
+ if (typeof value !== 'string' || !REF_PATTERN.test(value)) {
83
+ throw new Error(`[feynman] ${field} must be an env-var name (letters, digits, underscore); got ${JSON.stringify(value)}`)
84
+ }
85
+ }
86
+ return {
87
+ refs,
88
+ // Live row: volatile fields update in place.
89
+ source: () => ({
90
+ hfTokenEnv: read('hfTokenEnv'),
91
+ alphaxivTokenEnv: read('alphaxivTokenEnv'),
92
+ }),
93
+ loops: new Map(),
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Row config through the exported schema: defaults applied, out-of-range values
99
+ * rejected. Cordis validates the same schema before apply; this is the
100
+ * direct-apply and shared-code path, and it owns the plugin-facing message.
101
+ */
102
+ function resolveConfig(rawConfig) {
103
+ try {
104
+ return Config(detachConfig(rawConfig))
105
+ } catch (error) {
106
+ throw new Error(`[feynman] ${error instanceof Error ? error.message : String(error)}`)
107
+ }
108
+ }
109
+
110
+ /** Read live refs from the settings scope when valid, else the row fallback. */
111
+ function liveRefs(state) {
112
+ try {
113
+ const value = state.source()
114
+ if (value !== null && typeof value === 'object') {
115
+ const { hfTokenEnv, alphaxivTokenEnv } = value
116
+ if (typeof hfTokenEnv === 'string' && typeof alphaxivTokenEnv === 'string') {
117
+ return { hfTokenEnv, alphaxivTokenEnv }
118
+ }
119
+ }
120
+ } catch { /* fall through to row config */ }
121
+ return state.refs
122
+ }
123
+
124
+ /** Whether the settings namespace behind the config card is served. */
125
+ function cardState(ctx) {
126
+ try {
127
+ const settings = service(ctx, 'settings')
128
+ if (!settings) return 'settings service absent: card unavailable on minimal profiles'
129
+ // The service the harness mounts (`SettingsForms`) has no `get`: it lists
130
+ // one descriptor per configurable row through `describe()`. Probing for a
131
+ // `get` method threw a TypeError, which read as an unserved namespace even
132
+ // while the card was mounted.
133
+ const descriptors = typeof settings.describe === 'function' ? settings.describe() : undefined
134
+ const served = Array.isArray(descriptors)
135
+ && descriptors.some((descriptor) => descriptor?.ns === RESEARCH_KEYS_NAMESPACE)
136
+ return served
137
+ ? 'namespace served: the card is on the Plugins page, on the feynman row\'s Configure control'
138
+ : 'namespace NOT served'
139
+ } catch (error) {
140
+ return `namespace NOT served (${error instanceof Error ? error.message : String(error)})`
141
+ }
142
+ }
143
+
144
+ /** Optional service lookup: ctx.get returns undefined when absent. */
145
+ const service = (ctx, key) => ctx.get?.(key)
146
+
147
+ /** Configured state of one ref without ever exposing the value. */
148
+ async function keyState(ctx, ref) {
149
+ try {
150
+ const info = await service(ctx, 'credentials')?.describe?.(ref)
151
+ if (info) return info.configured ? `set (${info.source ?? 'store'})` : 'unset'
152
+ } catch { /* fall through to env */ }
153
+ return process.env[ref] ? 'set (environment)' : 'unset'
154
+ }
155
+
156
+ /** The two key-state lines shared by /keys (no args) and /status. */
157
+ async function keySummary(ctx, refs) {
158
+ const [hf, ax] = await Promise.all([keyState(ctx, refs.hfTokenEnv), keyState(ctx, refs.alphaxivTokenEnv)])
159
+ return [
160
+ `Hugging Face key (${refs.hfTokenEnv}): ${hf}`,
161
+ `AlphaXiv key (${refs.alphaxivTokenEnv}): ${ax}`,
162
+ ]
163
+ }
164
+
165
+ async function keysHandler(invocation, ctx, state) {
166
+ const args = invocation.rawInput.trim().split(/\s+/).filter(Boolean)
167
+ const creds = service(ctx, 'credentials')
168
+ const refs = liveRefs(state)
169
+ if (args.length === 0) {
170
+ return {
171
+ kind: 'success',
172
+ text: [
173
+ ...await keySummary(ctx, refs),
174
+ `Config card: ${cardState(ctx)}`,
175
+ '',
176
+ `Usage: /feynman keys set <hf|alphaxiv> <value> stores the key in the managed credentials file.`,
177
+ `Or export ${refs.hfTokenEnv} / ${refs.alphaxivTokenEnv} before launch; env shadows the store.`,
178
+ ].join('\n'),
179
+ }
180
+ }
181
+ if (args.length < 3 || args[0].toLowerCase() !== 'set') {
182
+ return { kind: 'error', text: 'Usage: /feynman keys | /feynman keys set <hf|alphaxiv> <value>' }
183
+ }
184
+ const which = args[1].toLowerCase()
185
+ const ref = which === 'hf' ? refs.hfTokenEnv : which === 'alphaxiv' ? refs.alphaxivTokenEnv : undefined
186
+ if (ref === undefined) return { kind: 'error', text: 'Usage: /feynman keys set <hf|alphaxiv> <value>' }
187
+ const value = args.slice(2).join(' ')
188
+ if (creds?.set === undefined) {
189
+ return { kind: 'error', text: `Credentials store is not mounted; export ${ref}=… before launch instead.` }
190
+ }
191
+ return creds.set(ref, value).then(
192
+ () => ({ kind: 'success', text: `${which === 'hf' ? 'Hugging Face' : 'AlphaXiv'} key stored (${ref}). Values are never echoed.` }),
193
+ (error) => ({ kind: 'error', text: `Could not store key: ${error instanceof Error ? error.message : String(error)}` }),
194
+ )
195
+ }
196
+
197
+ // --- commands ---
198
+
199
+ function err(text) { return { kind: 'error', text } }
200
+
201
+ /**
202
+ * Single dispatcher behind `/feynman`. The first token names a workflow or
203
+ * session subcommand; the rest is that subcommand's raw input. Attachments
204
+ * ride along on the sub-invocation.
205
+ */
206
+ function researchHandler(invocation, ctx, sessionHandlers, state) {
207
+ const [, sub = '', rest = ''] = invocation.rawInput.trim().match(/^(\S+)(?:\s+([\s\S]*))?$/) ?? []
208
+ const name = sub.toLowerCase()
209
+ // The registry admits attachments for the whole command; a subcommand that
210
+ // queues no message must refuse them so the composer keeps the originals.
211
+ if (invocation.attachments.length > 0 && !queuesMessage(name, rest)) {
212
+ return err(`/feynman ${name || '(bare)'} does not use attachments; attach them to a workflow, log, init, outputs, or btw.`)
213
+ }
214
+ if (!name) return helpHandler({ ...invocation, rawInput: '' })
215
+ const subInvocation = { ...invocation, rawInput: rest }
216
+ if (WORKFLOWS[name]) return workflowHandler(name, subInvocation, state)
217
+ const session = sessionHandlers[name]
218
+ if (session) return session(subInvocation, ctx)
219
+ return err(`Unknown subcommand "${sub}". Usage: /feynman <${[...Object.keys(WORKFLOWS), ...Object.keys(SESSION_COMMANDS)].join(' | ')}>.`)
220
+ }
221
+
222
+ /** Session subcommands whose handler sends the invocation's attachments to the model. */
223
+ const MESSAGE_SESSION_COMMANDS = new Set(['log', 'init', 'outputs', 'btw'])
224
+
225
+ /** Whether `/feynman <name> <rest>` carries its attachments into a queued message. */
226
+ function queuesMessage(name, rest) {
227
+ if (WORKFLOWS[name]) return !(name === 'review-loop' && rest.toLowerCase() === 'stop')
228
+ return MESSAGE_SESSION_COMMANDS.has(name)
229
+ }
230
+
231
+ /** Flag-parsing workflows: the parsed object replaces the raw argument text. */
232
+ const PARSERS = { rank: parseRankArgs, paper: parsePaperArgs }
233
+
234
+ function workflowHandler(kind, invocation, state) {
235
+ const spec = WORKFLOWS[kind]
236
+ const args = invocation.rawInput.trim()
237
+ const usage = `Usage: /feynman ${kind} ${spec.hint}`
238
+ if (!spec.optional && !args) return err(usage)
239
+ if (kind === 'review-loop' && args.toLowerCase() === 'stop') {
240
+ const loop = state.loops.get(invocation.agent.session.id)
241
+ if (!loop) return { kind: 'success', text: 'No review loop is running.' }
242
+ state.loops.delete(invocation.agent.session.id)
243
+ return { kind: 'success', text: `Review loop for "${loop.target}" stopped after ${loop.round - 1} round(s).` }
244
+ }
245
+ // Queue the workflow brief as the agent's next turn; the followup IS the work.
246
+ // Rank and paper parse every flag; unknown --flags are rejected, never absorbed into the topic.
247
+ const parse = PARSERS[kind]
248
+ if (parse) {
249
+ const parsed = parse(args)
250
+ if (!(parsed.topic || parsed.id)) return err(usage)
251
+ if (parsed.unsupported.length > 0) {
252
+ return err(`Unsupported flag(s): ${parsed.unsupported.join(', ')}. See \`Usage: /feynman ${kind} ${spec.hint}\`.`)
253
+ }
254
+ const body = buildPrompt(kind, parsed, liveRefs(state))
255
+ invocation.agent.followup(userMessage(invocation, `${kind}: ${args}\n\n${body}`))
256
+ return { kind: 'success', text: `/feynman ${kind} workflow started. Output lands in ${parsed.outputDir ?? 'outputs'}/.` }
257
+ }
258
+ const body = kind === 'review-loop'
259
+ ? reviewLoopPrompt(invocation, args, state)
260
+ : buildPrompt(kind, args, liveRefs(state))
261
+ if (body === null) return err(usage)
262
+ const message = userMessage(invocation, `${kind}: ${args}\n\n${body}`)
263
+ invocation.agent.followup(message)
264
+ // The round's turn is the one that appends this exact message.
265
+ if (kind === 'review-loop') state.loops.get(invocation.agent.session.id).pending = message.id
266
+ return { kind: 'success', text: `/feynman ${kind} workflow started. Output lands in outputs/.` }
267
+ }
268
+
269
+ function reviewLoopPrompt(invocation, args, state) {
270
+ const { target, rounds } = parseLoopArgs(args)
271
+ if (!target) return null
272
+ // `pending`: id of the queued round message; `open`: its turn has started.
273
+ state.loops.set(invocation.agent.session.id, { target, rounds, round: 1, pending: undefined, open: false })
274
+ const brief = buildPrompt('review-loop', target, liveRefs(state))
275
+ return `${brief}\n\nRound 1 of ${rounds}; follow-ups will drive re-review.`
276
+ }
277
+
278
+ /**
279
+ * Frozen user message for the queued follow-up. Built through the llm seam so
280
+ * identity branding, role, and deep-frozen content match every other producer;
281
+ * the queued message is handed to followup()/inject() and must not be mutated.
282
+ */
283
+ function userMessage(invocation, text) {
284
+ const content = [...invocation.attachments, { type: 'text', text }]
285
+ return createUserMessage({
286
+ content,
287
+ // Producer source, not forged 'user': title/outline/activity consumers gate human input on kind === 'user'.
288
+ source: { kind: 'feynman', form: 'relay' },
289
+ })
290
+ }
291
+
292
+ function logHandler(invocation) {
293
+ return followupHandler(invocation,
294
+ 'Write a durable session log for this session: completed work, findings, open questions, and next steps.',
295
+ 'Session-log request queued as the next turn.')
296
+ }
297
+
298
+ function jobsHandler(invocation, ctx) {
299
+ const lines = []
300
+ const registry = service(ctx, 'jobs')
301
+ if (typeof registry?.list !== 'function') {
302
+ lines.push('Job state is unavailable: the jobs seam is not mounted in this profile.')
303
+ } else {
304
+ try {
305
+ const jobs = registry.list(invocation.agent.id)
306
+ lines.push(jobs.length ? `Background jobs (${jobs.length}):` : 'No background jobs.')
307
+ for (const job of jobs.slice(0, 20)) lines.push(`- ${job.id}: ${job.label} [${job.status}]`)
308
+ } catch (error) {
309
+ lines.push(`Job state is unavailable: ${error instanceof Error ? error.message : String(error)}`)
310
+ }
311
+ }
312
+ lines.push('Durable artifacts: outputs/<slug>-baseline.md (watch), autoresearch.md + autoresearch.jsonl (autoresearch).')
313
+ return { kind: 'success', text: lines.join('\n') }
314
+ }
315
+
316
+ async function doctorHandler(invocation, ctx, state) {
317
+ if (invocation.rawInput.trim()) return err('Usage: /feynman doctor (no arguments)')
318
+ const refs = liveRefs(state)
319
+ const lines = ['Feynman diagnostics:']
320
+ for (const [label, ref] of [['Hugging Face', refs.hfTokenEnv], ['AlphaXiv', refs.alphaxivTokenEnv]]) {
321
+ lines.push(`- ${label} key (${ref}): ${await keyState(ctx, ref)}`)
322
+ }
323
+ const present = (key) => service(ctx, key) !== undefined && service(ctx, key) !== null
324
+ for (const [label, key] of [['credentials store', 'credentials'], ['settings (config card)', 'settings'],
325
+ ['jobs', 'jobs'], ['session search', 'sessionQuery'], ['scheduler', 'schedule']]) {
326
+ lines.push(`- ${label}: ${present(key) ? 'mounted' : 'absent (related commands degrade to an error or guidance text)'}`)
327
+ }
328
+ try {
329
+ const { execFileSync } = await import('node:child_process')
330
+ execFileSync('pandoc', ['--version'], { stdio: 'ignore' })
331
+ lines.push('- pandoc: installed (`/feynman preview` can render)')
332
+ } catch {
333
+ lines.push('- pandoc: NOT found (`/feynman preview` will give the install command)')
334
+ }
335
+ lines.push(`- Config card: ${cardState(ctx)}`)
336
+ return { kind: 'success', text: lines.join('\n') }
337
+ }
338
+
339
+ async function statusHandler(invocation, ctx, state) {
340
+ if (invocation.rawInput.trim()) return err('Usage: /feynman status (no arguments)')
341
+ const refs = liveRefs(state)
342
+ return {
343
+ kind: 'success',
344
+ text: [
345
+ 'Feynman setup summary:',
346
+ ...(await keySummary(ctx, refs)).map((line) => `- ${line}`),
347
+ `- Key refs: hfTokenEnv=${refs.hfTokenEnv} alphaxivTokenEnv=${refs.alphaxivTokenEnv} (row config; invalid names fail at load)`,
348
+ '- Model route: one profile route (see the agent-default-model row); per-turn overrides are not supported here.',
349
+ `- Thinking levels: ${THINKING_LEVELS.join(', ')} (note a level with /feynman thinking <level>).`,
350
+ '- For the full checklist run /feynman doctor.',
351
+ ].join('\n'),
352
+ }
353
+ }
354
+
355
+ function helpHandler() {
356
+ const lines = ['Research workflows (`/feynman <subcommand>`):']
357
+ for (const n of Object.keys(WORKFLOWS)) lines.push(` ${n} ${WORKFLOWS[n].hint}: ${WORKFLOWS[n].description}`)
358
+ lines.push('Session (`/feynman <subcommand>`):')
359
+ for (const n of Object.keys(SESSION_COMMANDS)) lines.push(` ${n}: ${SESSION_COMMANDS[n]}`)
360
+ lines.push('', 'Tip: /feynman review-loop <artifact> [rounds] iterates review→fix→re-review; /feynman review-loop stop ends it.')
361
+ return { kind: 'success', text: lines.join('\n') }
362
+ }
363
+
364
+ /** Queue a model turn from a prompt template; the Focus suffix is shared. */
365
+ function followupHandler(invocation, prompt, ack) {
366
+ const focus = invocation.rawInput.trim()
367
+ invocation.agent.followup(userMessage(invocation, focus ? `${prompt} Focus: ${focus}` : prompt))
368
+ return { kind: 'success', text: ack }
369
+ }
370
+
371
+ function initHandler(invocation) {
372
+ return followupHandler(invocation,
373
+ 'Bootstrap check for a research project: ensure AGENTS.md exists and outputs/.plans/ plus outputs/.drafts/ directories exist in the workspace, creating what is missing (ask before overwriting an existing AGENTS.md). Report what was created vs already present.',
374
+ 'Project bootstrap queued as the next turn.')
375
+ }
376
+
377
+ function outputsHandler(invocation) {
378
+ return followupHandler(invocation,
379
+ 'List the research artifacts under outputs/ (group by workflow: *-brief.md deepresearch, *-lit-review.md lit, *-review.md review, *-audit.md audit, *-replication-plan.md replicate, *-recipe.md recipe, *-compare.md compare, *-draft.md draft, *-paper-rank.md + *-research-run.json + *-papers.jsonl + *-scores.jsonl + *-score-audit.md + *-citation-graph.json + *-graph-explorer.html + *-field-map.json + *-rank-sensitivity.json + *-rank.provenance.md rank, *-paper-access.md + *-paper-access.json paper, *-baseline.md watch). Summarize what each contains in one line.',
380
+ 'Artifact listing queued as the next turn.')
381
+ }
382
+
383
+ function btwHandler(invocation) {
384
+ const q = invocation.rawInput.trim()
385
+ if (!q) return err('Usage: /feynman btw <question>')
386
+ // Non-waking context: visible at the next step boundary without hijacking the running turn.
387
+ invocation.agent.inject(userMessage(invocation, `Side question (answer when convenient, main task first): ${q}`))
388
+ return { kind: 'success', text: 'Side question noted as context; the main turn continues undisturbed.' }
389
+ }
390
+
391
+ function thinkingHandler(invocation) {
392
+ const level = invocation.rawInput.trim().toLowerCase()
393
+ if (!level) return { kind: 'success', text: `Thinking levels: ${THINKING_LEVELS.join(', ')}.` }
394
+ if (!THINKING_LEVELS.includes(level)) return err(`Unknown thinking level "${level}". Levels: ${THINKING_LEVELS.join(', ')}.`)
395
+ invocation.agent.inject(userMessage(invocation, `Reasoning effort preference for upcoming requests: ${level}. Apply if the route supports it.`))
396
+ return { kind: 'success', text: `Thinking level noted: ${level}. Applies where the model route supports it.` }
397
+ }
398
+
399
+ /**
400
+ * Run the full-text search when the session-query seam is mounted, so the
401
+ * result is the search itself. An unmounted seam is an error, never a success
402
+ * that claims work happened.
403
+ */
404
+ async function searchHandler(invocation, ctx) {
405
+ const q = invocation.rawInput.trim()
406
+ if (!q) return err('Usage: /feynman search <query>')
407
+ const engine = service(ctx, 'sessionQuery')
408
+ if (typeof engine?.searchSessions !== 'function') {
409
+ return err(`Full-text search is not mounted in this profile (session-query seam).` +
410
+ ` Grep this session's history instead: rg -n "${q.replaceAll('"', "'")}" <session-dir>.`)
411
+ }
412
+ let page
413
+ try {
414
+ page = await engine.searchSessions({ query: q, limit: 10 })
415
+ } catch (error) {
416
+ return err(`Session search failed: ${error instanceof Error ? error.message : String(error)}`)
417
+ }
418
+ const hits = Array.isArray(page?.items) ? page.items : []
419
+ if (hits.length === 0) return { kind: 'success', text: `No past sessions match "${q}".` }
420
+ const lines = [`Past sessions matching "${q}" (${hits.length}):`]
421
+ for (const hit of hits) {
422
+ const raw = typeof hit?.bestMatch?.snippet === 'string' ? hit.bestMatch.snippet.replace(/\s+/g, ' ').trim() : ''
423
+ lines.push(`- ${hit?.header?.id ?? 'unknown'}: ${raw.length > 160 ? `${raw.slice(0, 157)}…` : raw || '(no excerpt)'}`)
424
+ }
425
+ if (page.nextCursor !== undefined) lines.push('More matches exist; narrow the query to see them.')
426
+ return { kind: 'success', text: lines.join('\n') }
427
+ }
428
+
429
+ export function apply(ctx, config = {}) {
430
+ const state = createState(config)
431
+
432
+ ctx.effect(() => {
433
+ const disposers = []
434
+ const sessionHandlers = {
435
+ log: logHandler, jobs: jobsHandler, help: helpHandler,
436
+ 'feynman-model': () => ({
437
+ kind: 'success',
438
+ text: 'This profile runs one model route (see the agent-default-model row in the composed config). To change it, edit the profile patch or settings; per-turn model overrides are not supported here.',
439
+ }),
440
+ init: initHandler, outputs: outputsHandler, btw: btwHandler, thinking: thinkingHandler,
441
+ search: searchHandler,
442
+ 'web-results': () => ({
443
+ kind: 'success',
444
+ text: 'Stored web results live in this session log (web_search/web_fetch tool calls). Ask me to summarize the sources fetched so far and I will reconstruct them from history.',
445
+ }),
446
+ keys: (inv, context) => keysHandler(inv, context, state),
447
+ doctor: (inv, context) => doctorHandler(inv, context, state),
448
+ status: (inv, context) => statusHandler(inv, context, state),
449
+ }
450
+ // One top-level name; everything else rides `feynman <subcommand>`.
451
+ // Bare generic names (log, jobs, help, …) belong to the host or the user.
452
+ disposers.push(ctx.commands.register({
453
+ definitionId: 'dsh-feynman:feynman',
454
+ name: 'feynman',
455
+ description: '⟁ Research workflows and session utilities (subcommands: workflow names plus log, jobs, help, init, outputs, btw, thinking, search, web-results, keys, doctor, status)',
456
+ input: { hint: '<workflow | subcommand> [args]', attachments: true },
457
+ recordInput: false,
458
+ handler: (inv) => researchHandler(inv, ctx, sessionHandlers, state),
459
+ }))
460
+ // /review-loop driver. A round is the turn that appends the loop's own
461
+ // queued message as `user/message` (the agent loop records a claimed
462
+ // follow-up that way in the turn it opens), so a turn already running at
463
+ // dispatch, or a human turn in between, never counts. When that turn
464
+ // completes the next round is queued; any other end (error, abort,
465
+ // blocked, max-tokens) spends the round's message and ends the loop.
466
+ // Keyed by session id: the registry keys agents by session id.
467
+ const offTurn = ctx.on('session/event', (session, event) => {
468
+ const loop = state.loops.get(session.id)
469
+ if (loop === undefined) return
470
+ if (event?.type === 'user/message') {
471
+ if (event.data?.id === loop.pending) loop.open = true
472
+ return
473
+ }
474
+ if (event?.type !== 'turn/end' || !loop.open) return
475
+ loop.open = false
476
+ // `agents` is injected, so the service is present; ctx.get is for the
477
+ // optional seams only. The identity guard matches upstream
478
+ // (api/session-controller): the emitted session IS agent.session.
479
+ const agent = ctx.agents.get(session.id)
480
+ if (event.data?.reason?.kind !== 'completed' || loop.round >= loop.rounds
481
+ || !agent || agent.session !== session) {
482
+ state.loops.delete(session.id)
483
+ return
484
+ }
485
+ loop.round += 1
486
+ const message = userMessage({ attachments: [], agent }, loopFollowupPrompt(loop.target, loop.round, loop.rounds - loop.round))
487
+ loop.pending = message.id
488
+ agent.followup(message)
489
+ })
490
+ // A session that goes away mid-loop takes its loop state with it.
491
+ const offDisposed = ctx.on('session/disposed', (session) => { state.loops.delete(session.id) })
492
+ return () => { offTurn?.(); offDisposed?.(); for (const d of disposers) d() }
493
+ })
494
+ }