@north-light/crouter 0.3.263 → 0.3.269

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 (116) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/nodes.d.ts +6 -2
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  12. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  13. package/dist/clients/attach/session/whip-achievements.js +145 -0
  14. package/dist/clients/attach/slash/dispatch.js +5 -3
  15. package/dist/clients/attach/viewer.js +607 -606
  16. package/dist/commands/canvas-history/grep.js +3 -22
  17. package/dist/commands/canvas-history/read.js +5 -3
  18. package/dist/commands/canvas-history/search.js +3 -22
  19. package/dist/commands/canvas-history/shared.d.ts +8 -0
  20. package/dist/commands/canvas-history/shared.js +64 -1
  21. package/dist/commands/canvas-history/stats.d.ts +1 -0
  22. package/dist/commands/canvas-history/stats.js +68 -0
  23. package/dist/commands/canvas-history.js +4 -3
  24. package/dist/commands/node/create.js +2 -2
  25. package/dist/commands/sys/config.js +19 -2
  26. package/dist/commands/sys/panels/models-panel.js +3 -1
  27. package/dist/core/__tests__/integration/revive.test.js +4 -2
  28. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  29. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  30. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  31. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  32. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  33. package/dist/core/broker-client/transport-relay.js +5 -1
  34. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  36. package/dist/core/canvas/browse/app.d.ts +1 -1
  37. package/dist/core/canvas/browse/app.js +13 -10
  38. package/dist/core/canvas/browse/model.js +16 -1
  39. package/dist/core/canvas/browse/render.d.ts +23 -3
  40. package/dist/core/canvas/browse/render.js +429 -64
  41. package/dist/core/canvas/canvas.js +12 -8
  42. package/dist/core/canvas/extensions.d.ts +1 -1
  43. package/dist/core/canvas/extensions.js +7 -1
  44. package/dist/core/canvas/history.d.ts +36 -1
  45. package/dist/core/canvas/history.js +328 -7
  46. package/dist/core/canvas/labels.d.ts +2 -2
  47. package/dist/core/canvas/migrations.js +32 -0
  48. package/dist/core/canvas/node-recap.d.ts +23 -0
  49. package/dist/core/canvas/node-recap.js +67 -0
  50. package/dist/core/canvas/render-source.d.ts +33 -1
  51. package/dist/core/canvas/render-source.js +80 -22
  52. package/dist/core/canvas/types.d.ts +9 -0
  53. package/dist/core/config.js +13 -5
  54. package/dist/core/keybindings/catalog.d.ts +2 -2
  55. package/dist/core/keybindings/catalog.js +2 -1
  56. package/dist/core/model-routes.js +1 -1
  57. package/dist/core/runtime/broker/auth-reload.js +4 -1
  58. package/dist/core/runtime/broker/fault-retry.js +7 -2
  59. package/dist/core/runtime/broker/frame-client.js +4 -0
  60. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  61. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  62. package/dist/core/runtime/broker/inbox.js +68 -38
  63. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  64. package/dist/core/runtime/broker.js +14 -0
  65. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  66. package/dist/core/runtime/canvas-extensions.js +2 -0
  67. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  68. package/dist/core/runtime/interactive-deliver.js +12 -3
  69. package/dist/core/runtime/launch-target.js +1 -1
  70. package/dist/core/runtime/launch.d.ts +12 -6
  71. package/dist/core/runtime/launch.js +19 -17
  72. package/dist/core/runtime/model-swap.d.ts +5 -4
  73. package/dist/core/runtime/model-swap.js +7 -5
  74. package/dist/core/runtime/promote.js +2 -1
  75. package/dist/core/runtime/reset.js +4 -2
  76. package/dist/core/runtime/revive.js +22 -0
  77. package/dist/core/runtime/spawn.d.ts +3 -3
  78. package/dist/core/runtime/spawn.js +20 -3
  79. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  80. package/dist/core/runtime/stamp/channel.js +74 -0
  81. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  82. package/dist/core/runtime/stamp/protocol.js +113 -0
  83. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  84. package/dist/core/runtime/tmux-driver.js +27 -11
  85. package/dist/core/self-update.js +2 -5
  86. package/dist/core/tui/draw.d.ts +1 -0
  87. package/dist/core/tui/draw.js +5 -1
  88. package/dist/core/tui/markdown.d.ts +17 -0
  89. package/dist/core/tui/markdown.js +407 -0
  90. package/dist/core/user-settings.d.ts +4 -0
  91. package/dist/core/user-settings.js +1 -0
  92. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  93. package/dist/daemon/api/bridge.d.ts +3 -2
  94. package/dist/daemon/api/bridge.js +34 -9
  95. package/dist/daemon/api/handlers/canvas.js +62 -16
  96. package/dist/daemon/api/handlers/messages.js +12 -1
  97. package/dist/daemon/api/handlers/nodes.js +10 -3
  98. package/dist/daemon/api/map.js +2 -0
  99. package/dist/daemon/messaging/node-message.js +15 -1
  100. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  104. package/dist/pi-extensions/canvas-recap.js +4 -0
  105. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  106. package/dist/pi-extensions/canvas-stamp.js +77 -0
  107. package/dist/pi-extensions/canvas-stophook.js +3 -0
  108. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  109. package/dist/shared/crtr-version.d.ts +2 -0
  110. package/dist/shared/crtr-version.js +18 -0
  111. package/dist/shared/env.d.ts +10 -0
  112. package/dist/shared/env.js +19 -4
  113. package/dist/types.d.ts +10 -0
  114. package/dist/types.js +4 -0
  115. package/package.json +1 -1
  116. package/runtime.lock.json +2 -2
@@ -83,14 +83,15 @@ function writeMeta(meta) {
83
83
  * state. A fresh insert takes the schema defaults for runtime. */
84
84
  function upsertRow(meta) {
85
85
  openDb()
86
- .prepare(`INSERT INTO nodes (node_id, name, kind, mode, lifecycle, cwd, host_kind, profile_id, parent, created, pi_session_file)
87
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
86
+ .prepare(`INSERT INTO nodes (node_id, name, description, cycles, kind, mode, lifecycle, cwd, host_kind, profile_id, parent, created, pi_session_file)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
88
  ON CONFLICT(node_id) DO UPDATE SET
89
- name=excluded.name, kind=excluded.kind, mode=excluded.mode,
89
+ name=excluded.name, description=excluded.description, cycles=excluded.cycles,
90
+ kind=excluded.kind, mode=excluded.mode,
90
91
  lifecycle=excluded.lifecycle, cwd=excluded.cwd, host_kind=excluded.host_kind,
91
92
  profile_id=excluded.profile_id, parent=excluded.parent,
92
93
  pi_session_file=excluded.pi_session_file`)
93
- .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null);
94
+ .run(meta.node_id, meta.name, meta.description ?? null, meta.cycles ?? null, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null);
94
95
  }
95
96
  /** Seed a node's row at BIRTH: identity columns + runtime columns taken from the
96
97
  * incoming meta (defaults: status='active', the rest null). The only writer
@@ -99,11 +100,12 @@ function upsertRow(meta) {
99
100
  function seedRow(meta) {
100
101
  openDb()
101
102
  .prepare(`INSERT INTO nodes
102
- (node_id, name, kind, mode, lifecycle, cwd, host_kind, profile_id, parent, created, pi_session_file,
103
+ (node_id, name, description, cycles, kind, mode, lifecycle, cwd, host_kind, profile_id, parent, created, pi_session_file,
103
104
  status, intent, waiting_for, final_report, finalized_at, pi_pid, "window", tmux_session, pane)
104
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
105
106
  ON CONFLICT(node_id) DO UPDATE SET
106
- name=excluded.name, kind=excluded.kind, mode=excluded.mode,
107
+ name=excluded.name, description=excluded.description, cycles=excluded.cycles,
108
+ kind=excluded.kind, mode=excluded.mode,
107
109
  lifecycle=excluded.lifecycle, cwd=excluded.cwd, host_kind=excluded.host_kind,
108
110
  profile_id=excluded.profile_id, parent=excluded.parent,
109
111
  pi_session_file=excluded.pi_session_file,
@@ -111,12 +113,14 @@ function seedRow(meta) {
111
113
  final_report=excluded.final_report, finalized_at=excluded.finalized_at,
112
114
  pi_pid=excluded.pi_pid, "window"=excluded."window", tmux_session=excluded.tmux_session,
113
115
  pane=excluded.pane`)
114
- .run(meta.node_id, meta.name, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null, meta.status ?? 'active', meta.intent ?? null, meta.waiting_for ?? null, meta.final_report ?? null, meta.finalized_at ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
116
+ .run(meta.node_id, meta.name, meta.description ?? null, meta.cycles ?? null, meta.kind, meta.mode, meta.lifecycle, meta.cwd, meta.host_kind ?? null, meta.profile_id ?? null, meta.parent ?? null, meta.created, typeof meta.pi_session_file === 'string' ? canonicalSessionFile(meta.pi_session_file) : null, meta.status ?? 'active', meta.intent ?? null, meta.waiting_for ?? null, meta.final_report ?? null, meta.finalized_at ?? null, meta.pi_pid ?? null, meta.window ?? null, meta.tmux_session ?? null, meta.pane ?? null);
115
117
  }
116
118
  function rowFrom(r) {
117
119
  return {
118
120
  node_id: r['node_id'],
119
121
  name: r['name'],
122
+ description: r['description'] ?? undefined,
123
+ cycles: r['cycles'] ?? undefined,
120
124
  kind: r['kind'],
121
125
  mode: r['mode'],
122
126
  lifecycle: r['lifecycle'],
@@ -1,4 +1,4 @@
1
- export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve", "canvas-preview-result", "canvas-prompt-scrub"];
1
+ export declare const CANVAS_EXTENSION_NAMES: readonly ["canvas-inbox-watcher", "canvas-review-boundary", "canvas-stophook", "canvas-recap", "canvas-goal-capture", "canvas-passive-context", "canvas-context-intro", "canvas-tool-guide", "canvas-doc-substrate", "canvas-structured-output", "canvas-bash-valve", "canvas-preview-result", "canvas-prompt-scrub", "canvas-stamp"];
2
2
  /** Structural shipped-hook identity: a canvas-hook stem directly under a
3
3
  * `pi-extensions` directory. A user's own extension elsewhere may share the
4
4
  * stem and is not shipped. */
@@ -7,9 +7,15 @@ export const CANVAS_EXTENSION_NAMES = [
7
7
  'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
8
8
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
9
9
  'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
10
- 'canvas-bash-valve', 'canvas-preview-result', 'canvas-prompt-scrub',
10
+ 'canvas-bash-valve', 'canvas-preview-result', 'canvas-prompt-scrub', 'canvas-stamp',
11
11
  ];
12
12
  const HISTORICAL_CANVAS_EXTENSION_PREFIXES = [
13
+ [
14
+ 'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
15
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
16
+ 'canvas-tool-guide', 'canvas-doc-substrate', 'canvas-structured-output',
17
+ 'canvas-bash-valve', 'canvas-preview-result', 'canvas-prompt-scrub',
18
+ ],
13
19
  [
14
20
  'canvas-inbox-watcher', 'canvas-review-boundary', 'canvas-stophook', 'canvas-recap',
15
21
  'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
@@ -1,5 +1,13 @@
1
1
  import type { NodeStatus } from './types.js';
2
- export type HistorySource = 'report' | 'doc' | 'roadmap' | 'meta' | 'inbox';
2
+ export type HistorySource = 'report' | 'doc' | 'roadmap' | 'meta' | 'inbox' | 'transcript';
3
+ /** Every origin token `--origin` accepts. `human` is a person at a front door;
4
+ * the rest mirror the runtime origin kinds; `unknown` is a message that
5
+ * carries no stamp at all — its own bucket, never folded into another, since
6
+ * a rising unknown rate is how a delivery seam that stopped announcing shows
7
+ * itself. */
8
+ export declare const ORIGIN_TOKENS: readonly ["human", "inbox", "wake", "revive", "restart-continuation", "stop-guard", "park", "recovery", "context-nudge", "kickoff", "unknown"];
9
+ /** Message roles carried by a transcript artifact. */
10
+ export declare const MESSAGE_ROLES: readonly ["user", "assistant", "toolResult"];
3
11
  /** One searchable unit of the episodic record: a report, a context doc, a
4
12
  * node's roadmap, or a node's meta (name/description/kind). */
5
13
  export interface HistoryArtifact {
@@ -26,6 +34,16 @@ export interface HistoryArtifact {
26
34
  path: string | null;
27
35
  /** Frontmatter-stripped body, loaded once and cached. Empty on read error. */
28
36
  loadBody: () => string;
37
+ /** What drove this message to arrive. Set ONLY on inbound artifacts — a
38
+ * user-role transcript message or an inbox entry — because origin is a
39
+ * property of arrival. An assistant turn leaves it undefined, so `--origin`
40
+ * excludes generated turns without needing a role filter beside it. */
41
+ origin?: string;
42
+ /** Transcript only: user | assistant | toolResult. */
43
+ role?: string;
44
+ /** Sender attribution: an inbox stamp's delivered entries, or an inbox
45
+ * entry's own sender. Comma-joined when a delivery carried several. */
46
+ from?: string;
29
47
  }
30
48
  export interface ScopeFilter {
31
49
  /** A specific cwd. Default (all flags absent) = the caller's cwd. */
@@ -48,6 +66,12 @@ export interface CorpusFilter {
48
66
  /** Inclusive lower / upper bound on artifact ts, epoch ms. */
49
67
  sinceMs?: number;
50
68
  untilMs?: number;
69
+ /** Message-attribute narrowing — meaningful for transcript + inbox only.
70
+ * An artifact without the field never survives the corresponding filter. */
71
+ origins?: string[];
72
+ roles?: string[];
73
+ /** Case-insensitive substring against the artifact's sender attribution. */
74
+ from?: string;
51
75
  }
52
76
  /** The cwd the caller means by default: the calling node's cwd (resolved from
53
77
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
@@ -62,6 +86,17 @@ export declare function newestReportRef(nodeId: string): string | null;
62
86
  * are applied against the db rows first (cheap); only surviving nodes' files
63
87
  * are read. */
64
88
  export declare function buildCorpus(scope: ScopeFilter, corpus: CorpusFilter): HistoryArtifact[];
89
+ export type GroupKey = 'origin' | 'node' | 'kind' | 'day' | 'type' | 'role';
90
+ export interface CorpusGroup {
91
+ key: string;
92
+ count: number;
93
+ }
94
+ /** Collapse a built corpus into counts by one dimension, descending by count.
95
+ * Artifacts that do not carry the grouped dimension are omitted rather than
96
+ * bucketed as empty — grouping by origin answers "what drove arrivals", and a
97
+ * generated turn is not an arrival. `unknown` is a real origin value here, not
98
+ * a missing one. */
99
+ export declare function groupCorpus(arts: HistoryArtifact[], by: GroupKey): CorpusGroup[];
65
100
  export interface ResolvedRef {
66
101
  nodeId: string;
67
102
  relpath: string;
@@ -13,10 +13,31 @@ import { envNodeId } from '../../shared/env.js';
13
13
  import { statSync, readdirSync } from 'node:fs';
14
14
  import { join, relative, sep } from 'node:path';
15
15
  import { getNode, listNodes, view } from './canvas.js';
16
- import { contextDir, reportsDir, nodeDir, inboxPath } from './paths.js';
16
+ import { contextDir, reportsDir, nodeDir, inboxPath, sessionPtrPath } from './paths.js';
17
17
  import { readTextIfExists, walkFiles, pathExists } from '../fs-utils.js';
18
18
  import { parseFrontmatterGeneric } from '../frontmatter.js';
19
19
  import { warn } from '../output.js';
20
+ import { readStamps } from '../runtime/stamp/protocol.js';
21
+ /** Every origin token `--origin` accepts. `human` is a person at a front door;
22
+ * the rest mirror the runtime origin kinds; `unknown` is a message that
23
+ * carries no stamp at all — its own bucket, never folded into another, since
24
+ * a rising unknown rate is how a delivery seam that stopped announcing shows
25
+ * itself. */
26
+ export const ORIGIN_TOKENS = [
27
+ 'human',
28
+ 'inbox',
29
+ 'wake',
30
+ 'revive',
31
+ 'restart-continuation',
32
+ 'stop-guard',
33
+ 'park',
34
+ 'recovery',
35
+ 'context-nudge',
36
+ 'kickoff',
37
+ 'unknown',
38
+ ];
39
+ /** Message roles carried by a transcript artifact. */
40
+ export const MESSAGE_ROLES = ['user', 'assistant', 'toolResult'];
20
41
  /** The cwd the caller means by default: the calling node's cwd (resolved from
21
42
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
22
43
  * when not run as a node. */
@@ -184,7 +205,10 @@ function inboxEntryBody(e) {
184
205
  function inboxArtifact(row, desc, entry, index) {
185
206
  const relpath = `inbox/${String(index).padStart(6, '0')}`;
186
207
  const body = inboxEntryBody(entry);
187
- return {
208
+ // An inbox entry IS an arrival, so it carries the same two inbound
209
+ // dimensions a stamped user message does — which is what lets `--origin` and
210
+ // `--from` span the transcript and inbox corpora with one vocabulary.
211
+ const a = {
188
212
  ref: `${row.node_id}:${relpath}`,
189
213
  nodeId: row.node_id,
190
214
  relpath,
@@ -199,7 +223,201 @@ function inboxArtifact(row, desc, entry, index) {
199
223
  nodeDesc: desc,
200
224
  path: null,
201
225
  loadBody: () => body,
226
+ origin: 'inbox',
227
+ };
228
+ if (typeof entry.from === 'string' && entry.from !== '')
229
+ a.from = entry.from;
230
+ return a;
231
+ }
232
+ /** The node's session `.jsonl`, or null when nothing has been written yet. pi
233
+ * binds the path at session creation but writes the file on the FIRST appended
234
+ * entry, so a fresh node has a bound path that is not yet on disk. Mirrors
235
+ * `runtime/node-read.ts`'s resolution: the recorded path first, then the
236
+ * `session.ptr` the node dir carries. */
237
+ function resolveSessionFile(nodeId) {
238
+ const node = getNode(nodeId);
239
+ const recorded = node?.pi_session_file;
240
+ if (typeof recorded === 'string' && recorded !== '' && pathExists(recorded))
241
+ return recorded;
242
+ const raw = readTextIfExists(sessionPtrPath(nodeId));
243
+ if (raw === null)
244
+ return null;
245
+ const target = raw.trim();
246
+ return target !== '' && pathExists(target) ? target : null;
247
+ }
248
+ /** The searchable text of a message's content blocks. `thinking` blocks are
249
+ * deliberately dropped: private model reasoning is excluded from every human
250
+ * transcript surface, and putting it in the search corpus would leak it back
251
+ * in through grep. */
252
+ function textFromBlocks(content) {
253
+ if (typeof content === 'string')
254
+ return content;
255
+ if (!Array.isArray(content))
256
+ return '';
257
+ const parts = [];
258
+ for (const block of content) {
259
+ if (block === null || typeof block !== 'object')
260
+ continue;
261
+ const b = block;
262
+ if (b['type'] === 'text' && typeof b['text'] === 'string')
263
+ parts.push(b['text']);
264
+ else if (b['type'] === 'toolCall')
265
+ parts.push(`Tool: ${typeof b['name'] === 'string' ? b['name'] : 'call'}`);
266
+ }
267
+ return parts.join('\n');
268
+ }
269
+ /** The single token `--origin` matches against. */
270
+ function originToken(origin) {
271
+ if (origin === null || origin === undefined)
272
+ return 'unknown';
273
+ return origin.type === 'human' ? 'human' : origin.kind;
274
+ }
275
+ /** Who a delivery came from, comma-joined when it carried several entries. */
276
+ function sendersOf(origin) {
277
+ if (origin === null || origin === undefined || origin.type !== 'runtime')
278
+ return undefined;
279
+ const entries = origin.entries;
280
+ if (entries === undefined || entries.length === 0)
281
+ return undefined;
282
+ const names = new Set();
283
+ for (const e of entries) {
284
+ const who = e.fromName ?? e.from;
285
+ if (typeof who === 'string' && who !== '')
286
+ names.add(who);
287
+ }
288
+ return names.size === 0 ? undefined : [...names].join(', ');
289
+ }
290
+ /** Parse a node's session file into its messages, each carrying the origin its
291
+ * stamp recorded. Deliberately a direct line parse rather than pi's
292
+ * SessionManager: reconstruction copies the file to a temp dir and rebuilds
293
+ * cycles, which is far too heavy to run across every node in a scan.
294
+ *
295
+ * Stamps are correlated by the target message's own `timestamp` (epoch ms) —
296
+ * never by tree position, because a stamp is physically written BEFORE the
297
+ * message it describes. */
298
+ function sessionMessages(nodeId) {
299
+ const file = resolveSessionFile(nodeId);
300
+ if (file === null)
301
+ return [];
302
+ const raw = readTextIfExists(file);
303
+ if (raw === null)
304
+ return [];
305
+ const entries = [];
306
+ for (const line of raw.split('\n')) {
307
+ if (line.trim() === '')
308
+ continue;
309
+ try {
310
+ entries.push(JSON.parse(line));
311
+ }
312
+ catch {
313
+ // A truncated final line is normal while a broker is mid-append.
314
+ }
315
+ }
316
+ const origins = new Map();
317
+ for (const stamp of readStamps(entries)) {
318
+ if (stamp.kind !== 'message')
319
+ continue;
320
+ const payload = stamp.payload;
321
+ if (typeof payload.msgTs !== 'number')
322
+ continue;
323
+ origins.set(payload.msgTs, payload.origin);
324
+ }
325
+ const out = [];
326
+ for (const entry of entries) {
327
+ if (entry === null || typeof entry !== 'object')
328
+ continue;
329
+ const e = entry;
330
+ if (e['type'] !== 'message')
331
+ continue;
332
+ const message = e['message'];
333
+ if (message === null || typeof message !== 'object')
334
+ continue;
335
+ const m = message;
336
+ const role = typeof m['role'] === 'string' ? m['role'] : '';
337
+ if (role === '')
338
+ continue;
339
+ const msgTs = typeof m['timestamp'] === 'number' ? m['timestamp'] : undefined;
340
+ const iso = typeof e['timestamp'] === 'string'
341
+ ? e['timestamp']
342
+ : msgTs !== undefined ? new Date(msgTs).toISOString() : '';
343
+ const text = textFromBlocks(m['content']);
344
+ const msg = {
345
+ index: out.length,
346
+ role,
347
+ text,
348
+ ts: iso,
349
+ tsMs: msgTs ?? toMs(iso),
350
+ };
351
+ // Origin is a property of ARRIVAL, so only an inbound turn carries one.
352
+ if (role === 'user') {
353
+ const origin = msgTs !== undefined && origins.has(msgTs) ? origins.get(msgTs) : null;
354
+ msg.origin = originToken(origin);
355
+ const from = sendersOf(origin);
356
+ if (from !== undefined)
357
+ msg.from = from;
358
+ }
359
+ out.push(msg);
360
+ }
361
+ return out;
362
+ }
363
+ /** The verbatim jsonl line that persisted the Nth MESSAGE. Message ordinals
364
+ * and line numbers differ — stamps, model changes, and session headers occupy
365
+ * lines of their own — so this re-walks the file counting message entries
366
+ * rather than indexing lines directly. */
367
+ function rawSessionLine(file, index) {
368
+ const raw = readTextIfExists(file);
369
+ if (raw === null)
370
+ return null;
371
+ let seen = 0;
372
+ for (const line of raw.split('\n')) {
373
+ if (line.trim() === '')
374
+ continue;
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(line);
378
+ }
379
+ catch {
380
+ continue;
381
+ }
382
+ if (parsed === null || typeof parsed !== 'object')
383
+ continue;
384
+ const e = parsed;
385
+ if (e['type'] !== 'message')
386
+ continue;
387
+ const m = e['message'];
388
+ if (m === null || typeof m !== 'object' || typeof m['role'] !== 'string')
389
+ continue;
390
+ if (seen === index)
391
+ return line;
392
+ seen++;
393
+ }
394
+ return null;
395
+ }
396
+ function transcriptArtifact(row, desc, msg) {
397
+ const relpath = `session/${String(msg.index).padStart(6, '0')}`;
398
+ const lead = msg.text.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
399
+ const a = {
400
+ ref: `${row.node_id}:${relpath}`,
401
+ nodeId: row.node_id,
402
+ relpath,
403
+ source: 'transcript',
404
+ ts: msg.ts,
405
+ tsMs: msg.tsMs,
406
+ title: `[${msg.role}] ${lead.length > 80 ? `${lead.slice(0, 80)}…` : lead}`,
407
+ nodeName: row.name,
408
+ nodeKind: row.kind,
409
+ nodeStatus: row.status,
410
+ nodeCwd: row.cwd,
411
+ nodeDesc: desc,
412
+ path: null,
413
+ loadBody: () => msg.text,
414
+ role: msg.role,
202
415
  };
416
+ if (msg.origin !== undefined)
417
+ a.origin = msg.origin;
418
+ if (msg.from !== undefined)
419
+ a.from = msg.from;
420
+ return a;
203
421
  }
204
422
  function metaArtifact(row, desc) {
205
423
  const body = `${row.name}\n${desc}\n${row.kind}`;
@@ -280,9 +498,10 @@ export function buildCorpus(scope, corpus) {
280
498
  return false;
281
499
  return true;
282
500
  });
283
- // --report-kind implies type=report. `inbox` is opt-in only — never part of
284
- // the default set — since a node's raw inbox history is unbounded diagnostic
285
- // detail, not curated past work.
501
+ // --report-kind implies type=report. `inbox` and `transcript` are opt-in
502
+ // only — never part of the default set — since a node's raw inbox history and
503
+ // its full conversation are unbounded diagnostic detail, not curated past
504
+ // work.
286
505
  const wantReportKind = corpus.reportKind;
287
506
  const types = corpus.types !== undefined && corpus.types.length > 0
288
507
  ? corpus.types
@@ -335,16 +554,76 @@ export function buildCorpus(scope, corpus) {
335
554
  if (want.has('inbox')) {
336
555
  allInboxEntries(row.node_id).forEach((entry, i) => out.push(inboxArtifact(row, desc, entry, i)));
337
556
  }
557
+ if (want.has('transcript')) {
558
+ for (const msg of sessionMessages(row.node_id))
559
+ out.push(transcriptArtifact(row, desc, msg));
560
+ }
338
561
  }
339
- // Time window on artifact ts.
562
+ // Time window on artifact ts, then the message-attribute narrowing. An
563
+ // artifact that does not carry a dimension never survives that dimension's
564
+ // filter — asking for an origin excludes every generated turn by itself.
340
565
  return out.filter((a) => {
341
566
  if (corpus.sinceMs !== undefined && a.tsMs < corpus.sinceMs)
342
567
  return false;
343
568
  if (corpus.untilMs !== undefined && a.tsMs > corpus.untilMs)
344
569
  return false;
345
- return true;
570
+ return matchesMessageFilters(a, corpus);
346
571
  });
347
572
  }
573
+ /** The transcript/inbox dimensions: origin, role, sender. */
574
+ function matchesMessageFilters(a, corpus) {
575
+ const { origins, roles, from } = corpus;
576
+ if (origins !== undefined && origins.length > 0) {
577
+ if (a.origin === undefined || !origins.includes(a.origin))
578
+ return false;
579
+ }
580
+ if (roles !== undefined && roles.length > 0) {
581
+ if (a.role === undefined || !roles.includes(a.role))
582
+ return false;
583
+ }
584
+ if (from !== undefined && from !== '') {
585
+ if (a.from === undefined || !a.from.toLowerCase().includes(from.toLowerCase()))
586
+ return false;
587
+ }
588
+ return true;
589
+ }
590
+ /** Collapse a built corpus into counts by one dimension, descending by count.
591
+ * Artifacts that do not carry the grouped dimension are omitted rather than
592
+ * bucketed as empty — grouping by origin answers "what drove arrivals", and a
593
+ * generated turn is not an arrival. `unknown` is a real origin value here, not
594
+ * a missing one. */
595
+ export function groupCorpus(arts, by) {
596
+ const counts = new Map();
597
+ for (const a of arts) {
598
+ let key;
599
+ switch (by) {
600
+ case 'origin':
601
+ key = a.origin;
602
+ break;
603
+ case 'role':
604
+ key = a.role;
605
+ break;
606
+ case 'node':
607
+ key = `${a.nodeName} (${a.nodeId})`;
608
+ break;
609
+ case 'kind':
610
+ key = a.nodeKind;
611
+ break;
612
+ case 'type':
613
+ key = a.source;
614
+ break;
615
+ case 'day':
616
+ key = a.ts.slice(0, 10) || undefined;
617
+ break;
618
+ }
619
+ if (key === undefined || key === '')
620
+ continue;
621
+ counts.set(key, (counts.get(key) ?? 0) + 1);
622
+ }
623
+ return [...counts.entries()]
624
+ .map(([key, count]) => ({ key, count }))
625
+ .sort((x, y) => y.count - x.count || x.key.localeCompare(y.key));
626
+ }
348
627
  /** Resolve the scope to the node rows in play. */
349
628
  function scopedRows(scope) {
350
629
  const all = listNodes();
@@ -401,6 +680,44 @@ export function resolveRef(ref) {
401
680
  path: null,
402
681
  };
403
682
  }
683
+ // `session` addresses the whole conversation; `session/<index>` one message.
684
+ // `raw` is the verbatim record in both cases — the session file's bytes, or
685
+ // the one jsonl line that persisted that message.
686
+ if (relpath === 'session' || relpath.startsWith('session/')) {
687
+ const file = resolveSessionFile(nodeId);
688
+ if (file === null)
689
+ return null;
690
+ const messages = sessionMessages(nodeId);
691
+ if (relpath === 'session') {
692
+ const rendered = messages
693
+ .map((m) => `## ${m.role}${m.origin !== undefined ? ` (origin: ${m.origin})` : ''}\n\n${m.text}`)
694
+ .join('\n\n');
695
+ return {
696
+ nodeId,
697
+ relpath,
698
+ source: 'transcript',
699
+ ts: messages[messages.length - 1]?.ts ?? node.created,
700
+ nodeName: node.name,
701
+ body: rendered,
702
+ raw: readTextIfExists(file),
703
+ path: file,
704
+ };
705
+ }
706
+ const index = Number(relpath.slice('session/'.length));
707
+ const msg = messages[index];
708
+ if (msg === undefined)
709
+ return null;
710
+ return {
711
+ nodeId,
712
+ relpath,
713
+ source: 'transcript',
714
+ ts: msg.ts,
715
+ nodeName: node.name,
716
+ body: msg.text,
717
+ raw: rawSessionLine(file, index),
718
+ path: file,
719
+ };
720
+ }
404
721
  if (relpath.includes('..') || relpath.startsWith('/')) {
405
722
  throw new Error(`illegal relpath in ref: ${relpath}`);
406
723
  }
@@ -501,6 +818,10 @@ export function nodeArtifacts(nodeId, types) {
501
818
  if (want.has('inbox')) {
502
819
  allInboxEntries(nodeId).forEach((entry, i) => out.push(inboxArtifact(row, desc, entry, i)));
503
820
  }
821
+ if (want.has('transcript')) {
822
+ for (const msg of sessionMessages(nodeId))
823
+ out.push(transcriptArtifact(row, desc, msg));
824
+ }
504
825
  return out;
505
826
  }
506
827
  /** Bounded per-cwd aggregate for the branch `-h` `<corpus>` block. Counts files
@@ -3,8 +3,8 @@ import type { Mode, NodeMeta } from './types.js';
3
3
  * as a single figure (nf-fa-user, U+F007); an orchestrator reads as a fan-out
4
4
  * hierarchy (nf-fa-sitemap, U+F0E8). */
5
5
  export declare function modeIcon(mode: Mode): string;
6
- /** The minimal shape `fullName` needs — satisfied by NodeMeta and by anything
7
- * else carrying name + kind (description optional). */
6
+ /** The minimal shape `fullName` needs — satisfied by NodeMeta, by NodeRow, and
7
+ * by anything else carrying name + kind (description optional). */
8
8
  export interface NamedNode {
9
9
  name: string;
10
10
  kind: string;
@@ -1329,6 +1329,37 @@ END
1329
1329
  WHERE status IN ('done','canceled','dead') AND terminal_reason IS NULL;
1330
1330
  `);
1331
1331
  }
1332
+ /** v38 — mirror the two display-identity fields (`description`, `cycles`) that
1333
+ * lived ONLY in meta.json onto the row, completing the identity projection the
1334
+ * row index already is. Before this, every roster read that wanted a node's full
1335
+ * label had to open and parse each node's meta.json (a per-node disk read on a
1336
+ * path that already had all the other columns in one query). The backfill reads
1337
+ * each meta once; from here `upsertRow`/`seedRow` maintain them like any other
1338
+ * identity column, and `rebuildIndex` re-derives them. */
1339
+ function addNodeLabelColumns(db) {
1340
+ const columns = nodeColumns(db);
1341
+ addNodeColumnIfMissing(db, columns, 'description', 'ALTER TABLE nodes ADD COLUMN description TEXT;');
1342
+ addNodeColumnIfMissing(db, columns, 'cycles', 'ALTER TABLE nodes ADD COLUMN cycles INTEGER;');
1343
+ const root = nodesRoot();
1344
+ if (!existsSync(root))
1345
+ return;
1346
+ const upd = db.prepare('UPDATE nodes SET description = ?, cycles = ? WHERE node_id = ?');
1347
+ for (const id of readdirSync(root)) {
1348
+ const p = nodeMetaPath(id);
1349
+ if (!existsSync(p))
1350
+ continue;
1351
+ let meta;
1352
+ try {
1353
+ meta = JSON.parse(readFileSync(p, 'utf8'));
1354
+ }
1355
+ catch {
1356
+ continue; // a single unreadable meta never aborts the migration
1357
+ }
1358
+ const description = typeof meta['description'] === 'string' ? meta['description'] : null;
1359
+ const cycles = typeof meta['cycles'] === 'number' ? meta['cycles'] : null;
1360
+ upd.run(description, cycles, id);
1361
+ }
1362
+ }
1332
1363
  /** The journaled lane of the state-migration registry, adapted to this chain's
1333
1364
  * step shape. Registry order is preserved, so these versions stay stable as
1334
1365
  * long as the registry is append-only (same contract as the local list). */
@@ -1379,6 +1410,7 @@ export const MIGRATIONS = [
1379
1410
  /* v35 */ addNodeLifecycleColumns,
1380
1411
  /* v36 */ makeNodeSessionFileUnique,
1381
1412
  /* v37 */ repairNodeSessionFileUniqueIndex,
1413
+ /* v38 */ addNodeLabelColumns,
1382
1414
  ...JOURNALED_STATE_MIGRATIONS,
1383
1415
  ];
1384
1416
  /** Migration indexes that manage their OWN transaction and therefore must not
@@ -0,0 +1,23 @@
1
+ export interface NodeRecap {
2
+ /** What this conversation is for — the recap's `Goal:` line. */
3
+ goal: string;
4
+ /** Where it actually stands — the recap's `State:` line. */
5
+ state: string;
6
+ /** ISO 8601 stamp of when the briefing was generated. */
7
+ at: string;
8
+ }
9
+ /** Split the model's two-line briefing into its labelled halves. Returns null for
10
+ * anything that doesn't yield at least one non-empty line — a garbled recap is
11
+ * worse than none, so it is never stored. Missing labels fall back to line order
12
+ * (the prompt asks for Goal first, State second). */
13
+ export declare function parseRecapBriefing(briefing: string): {
14
+ goal: string;
15
+ state: string;
16
+ } | null;
17
+ /** Persist a freshly generated briefing. Best-effort — a write failure only costs
18
+ * the browse preview one stale panel, so it must never disturb the broker. */
19
+ export declare function writeNodeRecap(nodeId: string, briefing: string): void;
20
+ /** The node's stored briefing, or undefined when it never generated one (a node
21
+ * that finished before its first idle window, or one last run before this file
22
+ * existed). Never throws. */
23
+ export declare function readNodeRecap(nodeId: string): NodeRecap | undefined;