@north-light/crouter 0.3.175 → 0.3.176
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/dto/nodes.d.ts +7 -1
- package/dist/api/dto/profiles.d.ts +3 -0
- package/dist/clients/attach/render/crtr-output.d.ts +3 -3
- package/dist/clients/attach/render/crtr-output.js +25 -13
- package/dist/clients/attach/viewer.js +531 -561
- package/dist/commands/node/create.js +7 -3
- package/dist/commands/profile/list.js +3 -1
- package/dist/commands/profile/new.js +4 -1
- package/dist/commands/profile/project.js +23 -9
- package/dist/commands/profile/show.js +3 -1
- package/dist/commands/profile.js +2 -2
- package/dist/commands/sys/logs.js +4 -2
- package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/command-plugins/transport/http-invoke.js +10 -2
- package/dist/core/command.js +3 -1
- package/dist/core/config.js +4 -27
- package/dist/core/exclusive-lock.d.ts +14 -0
- package/dist/core/exclusive-lock.js +124 -0
- package/dist/core/human/claim.js +2 -1
- package/dist/core/human/convention.d.ts +0 -10
- package/dist/core/human/convention.js +1 -123
- package/dist/core/io.d.ts +23 -0
- package/dist/core/io.js +60 -0
- package/dist/core/preview-registry.d.ts +10 -5
- package/dist/core/preview-registry.js +136 -2537
- package/dist/core/profiles/manifest.d.ts +12 -1
- package/dist/core/profiles/manifest.js +57 -48
- package/dist/core/review/store.d.ts +4 -1
- package/dist/core/review/store.js +10 -0
- package/dist/core/runtime/branded-host.js +6 -84
- package/dist/core/runtime/broker-extension-render.js +1 -1
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/tmux-bindings.js +1 -1
- package/dist/core/runtime/warm-pool.d.ts +9 -8
- package/dist/core/runtime/warm-pool.js +58 -62
- package/dist/core/substrate/index.d.ts +2 -1
- package/dist/core/substrate/index.js +2 -1
- package/dist/core/substrate/on-read-node.d.ts +7 -0
- package/dist/core/substrate/on-read-node.js +37 -0
- package/dist/core/substrate/on-read.d.ts +7 -5
- package/dist/core/substrate/on-read.js +15 -23
- package/dist/core/termrender/termrender.js +16 -96
- package/dist/core/worktree.js +11 -100
- package/dist/daemon/api/handlers/nodes.js +32 -9
- package/dist/daemon/api/handlers/review-comments.js +5 -44
- package/dist/daemon/api/handlers/reviews.js +5 -53
- package/dist/daemon/api/handlers/validate.d.ts +22 -0
- package/dist/daemon/api/handlers/validate.js +59 -0
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/review/finish.js +1 -10
- package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
- package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
- package/dist/pi-extensions/canvas-preview-result.js +60 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/core/substrate/on-read-pure.d.ts +0 -7
- package/dist/core/substrate/on-read-pure.js +0 -272
|
@@ -187,2593 +187,192 @@ export function iconForPath(tokens) {
|
|
|
187
187
|
return familyCp !== undefined ? String.fromCodePoint(familyCp) : '◆';
|
|
188
188
|
}
|
|
189
189
|
const MEMORY_READ_CONTENT_LINES = 3;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* card (the old `kind : value` failure mode). */
|
|
193
|
-
function keyValueLines(entries) {
|
|
194
|
-
if (entries.length === 0)
|
|
195
|
-
return [];
|
|
196
|
-
const lengths = entries.map(([key]) => key.length);
|
|
197
|
-
const min = Math.min(...lengths);
|
|
198
|
-
const max = Math.max(...lengths);
|
|
199
|
-
const width = max - min <= 12 ? max : 0;
|
|
200
|
-
return entries.map(([key, value]) => ({ text: `${width === 0 ? key : key.padEnd(width)}: ${value}` }));
|
|
190
|
+
function record(input) {
|
|
191
|
+
return input.result;
|
|
201
192
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
* option C): a `name · kind · scope` identity line (path is filesystem
|
|
206
|
-
* noise, dropped; follow_up hint dropped) plus the first few lines of the
|
|
207
|
-
* content as a teaser — the full body is what ctrl+o reveals. Returns null
|
|
208
|
-
* when neither the metadata fields nor the `**content:**` marker are
|
|
209
|
-
* present — an error result (missing/ambiguous doc name, etc.) has none of
|
|
210
|
-
* this shape, and must fall back to the raw output rather than render a
|
|
211
|
-
* blank collapsed card. */
|
|
212
|
-
function summarizeMemoryRead(text) {
|
|
213
|
-
const field = (name) => new RegExp(`^-\\s+${name}:\\s*(.+)$`, 'm').exec(text)?.[1].trim() ?? '';
|
|
214
|
-
const header = [field('name'), field('kind'), field('scope')].filter((f) => f !== '').join(' · ');
|
|
215
|
-
// Content spans from `**content:**` to the `**follow_up:**` hint (or EOF).
|
|
216
|
-
const contentMatch = /\*\*content:\*\*\s*([\s\S]*?)(?:\n\s*\*\*follow_up:\*\*|$)/.exec(text);
|
|
217
|
-
if (header === '' && contentMatch === null)
|
|
218
|
-
return null;
|
|
219
|
-
const contentLines = (contentMatch?.[1] ?? '').replace(/^\s+/, '').replace(/\s+$/, '').split('\n');
|
|
220
|
-
const teaser = contentLines.slice(0, MEMORY_READ_CONTENT_LINES);
|
|
221
|
-
const hasMore = contentLines.slice(MEMORY_READ_CONTENT_LINES).some((l) => l.trim() !== '');
|
|
222
|
-
if (hasMore)
|
|
223
|
-
teaser.push('…');
|
|
224
|
-
const lines = [{ text: header }];
|
|
225
|
-
if (teaser.some((l) => l.trim() !== '')) {
|
|
226
|
-
lines.push({ text: '' });
|
|
227
|
-
for (const l of teaser)
|
|
228
|
-
lines.push({ text: l });
|
|
229
|
-
}
|
|
230
|
-
return lines;
|
|
231
|
-
}
|
|
232
|
-
/** Cap on individual finding rows in the `memory lint` findings-present
|
|
233
|
-
* summary — the count line always shows the true total, this just bounds
|
|
234
|
-
* how many per-finding detail lines get listed before falling back to a
|
|
235
|
-
* muted "… N more" line. */
|
|
236
|
-
const MEMORY_LINT_FINDINGS_CAP = 8;
|
|
237
|
-
/** `crtr memory lint` has two real output shapes (verified against the live
|
|
238
|
-
* command, not assumed):
|
|
239
|
-
* - 0 findings (success return): `- checked: N`, `- corpora: {...}`, a
|
|
240
|
-
* prose line, blank, then `0 findings.`
|
|
241
|
-
* - >0 findings: the leaf THROWS instead of returning, so the CLI error path
|
|
242
|
-
* renders `<error code="error">\nmemory lint: N finding(s) across M
|
|
243
|
-
* files\nNext: ...\n</error>` on stdout, with one `crtr: memory lint:
|
|
244
|
-
* <path>: <error>` line per finding on stderr (merged into the same tool
|
|
245
|
-
* result text when the bash host merges the two streams). Both shapes are
|
|
246
|
-
* parsed defensively; an output that matches neither falls through to the
|
|
247
|
-
* generic truncation path (returns null). */
|
|
248
|
-
function summarizeMemoryLint(text) {
|
|
249
|
-
const checkedMatch = /^- checked:\s*(\d+)\s*$/m.exec(text);
|
|
250
|
-
const successCountMatch = /(\d+)\s+findings\.\s*$/.exec(text.trimEnd());
|
|
251
|
-
if (checkedMatch && successCountMatch) {
|
|
252
|
-
const checked = checkedMatch[1];
|
|
253
|
-
const count = Number(successCountMatch[1]);
|
|
254
|
-
if (count === 0) {
|
|
255
|
-
return [
|
|
256
|
-
{ text: 'PASS', tone: 'success' },
|
|
257
|
-
{ text: `${checked} docs checked`, tone: 'muted' },
|
|
258
|
-
];
|
|
259
|
-
}
|
|
260
|
-
return [
|
|
261
|
-
{ text: `${count} findings`, tone: 'error' },
|
|
262
|
-
{ text: `${checked} docs checked`, tone: 'muted' },
|
|
263
|
-
];
|
|
264
|
-
}
|
|
265
|
-
const failureMatch = /(\d+)\s+finding\(s\)\s+across\s+(\d+)\s+files/.exec(text);
|
|
266
|
-
if (failureMatch) {
|
|
267
|
-
const count = Number(failureMatch[1]);
|
|
268
|
-
const checked = failureMatch[2];
|
|
269
|
-
const lines = [{ text: `${count} findings`, tone: 'error' }];
|
|
270
|
-
const findingLines = [...text.matchAll(/^crtr: memory lint: (.+)$/gm)].map((m) => m[1]);
|
|
271
|
-
for (const line of findingLines.slice(0, MEMORY_LINT_FINDINGS_CAP)) {
|
|
272
|
-
lines.push({ text: line, tone: 'warning' });
|
|
273
|
-
}
|
|
274
|
-
const remaining = findingLines.length - MEMORY_LINT_FINDINGS_CAP;
|
|
275
|
-
if (remaining > 0)
|
|
276
|
-
lines.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
277
|
-
lines.push({ text: `${checked} docs checked`, tone: 'muted' });
|
|
278
|
-
return lines;
|
|
279
|
-
}
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
// Shared table/count parsing helpers for the canvas/node runtime cockpit
|
|
284
|
-
// batch below. These parse the exact markdown shapes core/render.ts's generic
|
|
285
|
-
// renderArray/renderResult produce (a `- field: N` bullet for a scalar count,
|
|
286
|
-
// an `N <label>:` count lead-in followed by a GFM table for an array of
|
|
287
|
-
// objects, or a bare `0 <label>.` line for an empty array) — strict recognition
|
|
288
|
-
// of that one shape, never a second lenient fallback. Any deviation (missing
|
|
289
|
-
// table, column-count mismatch, non-numeric field expected to be numeric)
|
|
290
|
-
// returns null so the caller falls through to generic truncation.
|
|
291
|
-
// ---------------------------------------------------------------------------
|
|
292
|
-
/** Split one GFM table row into its cell strings, honoring `\|` as an escaped
|
|
293
|
-
* literal pipe (the only escape core/render.ts's cellEsc introduces) rather
|
|
294
|
-
* than a column separator. Returns null when the line isn't a `| ... |` row. */
|
|
295
|
-
function splitTableRow(line) {
|
|
296
|
-
const trimmed = line.trim();
|
|
297
|
-
if (!trimmed.startsWith('|') || !trimmed.endsWith('|'))
|
|
298
|
-
return null;
|
|
299
|
-
const body = trimmed.slice(1, -1);
|
|
300
|
-
const cells = [];
|
|
301
|
-
let current = '';
|
|
302
|
-
let i = 0;
|
|
303
|
-
while (i < body.length) {
|
|
304
|
-
const ch = body[i];
|
|
305
|
-
if (ch === '\\' && body[i + 1] === '|') {
|
|
306
|
-
current += '|';
|
|
307
|
-
i += 2;
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
if (ch === '|') {
|
|
311
|
-
cells.push(current.trim());
|
|
312
|
-
current = '';
|
|
313
|
-
i++;
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
current += ch;
|
|
317
|
-
i++;
|
|
318
|
-
}
|
|
319
|
-
cells.push(current.trim());
|
|
320
|
-
return cells;
|
|
193
|
+
function stringField(value, name) {
|
|
194
|
+
const field = value[name];
|
|
195
|
+
return typeof field === 'string' && field !== '' ? field : undefined;
|
|
321
196
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
* and parse every data row into a plain `{column: cellText}` record, using the
|
|
325
|
-
* header row's own column names as keys — no positional/column-index
|
|
326
|
-
* assumptions, so an unrelated extra column (e.g. `hanging`) is carried
|
|
327
|
-
* along and simply ignored by callers that only read specific keys. A bare
|
|
328
|
-
* `0 <headingLabel>.` line (renderArray's empty-array shape) parses as an
|
|
329
|
-
* empty (not null) array. Returns null when the heading is missing, the
|
|
330
|
-
* table shape doesn't match (header/separator/row column-count mismatch), or
|
|
331
|
-
* trailing non-blank content follows the table — never a partial parse. */
|
|
332
|
-
function parseTableRows(text, headingLabel) {
|
|
333
|
-
const lines = text.split('\n');
|
|
334
|
-
for (let i = 0; i < lines.length; i++) {
|
|
335
|
-
const trimmed = lines[i].trim();
|
|
336
|
-
if (trimmed === `0 ${headingLabel}.`)
|
|
337
|
-
return [];
|
|
338
|
-
if (!/^\d+ /.test(trimmed) || !trimmed.endsWith(`${headingLabel}:`))
|
|
339
|
-
continue;
|
|
340
|
-
let j = i + 1;
|
|
341
|
-
while (j < lines.length && lines[j].trim() === '')
|
|
342
|
-
j++;
|
|
343
|
-
if (j + 1 >= lines.length)
|
|
344
|
-
return null;
|
|
345
|
-
const header = splitTableRow(lines[j]);
|
|
346
|
-
const separator = splitTableRow(lines[j + 1]);
|
|
347
|
-
if (header === null || separator === null || header.length === 0 || separator.length !== header.length)
|
|
348
|
-
return null;
|
|
349
|
-
if (separator.some((cell) => !/^-+$/.test(cell)))
|
|
350
|
-
return null;
|
|
351
|
-
const rows = [];
|
|
352
|
-
j += 2;
|
|
353
|
-
while (j < lines.length && lines[j].trim() !== '') {
|
|
354
|
-
const cells = splitTableRow(lines[j]);
|
|
355
|
-
if (cells === null || cells.length !== header.length)
|
|
356
|
-
return null;
|
|
357
|
-
const row = {};
|
|
358
|
-
for (let c = 0; c < header.length; c++)
|
|
359
|
-
row[header[c]] = cells[c];
|
|
360
|
-
rows.push(row);
|
|
361
|
-
j++;
|
|
362
|
-
}
|
|
363
|
-
while (j < lines.length && lines[j].trim() === '')
|
|
364
|
-
j++;
|
|
365
|
-
return j === lines.length ? rows : null;
|
|
366
|
-
}
|
|
367
|
-
return null;
|
|
197
|
+
function booleanField(value, name) {
|
|
198
|
+
return typeof value[name] === 'boolean' ? value[name] : undefined;
|
|
368
199
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
function parseIntBullet(text, field) {
|
|
372
|
-
const match = new RegExp(`^-\\s+${field}:\\s*(\\d+)\\s*$`, 'm').exec(text);
|
|
373
|
-
return match === null ? null : Number(match[1]);
|
|
200
|
+
function numberField(value, name) {
|
|
201
|
+
return typeof value[name] === 'number' && Number.isFinite(value[name]) ? value[name] : undefined;
|
|
374
202
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
203
|
+
function arrayField(value, name) {
|
|
204
|
+
const field = value[name];
|
|
205
|
+
return Array.isArray(field) && field.every((item) => item !== null && typeof item === 'object' && !Array.isArray(item))
|
|
206
|
+
? field
|
|
207
|
+
: undefined;
|
|
380
208
|
}
|
|
381
|
-
/** The trailing path segment of a cwd, for a compact per-cwd label
|
|
382
|
-
* (`/Users/.../cli/crouter` → `crouter`) — a full absolute path is filesystem
|
|
383
|
-
* noise in a collapsed summary line. */
|
|
384
209
|
function basename(path) {
|
|
385
210
|
const trimmed = path.replace(/[\\/]+$/, '');
|
|
386
211
|
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
387
212
|
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
|
|
388
213
|
}
|
|
389
|
-
/** Render a `<count> <key>` digest across a fixed preferred key order (each
|
|
390
|
-
* shown only when its count is > 0), then any other key present sorted
|
|
391
|
-
* alphabetically — the `3 active · 24 done` shape both dashboard and node
|
|
392
|
-
* list summaries share. */
|
|
393
|
-
function summarizeOrderedCounts(counts, order) {
|
|
394
|
-
const inOrder = new Set(order);
|
|
395
|
-
const parts = [];
|
|
396
|
-
for (const key of order) {
|
|
397
|
-
const count = counts.get(key) ?? 0;
|
|
398
|
-
if (count > 0)
|
|
399
|
-
parts.push(`${count} ${key}`);
|
|
400
|
-
}
|
|
401
|
-
const extras = [...counts.entries()]
|
|
402
|
-
.filter(([key, count]) => count > 0 && !inOrder.has(key))
|
|
403
|
-
.sort(([a], [b]) => a.localeCompare(b));
|
|
404
|
-
for (const [key, count] of extras)
|
|
405
|
-
parts.push(`${count} ${key}`);
|
|
406
|
-
return parts.join(' · ');
|
|
407
|
-
}
|
|
408
|
-
/** Render the top `limit` entries of a name→count map, ranked by count
|
|
409
|
-
* descending (name ascending to break ties), with a muted `… N more` tail
|
|
410
|
-
* when entries were dropped — the `developer×2, advisor×1` kind-breakdown
|
|
411
|
-
* shape. */
|
|
412
|
-
function summarizeTopCounts(counts, limit, renderItem) {
|
|
413
|
-
const entries = [...counts.entries()]
|
|
414
|
-
.filter(([, count]) => count > 0)
|
|
415
|
-
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
416
|
-
const shown = entries.slice(0, limit).map(([name, count]) => renderItem(name, count));
|
|
417
|
-
const remaining = entries.length - shown.length;
|
|
418
|
-
if (remaining > 0)
|
|
419
|
-
shown.push(`… ${remaining} more`);
|
|
420
|
-
return shown.join(', ');
|
|
421
|
-
}
|
|
422
|
-
/** `crtr canvas dashboard`'s object output carries a `rows` array (one entry
|
|
423
|
-
* per node: {node_id,name,status,kind,mode,ctx_tokens,asks,...}) rendered by
|
|
424
|
-
* renderArray as an `N rows:` table. The collapsed card is a status-count
|
|
425
|
-
* digest (active/idle plain, done/dead/canceled muted by the ordering below),
|
|
426
|
-
* the largest ctx_tokens across the view as a muted aside, and a warning line
|
|
427
|
-
* only when any row carries a pending ask — Silas's 2026-07-01 reframe ("what
|
|
428
|
-
* does a human want to know?"), not a re-rendering of the raw tree/table.
|
|
429
|
-
* Returns null when the rows table isn't in the expected shape, or is empty
|
|
430
|
-
* (an empty canvas has nothing to digest into a status line). */
|
|
431
|
-
function summarizeCanvasDashboard(text) {
|
|
432
|
-
const rows = parseTableRows(text, 'rows');
|
|
433
|
-
if (rows === null || rows.length === 0)
|
|
434
|
-
return null;
|
|
435
|
-
const statuses = new Map();
|
|
436
|
-
let largestCtx = 0;
|
|
437
|
-
let asks = 0;
|
|
438
|
-
for (const row of rows) {
|
|
439
|
-
const status = row['status'];
|
|
440
|
-
const ctx = Number(row['ctx_tokens']);
|
|
441
|
-
const ask = Number(row['asks']);
|
|
442
|
-
if (status === undefined || status === '' || Number.isNaN(ctx) || Number.isNaN(ask))
|
|
443
|
-
return null;
|
|
444
|
-
statuses.set(status, (statuses.get(status) ?? 0) + 1);
|
|
445
|
-
largestCtx = Math.max(largestCtx, ctx);
|
|
446
|
-
asks += ask;
|
|
447
|
-
}
|
|
448
|
-
const digest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled']);
|
|
449
|
-
if (digest === '')
|
|
450
|
-
return null;
|
|
451
|
-
const lines = [{ text: digest }];
|
|
452
|
-
lines.push({ text: `largest ctx: ${compactThousands(largestCtx)} tokens`, tone: 'muted' });
|
|
453
|
-
if (asks > 0)
|
|
454
|
-
lines.push({ text: `${asks} pending asks`, tone: 'warning' });
|
|
455
|
-
return lines;
|
|
456
|
-
}
|
|
457
|
-
/** `crtr node inspect list`'s object output carries a `nodes` array (one entry
|
|
458
|
-
* per node: {node_id,name,kind,mode,lifecycle,status,cwd,...}) rendered as an
|
|
459
|
-
* `N nodes:` table. The collapsed card is a status-count digest (same order
|
|
460
|
-
* as the dashboard summary) plus a muted kind breakdown (top 5 kinds by
|
|
461
|
-
* count). Returns null when the table isn't in the expected shape or empty. */
|
|
462
|
-
function summarizeNodeInspectList(text) {
|
|
463
|
-
const rows = parseTableRows(text, 'nodes');
|
|
464
|
-
if (rows === null || rows.length === 0)
|
|
465
|
-
return null;
|
|
466
|
-
const statuses = new Map();
|
|
467
|
-
const kinds = new Map();
|
|
468
|
-
for (const row of rows) {
|
|
469
|
-
const status = row['status'];
|
|
470
|
-
const kind = row['kind'];
|
|
471
|
-
if (status === undefined || status === '' || kind === undefined || kind === '')
|
|
472
|
-
return null;
|
|
473
|
-
statuses.set(status, (statuses.get(status) ?? 0) + 1);
|
|
474
|
-
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
475
|
-
}
|
|
476
|
-
const statusDigest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled']);
|
|
477
|
-
const kindDigest = summarizeTopCounts(kinds, 5, (kind, count) => `${kind}×${count}`);
|
|
478
|
-
if (statusDigest === '' || kindDigest === '')
|
|
479
|
-
return null;
|
|
480
|
-
return [
|
|
481
|
-
{ text: statusDigest },
|
|
482
|
-
{ text: `kinds: ${kindDigest}`, tone: 'muted' },
|
|
483
|
-
];
|
|
484
|
-
}
|
|
485
|
-
/** `crtr canvas attention count`'s object output is a single `- count: N`
|
|
486
|
-
* bullet. Silas's computed-status framing (2026-07-01): 0 pending asks is a
|
|
487
|
-
* genuinely PASSing state worth a green line, not an empty card, and N > 0 is
|
|
488
|
-
* worth an error-toned line naming the count — never suppress this leaf down
|
|
489
|
-
* to nothing regardless of which way the count falls. Returns null only when
|
|
490
|
-
* `count` isn't present as a plain integer (an unrecognized/error shape). */
|
|
491
|
-
function summarizeCanvasAttentionCount(text) {
|
|
492
|
-
const count = parseIntBullet(text, 'count');
|
|
493
|
-
if (count === null)
|
|
494
|
-
return null;
|
|
495
|
-
if (count === 0)
|
|
496
|
-
return [{ text: 'PASS · no pending asks', tone: 'success' }];
|
|
497
|
-
return [{ text: `${count} pending asks`, tone: 'error' }];
|
|
498
|
-
}
|
|
499
|
-
/** `crtr canvas attention list`'s object output is a `- total: N` bullet
|
|
500
|
-
* followed by an `M items:` table ({node_id,name,cwd,count}). The collapsed
|
|
501
|
-
* card leads with `total · cwd count`, calls out the hottest single cwd in
|
|
502
|
-
* warning tone (the one place a human should look first), then a muted
|
|
503
|
-
* digest of the remaining cwd basenames (paths are filesystem noise here).
|
|
504
|
-
* Zero pending asks reads as the same PASS line attention count uses —
|
|
505
|
-
* consistent health framing across both leaves, never an empty card. Returns
|
|
506
|
-
* null when the total/items shape doesn't match, or when any item is missing
|
|
507
|
-
* its cwd/count (an unrecognized row shape). */
|
|
508
|
-
function summarizeCanvasAttentionList(text) {
|
|
509
|
-
const total = parseIntBullet(text, 'total');
|
|
510
|
-
const items = parseTableRows(text, 'items');
|
|
511
|
-
if (total === null || items === null)
|
|
512
|
-
return null;
|
|
513
|
-
if (total === 0 || items.length === 0)
|
|
514
|
-
return [{ text: 'PASS · no pending asks', tone: 'success' }];
|
|
515
|
-
const entries = [];
|
|
516
|
-
for (const row of items) {
|
|
517
|
-
const cwd = row['cwd'];
|
|
518
|
-
const count = Number(row['count']);
|
|
519
|
-
if (cwd === undefined || cwd === '' || Number.isNaN(count))
|
|
520
|
-
return null;
|
|
521
|
-
entries.push({ cwd, count });
|
|
522
|
-
}
|
|
523
|
-
const sorted = entries
|
|
524
|
-
.slice()
|
|
525
|
-
.sort((a, b) => b.count - a.count || basename(a.cwd).localeCompare(basename(b.cwd)) || a.cwd.localeCompare(b.cwd));
|
|
526
|
-
const hottest = sorted[0];
|
|
527
|
-
const restBasenames = sorted.slice(1).map((item) => basename(item.cwd));
|
|
528
|
-
const lines = [
|
|
529
|
-
{ text: `${total} total · ${items.length} cwds` },
|
|
530
|
-
{ text: `hottest: ${basename(hottest.cwd)} (${hottest.count})`, tone: 'warning' },
|
|
531
|
-
];
|
|
532
|
-
if (restBasenames.length > 0) {
|
|
533
|
-
const shown = restBasenames.slice(0, 4);
|
|
534
|
-
const remaining = restBasenames.length - shown.length;
|
|
535
|
-
const digest = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
|
|
536
|
-
lines.push({ text: `other cwds: ${digest}`, tone: 'muted' });
|
|
537
|
-
}
|
|
538
|
-
return lines;
|
|
539
|
-
}
|
|
540
|
-
/** `crtr canvas attention map` (src/commands/attention.ts:227) has no custom
|
|
541
|
-
* `render`; its `counts` field is a nested plain object (node_id → ask
|
|
542
|
-
* count), not an array, so the generic renderer's `renderResult` emits it
|
|
543
|
-
* as ONE scalar bullet via `scalarStr`'s compact-JSON branch: `- counts:
|
|
544
|
-
* {"id1":0,"id2":3,...}` (output schema at attention.ts:190). No node names
|
|
545
|
-
* are available in this output (only ids), so the collapsed card names the
|
|
546
|
-
* hottest id, not a name. The real shape is `Record<string, number>` — a
|
|
547
|
-
* mixed object (any non-numeric value) is a shape mismatch, not a partial
|
|
548
|
-
* total, so it's rejected wholesale rather than filtered down. Returns
|
|
549
|
-
* null when `counts` isn't parseable JSON, is empty, or has any
|
|
550
|
-
* non-numeric entry. */
|
|
551
|
-
function summarizeCanvasAttentionMap(text) {
|
|
552
|
-
const m = /^- counts:\s*(\{.*\})\s*$/m.exec(text);
|
|
553
|
-
if (m === null)
|
|
554
|
-
return null;
|
|
555
|
-
let counts;
|
|
556
|
-
try {
|
|
557
|
-
counts = JSON.parse(m[1]);
|
|
558
|
-
}
|
|
559
|
-
catch {
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
if (counts === null || typeof counts !== 'object')
|
|
563
|
-
return null;
|
|
564
|
-
const rawEntries = Object.entries(counts);
|
|
565
|
-
if (rawEntries.length === 0)
|
|
566
|
-
return null;
|
|
567
|
-
if (rawEntries.some(([, v]) => typeof v !== 'number'))
|
|
568
|
-
return null;
|
|
569
|
-
const entries = rawEntries;
|
|
570
|
-
const total = entries.reduce((s, [, v]) => s + v, 0);
|
|
571
|
-
if (total === 0)
|
|
572
|
-
return [{ text: 'PASS · no pending asks', tone: 'success' }];
|
|
573
|
-
const withAsks = entries.filter(([, v]) => v > 0);
|
|
574
|
-
const sorted = withAsks.slice().sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
575
|
-
const hottest = sorted[0];
|
|
576
|
-
return [
|
|
577
|
-
{ text: `${total} asks across ${withAsks.length} node${withAsks.length === 1 ? '' : 's'}` },
|
|
578
|
-
{ text: `hottest: ${hottest[0]} (${hottest[1]})`, tone: 'warning' },
|
|
579
|
-
];
|
|
580
|
-
}
|
|
581
|
-
/** Find the first argv token after `skipWords` that isn't a boolean flag
|
|
582
|
-
* (`--foo`) — recovers a leaf's own positional id when its render() output
|
|
583
|
-
* carries no id of its own (see summarizeNodeLifecycleRevive below). Safe
|
|
584
|
-
* here specifically because every `node lifecycle revive` flag is a boolean
|
|
585
|
-
* with no separate value token, so a naive forward scan can't mistake a
|
|
586
|
-
* flag's value for the positional. */
|
|
587
|
-
function firstPositionalAfter(tokens, skipWords) {
|
|
588
|
-
for (let i = skipWords; i < tokens.length; i++) {
|
|
589
|
-
if (!tokens[i].startsWith('--'))
|
|
590
|
-
return tokens[i];
|
|
591
|
-
}
|
|
592
|
-
return undefined;
|
|
593
|
-
}
|
|
594
|
-
/** `crtr node lifecycle revive` (src/commands/node-lifecycle-revive.ts) has no
|
|
595
|
-
* custom `render`, so its object output falls through to generic
|
|
596
|
-
* renderResult. Two shapes depending on flags:
|
|
597
|
-
* - `--now` kick: the only shape with `- kicked: true`.
|
|
598
|
-
* - plain single-node revive (default or `--fresh`): the only shape with
|
|
599
|
-
* `- resumed: true|false`. `window` is documented "Always null", so it's
|
|
600
|
-
* never bulleted — and there is no node id anywhere in the output, so
|
|
601
|
-
* it's recovered from argv (the leaf's own positional) via
|
|
602
|
-
* firstPositionalAfter.
|
|
603
|
-
* Returns null on any other/unrecognized shape. */
|
|
604
|
-
function summarizeNodeLifecycleRevive(text, tokens) {
|
|
605
|
-
if (/^- kicked:\s*true\s*$/m.test(text)) {
|
|
606
|
-
return [{ text: 'kicked — daemon resumes in ~20s', tone: 'warning' }];
|
|
607
|
-
}
|
|
608
|
-
const resumedMatch = /^- resumed:\s*(true|false)\s*$/m.exec(text);
|
|
609
|
-
if (resumedMatch === null)
|
|
610
|
-
return null;
|
|
611
|
-
const id = firstPositionalAfter(tokens, 3); // 'node','lifecycle','revive' = 3 words
|
|
612
|
-
const lines = [{ text: id !== undefined ? `revived ${id}` : 'revived', tone: 'success' }];
|
|
613
|
-
const readyMatch = /^- ready:\s*(true|false)\s*$/m.exec(text);
|
|
614
|
-
if (readyMatch !== null && readyMatch[1] === 'false') {
|
|
615
|
-
lines.push({ text: 'not yet attachable (socket not ready)', tone: 'warning' });
|
|
616
|
-
}
|
|
617
|
-
return lines;
|
|
618
|
-
}
|
|
619
|
-
const MAINTENANCE_ROSTER_CAP = 4;
|
|
620
|
-
/** `crtr canvas prune` emits scalar maintenance receipts followed by a `nodes`
|
|
621
|
-
* table. A dry run deliberately reports `pruned: 0` while the table holds its
|
|
622
|
-
* candidates; a real run must report exactly as many pruned nodes as table
|
|
623
|
-
* rows. This keeps the collapsed receipt outcome-first while retaining the
|
|
624
|
-
* expanded table as the authoritative roster. */
|
|
625
|
-
function summarizeCanvasPrune(text) {
|
|
626
|
-
const pruned = parseIntBullet(text, 'pruned');
|
|
627
|
-
const ttlDays = parseIntBullet(text, 'ttlDays');
|
|
628
|
-
const dryRunMatch = /^- dryRun:\s*(true|false)\s*$/m.exec(text);
|
|
629
|
-
const rows = parseTableRows(text, 'nodes');
|
|
630
|
-
if (pruned === null || ttlDays === null || dryRunMatch === null || rows === null)
|
|
631
|
-
return null;
|
|
632
|
-
const dryRun = dryRunMatch[1] === 'true';
|
|
633
|
-
if ((dryRun && pruned !== 0) || (!dryRun && pruned !== rows.length))
|
|
634
|
-
return null;
|
|
635
|
-
const statuses = new Map();
|
|
636
|
-
const ids = [];
|
|
637
|
-
for (const row of rows) {
|
|
638
|
-
const id = row['node_id'];
|
|
639
|
-
const status = row['status'];
|
|
640
|
-
if (id === undefined || id === '' || status === undefined || status === '')
|
|
641
|
-
return null;
|
|
642
|
-
ids.push(id);
|
|
643
|
-
statuses.set(status, (statuses.get(status) ?? 0) + 1);
|
|
644
|
-
}
|
|
645
|
-
const lines = [
|
|
646
|
-
dryRun
|
|
647
|
-
? { text: `dry run · ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, tone: 'accent' }
|
|
648
|
-
: rows.length === 0
|
|
649
|
-
? { text: 'nothing to prune', tone: 'success' }
|
|
650
|
-
: { text: `pruned ${pruned} node${pruned === 1 ? '' : 's'}`, tone: 'success' },
|
|
651
|
-
{ text: `ttl: ${ttlDays} days`, tone: 'muted' },
|
|
652
|
-
];
|
|
653
|
-
const statusDigest = summarizeOrderedCounts(statuses, ['active', 'idle', 'done', 'dead', 'canceled', 'empty']);
|
|
654
|
-
if (statusDigest !== '')
|
|
655
|
-
lines.push({ text: statusDigest, tone: 'muted' });
|
|
656
|
-
if (ids.length > 0) {
|
|
657
|
-
const shown = ids.slice(0, MAINTENANCE_ROSTER_CAP);
|
|
658
|
-
const remaining = ids.length - shown.length;
|
|
659
|
-
lines.push({ text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' });
|
|
660
|
-
}
|
|
661
|
-
return lines;
|
|
662
|
-
}
|
|
663
|
-
/** `crtr canvas browse` writes a static forest only without a TTY. Each node
|
|
664
|
-
* line ends in the stable `[kind/mode] ctx Nk` suffix; interactive ANSI output,
|
|
665
|
-
* cycle diagnostics, and malformed lines deliberately fall back to raw text. */
|
|
666
|
-
function summarizeCanvasBrowse(text) {
|
|
667
|
-
const trimmed = text.trim();
|
|
668
|
-
if (trimmed === '(canvas is empty)')
|
|
669
|
-
return [{ text: 'canvas empty', tone: 'muted' }];
|
|
670
|
-
if (trimmed === '(no live roots)')
|
|
671
|
-
return [{ text: 'no live roots', tone: 'muted' }];
|
|
672
|
-
const kinds = new Map();
|
|
673
|
-
let asks = 0;
|
|
674
|
-
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
675
|
-
if (lines.length === 0)
|
|
676
|
-
return null;
|
|
677
|
-
for (const line of lines) {
|
|
678
|
-
const row = /^(?:[│ ]*(?:├─ |└─ ))?.+ \[(.+)\/(base|orchestrator)\] ctx \d+k(?: ⚑(\d+))?(?: · .+)?$/.exec(line);
|
|
679
|
-
if (row === null || row[1] === '')
|
|
680
|
-
return null;
|
|
681
|
-
kinds.set(row[1], (kinds.get(row[1]) ?? 0) + 1);
|
|
682
|
-
asks += Number(row[3] ?? 0);
|
|
683
|
-
}
|
|
684
|
-
const kindDigest = summarizeTopCounts(kinds, MAINTENANCE_ROSTER_CAP, (kind, count) => `${kind}×${count}`);
|
|
685
|
-
if (kindDigest === '')
|
|
686
|
-
return null;
|
|
687
|
-
const out = [
|
|
688
|
-
{ text: `${lines.length} node${lines.length === 1 ? '' : 's'} in forest` },
|
|
689
|
-
{ text: `kinds: ${kindDigest}`, tone: 'muted' },
|
|
690
|
-
];
|
|
691
|
-
if (asks > 0)
|
|
692
|
-
out.push({ text: `${asks} pending ask${asks === 1 ? '' : 's'}`, tone: 'warning' });
|
|
693
|
-
return out;
|
|
694
|
-
}
|
|
695
|
-
/** `crtr canvas revive --all` has a short mode bullet plus a prose message;
|
|
696
|
-
* candidate and failure details switch between bullets and prose as their
|
|
697
|
-
* length changes, so the stable message count is the only digest input. */
|
|
698
|
-
function summarizeCanvasRevive(text) {
|
|
699
|
-
const mode = /^- mode:\s*(preview|revived)\s*$/m.exec(text)?.[1];
|
|
700
|
-
const message = readFieldFlexible(text, 'message') ?? soloProseAfterBullets(text);
|
|
701
|
-
if (mode === undefined || message === undefined)
|
|
702
|
-
return null;
|
|
703
|
-
if (mode === 'preview') {
|
|
704
|
-
if (message === 'No disconnected nodes — every node with a resumable session already has a running engine. Nothing to revive.') {
|
|
705
|
-
return [{ text: 'PASS · no disconnected nodes', tone: 'success' }];
|
|
706
|
-
}
|
|
707
|
-
const preview = /^(\d+) disconnected node\(s\) WOULD be revived \(RESUME\)\. Nothing has been launched yet:/.exec(message);
|
|
708
|
-
if (preview === null)
|
|
709
|
-
return null;
|
|
710
|
-
return [
|
|
711
|
-
{ text: `preview · ${preview[1]} candidate${preview[1] === '1' ? '' : 's'}`, tone: 'accent' },
|
|
712
|
-
{ text: 'nothing launched', tone: 'muted' },
|
|
713
|
-
];
|
|
714
|
-
}
|
|
715
|
-
const complete = /^Revived (\d+) disconnected node\(s\) \(RESUME\)\.$/.exec(message);
|
|
716
|
-
if (complete !== null)
|
|
717
|
-
return [{ text: `revived ${complete[1]}`, tone: 'success' }];
|
|
718
|
-
const partial = /^Revived (\d+) of (\d+) disconnected node\(s\); (\d+) failed to relaunch \(see failed\)\.$/.exec(message);
|
|
719
|
-
if (partial === null)
|
|
720
|
-
return null;
|
|
721
|
-
return [
|
|
722
|
-
{ text: `revived ${partial[1]} of ${partial[2]}`, tone: 'success' },
|
|
723
|
-
{ text: `${partial[3]} failed to relaunch`, tone: 'warning' },
|
|
724
|
-
];
|
|
725
|
-
}
|
|
726
|
-
/** `crtr canvas chord` is a high-frequency menu dispatcher. Its action can
|
|
727
|
-
* become prose when argv is long, so retain the dispatched command as the
|
|
728
|
-
* compact asset while rejecting anything outside its two real result shapes. */
|
|
729
|
-
function summarizeCanvasChord(text) {
|
|
730
|
-
const ran = /^- ran:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
731
|
-
const nodeId = /^- node_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
732
|
-
const action = readFieldFlexible(text, 'action') ?? soloProseAfterBullets(text);
|
|
733
|
-
if (ran === undefined || nodeId === undefined || action === undefined)
|
|
734
|
-
return null;
|
|
735
|
-
if (ran === 'false')
|
|
736
|
-
return action === 'noop' ? [{ text: `no action · ${nodeId}`, tone: 'muted' }] : null;
|
|
737
|
-
if (!action.startsWith('crtr '))
|
|
738
|
-
return null;
|
|
739
|
-
return [
|
|
740
|
-
{ text: `action dispatched · ${nodeId}`, tone: 'success' },
|
|
741
|
-
{ text: truncateLine(action, 120), tone: 'muted' },
|
|
742
|
-
];
|
|
743
|
-
}
|
|
744
|
-
/** `crtr canvas history search` (renderSearch, src/commands/canvas-history/
|
|
745
|
-
* search.ts) has three shapes: `0 hits.`; `--full` (### heading blocks
|
|
746
|
-
* per hit, not tabular — skipped, returns null); or the normal `N of M
|
|
747
|
-
* hits:` lead-in plus a GFM table (columns `{ref,node,kind,source,ts,
|
|
748
|
-
* score?,snippet}` — `--full` mode aside, this is now the ONLY row shape
|
|
749
|
-
* this leaf can produce; the historical `--grep` flag on this leaf was
|
|
750
|
-
* removed, and that line-hit shape now lives at the separate `canvas
|
|
751
|
-
* history grep` leaf). Reuses this file's own `splitTableRow` helper rather
|
|
752
|
-
* than `parseTableRows` (whose `N <label>:` lead-in must end in a literal
|
|
753
|
-
* colon immediately before the table — `N of M hits:` is a close but
|
|
754
|
-
* different shape). Returns null on `--full` or any other shape mismatch: a
|
|
755
|
-
* non-`---` separator cell, a missing `node`/`ts`/`kind` column (all three
|
|
756
|
-
* are now always present on this leaf's rows), or a parsed row count that
|
|
757
|
-
* doesn't match the leading `N of M hits:` displayed count `N` (the actual
|
|
758
|
-
* page size — pagination can make it less than `M`, the grand total). */
|
|
759
|
-
function summarizeCanvasHistorySearch(text) {
|
|
760
|
-
if (/^0 hits\.$/m.test(text))
|
|
761
|
-
return [{ text: '0 hits', tone: 'muted' }];
|
|
762
|
-
const lead = /^(\d+) of (\d+) hits:$/m.exec(text);
|
|
763
|
-
if (lead === null)
|
|
764
|
-
return null;
|
|
765
|
-
const displayed = Number(lead[1]);
|
|
766
|
-
const total = Number(lead[2]);
|
|
767
|
-
const lines = text.split('\n');
|
|
768
|
-
const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
|
|
769
|
-
if (headerIdx === -1)
|
|
770
|
-
return null;
|
|
771
|
-
const header = splitTableRow(lines[headerIdx]);
|
|
772
|
-
const separator = splitTableRow(lines[headerIdx + 1]);
|
|
773
|
-
if (header === null || separator === null || header.length !== separator.length)
|
|
774
|
-
return null;
|
|
775
|
-
if (separator.some((cell) => !/^-+$/.test(cell)))
|
|
776
|
-
return null;
|
|
777
|
-
if (!header.includes('node') || !header.includes('ts') || !header.includes('kind'))
|
|
778
|
-
return null;
|
|
779
|
-
const rows = [];
|
|
780
|
-
let j = headerIdx + 2;
|
|
781
|
-
while (j < lines.length && lines[j].trim().startsWith('|')) {
|
|
782
|
-
const cells = splitTableRow(lines[j]);
|
|
783
|
-
if (cells === null || cells.length !== header.length)
|
|
784
|
-
return null;
|
|
785
|
-
const row = {};
|
|
786
|
-
header.forEach((h, i) => { row[h] = cells[i]; });
|
|
787
|
-
rows.push(row);
|
|
788
|
-
j++;
|
|
789
|
-
}
|
|
790
|
-
if (rows.length !== displayed)
|
|
791
|
-
return null;
|
|
792
|
-
const top = rows.slice(0, 3).map((r) => `${r['node']} · ${r['kind']} · ${r['ts']}`);
|
|
793
|
-
const out = [{ text: `${total} hit${total === 1 ? '' : 's'}` }];
|
|
794
|
-
for (const t of top)
|
|
795
|
-
out.push({ text: t, tone: 'muted' });
|
|
796
|
-
const remaining = rows.length - top.length;
|
|
797
|
-
if (remaining > 0)
|
|
798
|
-
out.push({ text: `… ${remaining} more shown`, tone: 'muted' });
|
|
799
|
-
return out;
|
|
800
|
-
}
|
|
801
|
-
/** `crtr canvas history read` (src/commands/canvas-history/read.ts:61):
|
|
802
|
-
* `${node} | source: ${source} | ts: ${ts} | ref: ${ref}\n\n${content}`
|
|
803
|
-
* — note the DOUBLE SPACES around each `|`. Mirrors summarizeMemoryRead's
|
|
804
|
-
* identity+teaser pattern (reusing MEMORY_READ_CONTENT_LINES). Returns null
|
|
805
|
-
* when the head line doesn't match this exact shape. */
|
|
806
|
-
function summarizeCanvasHistoryRead(text) {
|
|
807
|
-
const first = text.split('\n')[0] ?? '';
|
|
808
|
-
const head = /^(.+?) \| source: (.+?) \| ts: (.+?) \| ref: (.+)$/.exec(first);
|
|
809
|
-
if (head === null)
|
|
810
|
-
return null;
|
|
811
|
-
const [, node, source, ts] = head;
|
|
812
|
-
const rest = text.split('\n').slice(1);
|
|
813
|
-
let idx = 0;
|
|
814
|
-
while (idx < rest.length && rest[idx].trim() === '')
|
|
815
|
-
idx++;
|
|
816
|
-
const contentLines = rest.slice(idx);
|
|
817
|
-
const teaser = contentLines.slice(0, MEMORY_READ_CONTENT_LINES);
|
|
818
|
-
const hasMore = contentLines.slice(MEMORY_READ_CONTENT_LINES).some((l) => l.trim() !== '');
|
|
819
|
-
if (hasMore)
|
|
820
|
-
teaser.push('…');
|
|
821
|
-
const lines = [{ text: `${node} · ${source} · ${ts}` }];
|
|
822
|
-
if (teaser.some((l) => l.trim() !== '')) {
|
|
823
|
-
lines.push({ text: '' });
|
|
824
|
-
for (const l of teaser)
|
|
825
|
-
lines.push({ text: l });
|
|
826
|
-
}
|
|
827
|
-
return lines;
|
|
828
|
-
}
|
|
829
|
-
/** `crtr canvas history grep` (grepLeaf, src/commands/canvas-history/grep.ts,
|
|
830
|
-
* its own `renderGrep` at the bottom of that file) has the same `0 hits.` /
|
|
831
|
-
* `N of M hits:` lead-in shape as `canvas history search` but a DIFFERENT,
|
|
832
|
-
* fixed row shape — `{ref,node,ts,line,text}` (HistoryGrepHitDTO), never
|
|
833
|
-
* `kind`, since this leaf is an exact regex line search, not ranked/browse.
|
|
834
|
-
* Reuses splitTableRow for the same reason summarizeCanvasHistorySearch
|
|
835
|
-
* does. Returns null on any shape mismatch: a non-`---` separator cell, a
|
|
836
|
-
* missing `node`/`line`/`text` column, or a parsed row count that doesn't
|
|
837
|
-
* match the leading `N of M hits:` displayed count `N` (the page size —
|
|
838
|
-
* pagination can make it less than `M`, the grand total). */
|
|
839
|
-
function summarizeCanvasHistoryGrep(text) {
|
|
840
|
-
if (/^0 hits\.$/m.test(text))
|
|
841
|
-
return [{ text: '0 hits', tone: 'muted' }];
|
|
842
|
-
const lead = /^(\d+) of (\d+) hits:$/m.exec(text);
|
|
843
|
-
if (lead === null)
|
|
844
|
-
return null;
|
|
845
|
-
const displayed = Number(lead[1]);
|
|
846
|
-
const total = Number(lead[2]);
|
|
847
|
-
const lines = text.split('\n');
|
|
848
|
-
const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
|
|
849
|
-
if (headerIdx === -1)
|
|
850
|
-
return null;
|
|
851
|
-
const header = splitTableRow(lines[headerIdx]);
|
|
852
|
-
const separator = splitTableRow(lines[headerIdx + 1]);
|
|
853
|
-
if (header === null || separator === null || header.length !== separator.length)
|
|
854
|
-
return null;
|
|
855
|
-
if (separator.some((cell) => !/^-+$/.test(cell)))
|
|
856
|
-
return null;
|
|
857
|
-
if (!header.includes('node') || !header.includes('line') || !header.includes('text'))
|
|
858
|
-
return null;
|
|
859
|
-
const rows = [];
|
|
860
|
-
let j = headerIdx + 2;
|
|
861
|
-
while (j < lines.length && lines[j].trim().startsWith('|')) {
|
|
862
|
-
const cells = splitTableRow(lines[j]);
|
|
863
|
-
if (cells === null || cells.length !== header.length)
|
|
864
|
-
return null;
|
|
865
|
-
const row = {};
|
|
866
|
-
header.forEach((h, i) => { row[h] = cells[i]; });
|
|
867
|
-
rows.push(row);
|
|
868
|
-
j++;
|
|
869
|
-
}
|
|
870
|
-
if (rows.length !== displayed)
|
|
871
|
-
return null;
|
|
872
|
-
const top = rows.slice(0, 3).map((r) => `${r['node']} · line ${r['line']}: ${truncateLine(r['text'] ?? '', 80)}`);
|
|
873
|
-
const out = [{ text: `${total} hit${total === 1 ? '' : 's'}` }];
|
|
874
|
-
for (const t of top)
|
|
875
|
-
out.push({ text: t, tone: 'muted' });
|
|
876
|
-
const remaining = rows.length - top.length;
|
|
877
|
-
if (remaining > 0)
|
|
878
|
-
out.push({ text: `… ${remaining} more shown`, tone: 'muted' });
|
|
879
|
-
return out;
|
|
880
|
-
}
|
|
881
|
-
/** `crtr node inspect artifacts` (src/commands/node-inspect-artifacts.ts):
|
|
882
|
-
* `node: <name (id)>\n\n0 artifacts. | N artifacts:\n\n<table
|
|
883
|
-
* cols=ref,source,ts,detail>\n\n<follow_up>`. `source` values are
|
|
884
|
-
* colon-prefixed types (`report:final`, `report:update`, `doc`,
|
|
885
|
-
* `roadmap`). Reuses this file's `summarizeTopCounts` for the type
|
|
886
|
-
* breakdown. Returns null when the `node:` line, the table shape (a
|
|
887
|
-
* non-`---` separator cell), or a missing `source` column doesn't
|
|
888
|
-
* match — or when the parsed row count doesn't match the leading `N
|
|
889
|
-
* artifacts:` count (this render has no pagination, so the two must
|
|
890
|
-
* agree exactly). */
|
|
891
|
-
function summarizeNodeInspectArtifacts(text) {
|
|
892
|
-
const nodeMatch = /^node: (.+)$/m.exec(text);
|
|
893
|
-
if (nodeMatch === null)
|
|
894
|
-
return null;
|
|
895
|
-
if (/^0 artifacts\.$/m.test(text))
|
|
896
|
-
return [{ text: `${nodeMatch[1]} · 0 artifacts`, tone: 'muted' }];
|
|
897
|
-
const countMatch = /^(\d+) artifacts:$/m.exec(text);
|
|
898
|
-
if (countMatch === null)
|
|
899
|
-
return null;
|
|
900
|
-
const lines = text.split('\n');
|
|
901
|
-
const headerIdx = lines.findIndex((l) => l.trim().startsWith('| ref |'));
|
|
902
|
-
if (headerIdx === -1)
|
|
903
|
-
return null;
|
|
904
|
-
const header = splitTableRow(lines[headerIdx]);
|
|
905
|
-
const separator = splitTableRow(lines[headerIdx + 1]);
|
|
906
|
-
if (header === null || separator === null || header.length !== separator.length)
|
|
907
|
-
return null;
|
|
908
|
-
if (separator.some((cell) => !/^-+$/.test(cell)))
|
|
909
|
-
return null;
|
|
910
|
-
const sourceIdx = header.indexOf('source');
|
|
911
|
-
if (sourceIdx === -1)
|
|
912
|
-
return null;
|
|
913
|
-
const typeCounts = new Map();
|
|
914
|
-
let rowCount = 0;
|
|
915
|
-
let j = headerIdx + 2;
|
|
916
|
-
while (j < lines.length && lines[j].trim().startsWith('|')) {
|
|
917
|
-
const cells = splitTableRow(lines[j]);
|
|
918
|
-
if (cells === null || cells.length !== header.length)
|
|
919
|
-
return null;
|
|
920
|
-
const type = (cells[sourceIdx] ?? '').split(':')[0];
|
|
921
|
-
typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
|
|
922
|
-
rowCount++;
|
|
923
|
-
j++;
|
|
924
|
-
}
|
|
925
|
-
if (rowCount !== Number(countMatch[1]))
|
|
926
|
-
return null;
|
|
927
|
-
const digest = summarizeTopCounts(typeCounts, 5, (name, count) => `${name}s×${count}`);
|
|
928
|
-
return [{ text: `${countMatch[1]} artifacts` }, { text: digest, tone: 'muted' }];
|
|
929
|
-
}
|
|
930
|
-
/** `crtr node inspect context` renders a non-TTY roster as `Context roots
|
|
931
|
-
* (N):`, followed by one tab-delimited `label (self)\tfiles\tdir` row per
|
|
932
|
-
* root. Collapse the filesystem paths into a count and a compact root-name
|
|
933
|
-
* digest; a missing/malformed row is an unrecognized shape. */
|
|
934
|
-
function summarizeNodeInspectContext(text) {
|
|
935
|
-
const head = /^Context roots \((\d+)\):\n([\s\S]*)$/.exec(text);
|
|
936
|
-
if (head === null)
|
|
937
|
-
return null;
|
|
938
|
-
const rows = head[2].split('\n');
|
|
939
|
-
if (rows.length !== Number(head[1]) || rows.length === 0)
|
|
940
|
-
return null;
|
|
941
|
-
let files = 0;
|
|
942
|
-
const labels = [];
|
|
943
|
-
for (const row of rows) {
|
|
944
|
-
const match = /^ {2}(.+?)(?: \(self\))?\t(\d+) files\t(.+)$/.exec(row);
|
|
945
|
-
if (match === null)
|
|
946
|
-
return null;
|
|
947
|
-
labels.push(match[1]);
|
|
948
|
-
files += Number(match[2]);
|
|
949
|
-
}
|
|
950
|
-
const shown = labels.slice(0, 5);
|
|
951
|
-
const remaining = labels.length - shown.length;
|
|
952
|
-
return [
|
|
953
|
-
{ text: `${rows.length} context root${rows.length === 1 ? '' : 's'} · ${files} files` },
|
|
954
|
-
{ text: remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', '), tone: 'muted' },
|
|
955
|
-
];
|
|
956
|
-
}
|
|
957
|
-
/** `crtr node inspect transcript` returns the saved Markdown title and
|
|
958
|
-
* status/lifecycle line followed by raw message text. The collapsed card keeps
|
|
959
|
-
* only the fixed metadata; raw text can contain section-like Markdown. */
|
|
960
|
-
function summarizeNodeInspectTranscript(text) {
|
|
961
|
-
const head = /^# (.+)\n\nSaved transcript · ([^·\n]+) · ([^\n]+)\n\n([\s\S]*)$/.exec(text);
|
|
962
|
-
if (head === null)
|
|
963
|
-
return null;
|
|
964
|
-
const [, title, status, lifecycle, body] = head;
|
|
965
|
-
if (body === undefined)
|
|
966
|
-
return null;
|
|
967
|
-
const lines = [{ text: `${title} · ${status.trim()} · ${lifecycle.trim()}` }];
|
|
968
|
-
if (body === '' || body === '_No saved messages._\n')
|
|
969
|
-
lines.push({ text: 'no saved messages', tone: 'muted' });
|
|
970
|
-
return lines;
|
|
971
|
-
}
|
|
972
|
-
/** `crtr node fork` has one success receipt with a branching viewer clause.
|
|
973
|
-
* Keep the new root id and viewer disposition instead of truncating the
|
|
974
|
-
* explanatory fixed prose. */
|
|
975
|
-
function summarizeNodeFork(text) {
|
|
976
|
-
const match = /^Forked independent idle root (\S+)\. (Viewer opened beside the source(?: in session ([^.]+))?\.|No viewer opened because no tmux pane was available\.) It will make no model call until its first real user message\.$/.exec(text);
|
|
977
|
-
if (match === null)
|
|
978
|
-
return null;
|
|
979
|
-
const [, id, viewerSentence, session] = match;
|
|
980
|
-
const viewer = viewerSentence.startsWith('Viewer opened')
|
|
981
|
-
? `viewer opened${session !== undefined ? ` · ${session}` : ''}`
|
|
982
|
-
: 'no viewer · outside tmux';
|
|
983
|
-
return [{ text: `forked idle root ${id}`, tone: 'success' }, { text: viewer, tone: 'muted' }];
|
|
984
|
-
}
|
|
985
|
-
/** `crtr node worktree close` reports an already-landed fast-forward plus
|
|
986
|
-
* best-effort cleanup. Recognize only its full success receipt; errors retain
|
|
987
|
-
* their raw recovery guidance. */
|
|
988
|
-
function summarizeNodeWorktreeClose(text) {
|
|
989
|
-
const match = /^Managed worktree landed — its local base branch fast-forwarded to (\S+) at (\S+)\.(.*)$/s.exec(text);
|
|
990
|
-
if (match === null)
|
|
991
|
-
return null;
|
|
992
|
-
const [, branch, sha, tail] = match;
|
|
993
|
-
const noteAt = tail.indexOf(' Note: ');
|
|
994
|
-
const cleanup = noteAt === -1 ? tail : tail.slice(0, noteAt);
|
|
995
|
-
const note = noteAt === -1 ? undefined : tail.slice(noteAt + ' Note: '.length);
|
|
996
|
-
if (note !== undefined && note.trim() === '')
|
|
997
|
-
return null;
|
|
998
|
-
const worktreeRemoved = /^ Worktree removed at .+\.$/.test(cleanup);
|
|
999
|
-
if (!worktreeRemoved && cleanup !== '')
|
|
1000
|
-
return null;
|
|
1001
|
-
if (!worktreeRemoved && note === undefined)
|
|
1002
|
-
return null;
|
|
1003
|
-
return [
|
|
1004
|
-
{ text: `landed ${branch} · ${sha.slice(0, 12)}`, tone: 'success' },
|
|
1005
|
-
{ text: note === undefined ? 'worktree and branch cleaned up' : worktreeRemoved ? 'worktree removed · cleanup needs attention' : 'cleanup needs attention', tone: note === undefined ? 'muted' : 'warning' },
|
|
1006
|
-
];
|
|
1007
|
-
}
|
|
1008
|
-
/** `crtr sys daemon start` (src/commands/sys/daemon.ts:40, `spawnDaemon()` in
|
|
1009
|
-
* src/daemon/manage.ts:89) has no custom `render`; the generic renderer
|
|
1010
|
-
* bullets `{started:true,pid}` or `{started:false,existing_pid}`. `pid` is
|
|
1011
|
-
* unconditionally set alongside `started:true` (manage.ts:125) — a
|
|
1012
|
-
* true-with-no-pid shape is unrecognized, so it returns null rather than a
|
|
1013
|
-
* pid-less success line. `existing_pid` genuinely can be absent on the
|
|
1014
|
-
* false branch (a `readPidfile() ?? undefined` race), so that one stays
|
|
1015
|
-
* optional. Returns null when `started` isn't a plain boolean bullet, or
|
|
1016
|
-
* is `true` without a `pid` bullet. */
|
|
1017
|
-
function summarizeSysDaemonStart(text) {
|
|
1018
|
-
const m = /^- started:\s*(true|false)\s*$/m.exec(text);
|
|
1019
|
-
if (m === null)
|
|
1020
|
-
return null;
|
|
1021
|
-
if (m[1] === 'true') {
|
|
1022
|
-
const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
1023
|
-
if (pid === undefined)
|
|
1024
|
-
return null;
|
|
1025
|
-
return [{ text: `started · pid ${pid}`, tone: 'success' }];
|
|
1026
|
-
}
|
|
1027
|
-
const pid = /^- existing_pid:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
1028
|
-
return [{ text: `already running${pid !== undefined ? ` · pid ${pid}` : ''}`, tone: 'muted' }];
|
|
1029
|
-
}
|
|
1030
|
-
/** `crtr sys daemon status` (src/commands/sys/daemon.ts:65) has no custom
|
|
1031
|
-
* `render`; the generic renderer bullets `{running:true,pid}` or
|
|
1032
|
-
* `{running:false}`. `pid` is unconditionally set alongside `running:true`
|
|
1033
|
-
* (daemon.ts:70 — the true branch only fires once `readPidfile()` already
|
|
1034
|
-
* returned a pid), so a true-with-no-pid shape is unrecognized. Returns
|
|
1035
|
-
* null when `running` isn't a plain boolean bullet, or is `true` without a
|
|
1036
|
-
* `pid` bullet. */
|
|
1037
|
-
function summarizeSysDaemonStatus(text) {
|
|
1038
|
-
const m = /^- running:\s*(true|false)\s*$/m.exec(text);
|
|
1039
|
-
if (m === null)
|
|
1040
|
-
return null;
|
|
1041
|
-
if (m[1] === 'true') {
|
|
1042
|
-
const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
1043
|
-
if (pid === undefined)
|
|
1044
|
-
return null;
|
|
1045
|
-
return [{ text: `running · pid ${pid}`, tone: 'success' }];
|
|
1046
|
-
}
|
|
1047
|
-
return [{ text: 'not running', tone: 'error' }];
|
|
1048
|
-
}
|
|
1049
|
-
/** `crtr sys daemon stop` (src/commands/sys/daemon.ts:93) has no custom
|
|
1050
|
-
* `render`; the generic renderer bullets `{stopped:true,pid}` or
|
|
1051
|
-
* `{stopped:false}`. `pid` is unconditionally set alongside `stopped:true`
|
|
1052
|
-
* (daemon.ts:98 — the signaled pid is echoed back in the same return), so a
|
|
1053
|
-
* true-with-no-pid shape is unrecognized. Returns null when `stopped` isn't
|
|
1054
|
-
* a plain boolean bullet, or is `true` without a `pid` bullet. */
|
|
1055
|
-
function summarizeSysDaemonStop(text) {
|
|
1056
|
-
const m = /^- stopped:\s*(true|false)\s*$/m.exec(text);
|
|
1057
|
-
if (m === null)
|
|
1058
|
-
return null;
|
|
1059
|
-
if (m[1] === 'true') {
|
|
1060
|
-
const pid = /^- pid:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
1061
|
-
if (pid === undefined)
|
|
1062
|
-
return null;
|
|
1063
|
-
return [{ text: `stopped · pid ${pid}` }];
|
|
1064
|
-
}
|
|
1065
|
-
return [{ text: 'was not running', tone: 'muted' }];
|
|
1066
|
-
}
|
|
1067
|
-
/** `crtr canvas issue new` (src/commands/canvas-issue.ts) has no custom
|
|
1068
|
-
* `render`; the generic renderer bullets `{created:false}` (empty note,
|
|
1069
|
-
* line 125), `{created:true,target:'github',type,title,url}` (line 137),
|
|
1070
|
-
* or `{created:true,target:'file',type,title,path}` (line 159). `url` is
|
|
1071
|
-
* unconditionally set on every `target:'github'` return, and `path` on
|
|
1072
|
-
* every `target:'file'` return — so either missing (e.g. a >80-char
|
|
1073
|
-
* value core/render.ts's `isProse` rule pushed to prose form instead of a
|
|
1074
|
-
* bullet) is an unrecognized shape, not a success to report without the
|
|
1075
|
-
* field. Returns null when `created` isn't a plain boolean bullet, or the
|
|
1076
|
-
* target's required companion field isn't present as a bullet. */
|
|
1077
|
-
function summarizeCanvasIssueNew(text) {
|
|
1078
|
-
const createdMatch = /^- created:\s*(true|false)\s*$/m.exec(text);
|
|
1079
|
-
if (createdMatch === null)
|
|
1080
|
-
return null;
|
|
1081
|
-
if (createdMatch[1] === 'false')
|
|
1082
|
-
return [{ text: 'nothing captured', tone: 'muted' }];
|
|
1083
|
-
const target = /^- target:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1084
|
-
if (target === 'github') {
|
|
1085
|
-
const url = /^- url:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1086
|
-
if (url === undefined)
|
|
1087
|
-
return null;
|
|
1088
|
-
const num = /\/issues\/(\d+)$/.exec(url)?.[1];
|
|
1089
|
-
return [
|
|
1090
|
-
{ text: num !== undefined ? `issue #${num}` : 'issue filed', tone: 'success' },
|
|
1091
|
-
{ text: url, tone: 'muted' },
|
|
1092
|
-
];
|
|
1093
|
-
}
|
|
1094
|
-
if (target === 'file') {
|
|
1095
|
-
const path = /^- path:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1096
|
-
if (path === undefined)
|
|
1097
|
-
return null;
|
|
1098
|
-
return [
|
|
1099
|
-
{ text: 'note saved', tone: 'success' },
|
|
1100
|
-
{ text: basename(path), tone: 'muted' },
|
|
1101
|
-
];
|
|
1102
|
-
}
|
|
1103
|
-
return null;
|
|
1104
|
-
}
|
|
1105
|
-
/** Compact the immediate `crtr node new` result. */
|
|
1106
|
-
function summarizeNodeNew(text) {
|
|
1107
|
-
const spawned = /^Spawned "([^"]*)" \((\S+)\) — (\w+)/.exec(text);
|
|
1108
|
-
if (spawned !== null) {
|
|
1109
|
-
const [, name, id, status] = spawned;
|
|
1110
|
-
return [{ text: `spawned ${name} (${id}) · ${status}`, tone: 'success' }];
|
|
1111
|
-
}
|
|
1112
|
-
return null;
|
|
1113
|
-
}
|
|
1114
|
-
/** Compact immediate node-message receipts whose guidance is command-owned,
|
|
1115
|
-
* never user text. Other shapes fall through to generic truncation. */
|
|
1116
|
-
function summarizeNodeMessageEngine(text) {
|
|
1117
|
-
if (/^Delivered\.$/.test(text))
|
|
1118
|
-
return [{ text: 'delivered', tone: 'success' }];
|
|
1119
|
-
if (/^Delivered — the dormant target was revived to receive it\.$/.test(text)) {
|
|
1120
|
-
return [{ text: 'delivered · revived', tone: 'success' }];
|
|
1121
|
-
}
|
|
1122
|
-
if (/^Delivered — held for the target's next natural cycle; not revived \(deferred\)\.$/.test(text)) {
|
|
1123
|
-
return [{ text: 'delivered · held (deferred)', tone: 'accent' }];
|
|
1124
|
-
}
|
|
1125
|
-
const fresh = /^Fresh-revived (\S+) now/.exec(text);
|
|
1126
|
-
if (fresh !== null)
|
|
1127
|
-
return [{ text: `fresh-revived ${fresh[1]}`, tone: 'success' }];
|
|
1128
|
-
return null;
|
|
1129
|
-
}
|
|
1130
|
-
/** Compact the `crtr node wait deadline` receipt (nodeWaitDeadline.render):
|
|
1131
|
-
* `Deadline cron armed — <name> (<id>), fires <iso>.` followed by
|
|
1132
|
-
* command-owned guidance prose. Other shapes fall through to generic
|
|
1133
|
-
* truncation. */
|
|
1134
|
-
function summarizeNodeWaitDeadline(text) {
|
|
1135
|
-
const m = /^Deadline cron armed — (\S+) \((\S+)\), fires (\S+)\.\s*$/m.exec(text);
|
|
1136
|
-
if (m === null)
|
|
1137
|
-
return null;
|
|
1138
|
-
const [, , id, firesAt] = m;
|
|
1139
|
-
return [
|
|
1140
|
-
{ text: `deadline armed — fires ${firesAt}`, tone: 'accent' },
|
|
1141
|
-
{ text: id, tone: 'muted' },
|
|
1142
|
-
];
|
|
1143
|
-
}
|
|
1144
|
-
/** `crtr node yield` (`src/commands/node/lifecycle.ts`) has no custom `render`;
|
|
1145
|
-
* the generic renderer bullets `{node_id, promoted, model?, will_refresh}`
|
|
1146
|
-
* — none of these names are PROSE_FIELDS. `will_refresh` is documented
|
|
1147
|
-
* "Always true" (the command has no path that returns false), so
|
|
1148
|
-
* `- will_refresh: false` is itself an unrecognized/error shape, not an
|
|
1149
|
-
* alternate success to report armed. Returns null unless `will_refresh` is
|
|
1150
|
-
* exactly `true`, or `node_id` isn't present as a plain bullet. */
|
|
1151
|
-
function summarizeNodeYield(text) {
|
|
1152
|
-
if (!/^- will_refresh:\s*true\s*$/m.test(text))
|
|
1153
|
-
return null;
|
|
1154
|
-
const nodeId = /^- node_id:\s*(\S+)\s*$/m.exec(text);
|
|
1155
|
-
if (nodeId === null)
|
|
1156
|
-
return null;
|
|
1157
|
-
const promoted = /^- promoted:\s*true\s*$/m.test(text);
|
|
1158
|
-
const model = /^- model:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1159
|
-
const extras = [];
|
|
1160
|
-
if (promoted)
|
|
1161
|
-
extras.push('promoted → orchestrator');
|
|
1162
|
-
if (model !== undefined)
|
|
1163
|
-
extras.push(`model: ${model}`);
|
|
1164
|
-
const lines = [{ text: 'yield armed — refreshes on next wake', tone: 'accent' }];
|
|
1165
|
-
if (extras.length > 0)
|
|
1166
|
-
lines.push({ text: extras.join(' · '), tone: 'muted' });
|
|
1167
|
-
return lines;
|
|
1168
|
-
}
|
|
1169
|
-
/** `crtr node lifecycle close` (`nodeClose.render` in `src/commands/node/lifecycle.ts`):
|
|
1170
|
-
* `Closed ${id} and its exclusive subtree — ${count} node(s)
|
|
1171
|
-
* closed${spared>0 ? ", ${spared} spared..." : ''}.` on success, or a
|
|
1172
|
-
* short failure line — left as null there, the generic fallback is fine for
|
|
1173
|
-
* a one-liner. */
|
|
1174
|
-
function summarizeNodeLifecycleClose(text) {
|
|
1175
|
-
const m = /^Closed (\S+) and its exclusive subtree — (\d+) node\(s\) closed(?:, (\d+) spared)?/.exec(text);
|
|
1176
|
-
if (m === null)
|
|
1177
|
-
return null;
|
|
1178
|
-
const [, id, countStr, sparedStr] = m;
|
|
1179
|
-
const count = Number(countStr);
|
|
1180
|
-
const descendants = count - 1;
|
|
1181
|
-
const lines = [{ text: `closed ${id}`, tone: 'success' }];
|
|
1182
|
-
if (descendants > 0)
|
|
1183
|
-
lines.push({ text: `${descendants} descendant${descendants === 1 ? '' : 's'} canceled`, tone: 'warning' });
|
|
1184
|
-
if (sparedStr !== undefined)
|
|
1185
|
-
lines.push({ text: `${sparedStr} spared (managed elsewhere)`, tone: 'muted' });
|
|
1186
|
-
return lines;
|
|
1187
|
-
}
|
|
1188
|
-
/** `crtr push final` (makeTierLeaf's shared render, src/commands/push.ts:161)
|
|
1189
|
-
* leads with a fixed sentence ('Result recorded — node finished; ...')
|
|
1190
|
-
* followed unconditionally by `\n\n- report: ${report_path}` (`report_path`
|
|
1191
|
-
* is a required output field, push.ts:94). Returns null when that lead
|
|
1192
|
-
* sentence isn't present, or the `- report:` line is missing (an
|
|
1193
|
-
* unrecognized/error shape either way). */
|
|
1194
|
-
function summarizePushFinal(text) {
|
|
1195
|
-
if (!/^Result recorded — node finished/.test(text))
|
|
1196
|
-
return null;
|
|
1197
|
-
const report = /^- report: (.+)$/m.exec(text)?.[1];
|
|
1198
|
-
if (report === undefined)
|
|
1199
|
-
return null;
|
|
1200
|
-
return [
|
|
1201
|
-
{ text: 'final pushed · node done', tone: 'success' },
|
|
1202
|
-
{ text: basename(report.trim()), tone: 'muted' },
|
|
1203
|
-
];
|
|
1204
|
-
}
|
|
1205
|
-
/** `crtr push urgent` (makeTierLeaf's shared render, src/commands/push.ts:161):
|
|
1206
|
-
* `Urgent report fanned to ${n} subscriber(s) — they are force-woken.\n\n-
|
|
1207
|
-
* report: ${report_path}`, with the same unconditional `- report:` line as
|
|
1208
|
-
* push final. Returns null when the lead sentence doesn't match, or the
|
|
1209
|
-
* `- report:` line is missing. */
|
|
1210
|
-
function summarizePushUrgent(text) {
|
|
1211
|
-
const m = /^Urgent report fanned to (\d+) subscriber/.exec(text);
|
|
1212
|
-
if (m === null)
|
|
1213
|
-
return null;
|
|
1214
|
-
if (/^- report: (.+)$/m.exec(text) === null)
|
|
1215
|
-
return null;
|
|
1216
|
-
return [{ text: `urgent → ${m[1]} subscribers woken`, tone: 'warning' }];
|
|
1217
|
-
}
|
|
1218
|
-
// ---------------------------------------------------------------------------
|
|
1219
|
-
// Batch C — human/memory/pkg. Shared helpers for the two output shapes
|
|
1220
|
-
// renderResult (core/render.ts) can give the SAME logical field depending on
|
|
1221
|
-
// string length/newlines (isProse): a short value is a `- field: value`
|
|
1222
|
-
// bullet; a long/multiline one is prose — labeled `**field:** text` when 2+
|
|
1223
|
-
// prose fields are present, or a bare unlabeled paragraph when it's the SOLE
|
|
1224
|
-
// prose field (renderResult only labels when there's more than one). Several
|
|
1225
|
-
// leaves below carry a field (a path, typically) that crosses this threshold
|
|
1226
|
-
// depending on the user's home directory length, so both forms are handled
|
|
1227
|
-
// rather than assumed.
|
|
1228
|
-
// ---------------------------------------------------------------------------
|
|
1229
|
-
/** Read a `- field: value` bullet or a `**field:** text` labeled-prose block
|
|
1230
|
-
* for the same logical field — the two shapes renderResult can produce once
|
|
1231
|
-
* 2+ prose fields are present (see block comment above). Does NOT recognize
|
|
1232
|
-
* a bare unlabeled solo-prose paragraph (see soloProseAfterBullets for
|
|
1233
|
-
* that); a leaf with exactly one long field needs that instead. */
|
|
1234
|
-
function readFieldFlexible(text, field) {
|
|
1235
|
-
const bullet = new RegExp(`^-\\s+${field}:\\s*(.+)$`, 'm').exec(text);
|
|
1236
|
-
if (bullet !== null)
|
|
1237
|
-
return bullet[1].trim();
|
|
1238
|
-
const labeled = new RegExp(`^\\*\\*${field}:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\n|$)`, 'm').exec(text);
|
|
1239
|
-
return labeled !== null ? labeled[1].trim() : undefined;
|
|
1240
|
-
}
|
|
1241
|
-
/** Recover the SOLE prose field's text when it renders as a bare, unlabeled
|
|
1242
|
-
* paragraph after the bullets block — renderResult's shape for exactly one
|
|
1243
|
-
* long/multiline field among otherwise-short scalars (see block comment
|
|
1244
|
-
* above). Requires at least one bullet line above it (otherwise this isn't
|
|
1245
|
-
* the bullets-then-solo-paragraph shape at all) and bails if what follows
|
|
1246
|
-
* looks like a labeled prose block or an array heading instead of a bare
|
|
1247
|
-
* paragraph — never misparse a different shape as this one. */
|
|
1248
|
-
function soloProseAfterBullets(text) {
|
|
1249
|
-
const lines = text.split('\n');
|
|
1250
|
-
let i = 0;
|
|
1251
|
-
while (i < lines.length && /^- /.test(lines[i]))
|
|
1252
|
-
i++;
|
|
1253
|
-
if (i === 0)
|
|
1254
|
-
return undefined;
|
|
1255
|
-
while (i < lines.length && lines[i].trim() === '')
|
|
1256
|
-
i++;
|
|
1257
|
-
if (i >= lines.length)
|
|
1258
|
-
return undefined;
|
|
1259
|
-
if (/^\*\*\w+:\*\*/.test(lines[i]) || /^\d+ \S+:$/.test(lines[i]) || /^0 \S+\.$/.test(lines[i]))
|
|
1260
|
-
return undefined;
|
|
1261
|
-
const para = [];
|
|
1262
|
-
while (i < lines.length && lines[i].trim() !== '') {
|
|
1263
|
-
para.push(lines[i]);
|
|
1264
|
-
i++;
|
|
1265
|
-
}
|
|
1266
|
-
const text2 = para.join(' ').trim();
|
|
1267
|
-
return text2 === '' ? undefined : text2;
|
|
1268
|
-
}
|
|
1269
|
-
/** Find the first argv token after `skip` that isn't a flag, skipping the
|
|
1270
|
-
* value token that follows any flag listed in `valueFlags` (a boolean flag
|
|
1271
|
-
* with no separate value is skipped on its own via the `startsWith('--')`
|
|
1272
|
-
* check). Generalizes firstPositionalAfter (above) for leaves that mix
|
|
1273
|
-
* boolean and value-taking flags ahead of their positional. */
|
|
1274
|
-
function firstPositionalArg(tokens, skip, valueFlags) {
|
|
1275
|
-
for (let i = skip; i < tokens.length; i++) {
|
|
1276
|
-
const t = tokens[i];
|
|
1277
|
-
if (t.startsWith('--')) {
|
|
1278
|
-
if (valueFlags.has(t))
|
|
1279
|
-
i++;
|
|
1280
|
-
continue;
|
|
1281
|
-
}
|
|
1282
|
-
return t;
|
|
1283
|
-
}
|
|
1284
|
-
return undefined;
|
|
1285
|
-
}
|
|
1286
|
-
/** Truncate a single line of text to `max` chars for a muted-aside teaser,
|
|
1287
|
-
* appending an ellipsis when it was cut. */
|
|
1288
214
|
function truncateLine(line, max) {
|
|
1289
215
|
return line.length > max ? `${line.slice(0, max).trimEnd()}…` : line;
|
|
1290
216
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
* prefix `Queued for the human` (queuedFollowUp, prompts.ts — its inbox-
|
|
1297
|
-
* affordance tail resolves per call, the prefix never varies) appearing
|
|
1298
|
-
* anywhere in the text — never by assuming dir/follow_up's bullet vs. prose
|
|
1299
|
-
* form. Arrival is always just durable queue state, so the card collapses to
|
|
1300
|
-
* one posted line. The
|
|
1301
|
-
* question text isn't in the output at all (only the bare-question invocation
|
|
1302
|
-
* echoes it), so it's recovered from argv: the first non-flag token after
|
|
1303
|
-
* `human ask`, skipping `--context-file`'s value — absent for a
|
|
1304
|
-
* `--context-file` deck invocation, which still gets the road-sign line with
|
|
1305
|
-
* no question teaser. Returns null when the road-sign is absent (an
|
|
1306
|
-
* error/unrecognized shape). */
|
|
1307
|
-
function summarizeHumanAsk(text, tokens) {
|
|
1308
|
-
const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1309
|
-
if (jobId === undefined)
|
|
1310
|
-
return null;
|
|
1311
|
-
if (!text.includes('Queued for the human'))
|
|
1312
|
-
return null;
|
|
1313
|
-
const lines = [{ text: 'ask posted → waiting on human', tone: 'accent' }];
|
|
1314
|
-
const question = firstPositionalArg(tokens, 2, new Set(['--context-file']));
|
|
1315
|
-
if (question !== undefined) {
|
|
1316
|
-
const firstLine = truncateLine(question.split('\n')[0].trim(), ASK_TEASER_CHARS);
|
|
1317
|
-
if (firstLine !== '')
|
|
1318
|
-
lines.push({ text: firstLine, tone: 'muted' });
|
|
1319
|
-
}
|
|
1320
|
-
return lines;
|
|
1321
|
-
}
|
|
1322
|
-
/** `crtr human review new` (humanReviewNew.run,
|
|
1323
|
-
* src/commands/human/review.ts) returns `{job_id, dir, output, review_id,
|
|
1324
|
-
* follow_up}`. A ticket bridge id plus the daemon-owned review id and the
|
|
1325
|
-
* stable live-review road-sign distinguish this canonical result from an
|
|
1326
|
-
* error or another review leaf. The reviewed file is recovered from argv:
|
|
1327
|
-
* it is the positional after `human review new`, skipping the two value
|
|
1328
|
-
* flags. Returns null when that canonical result shape is absent. */
|
|
1329
|
-
function summarizeHumanReviewNew(text, tokens) {
|
|
1330
|
-
const jobId = readFieldFlexible(text, 'job_id');
|
|
1331
|
-
const reviewId = readFieldFlexible(text, 'review_id');
|
|
1332
|
-
if (jobId === undefined || reviewId === undefined)
|
|
1333
|
-
return null;
|
|
1334
|
-
if (!text.includes('The review is live for the person in the attach viewer with its companion agent.'))
|
|
1335
|
-
return null;
|
|
1336
|
-
const file = firstPositionalArg(tokens, 3, new Set(['--output', '--subtitle']));
|
|
1337
|
-
const label = file !== undefined ? `review posted: ${basename(file)}` : 'review posted';
|
|
1338
|
-
return [{ text: `${label} → live with companion`, tone: 'accent' }];
|
|
1339
|
-
}
|
|
1340
|
-
/** `crtr human list` (humanList.run, src/commands/human/queue.ts:283) has no
|
|
1341
|
-
* custom `render`; the generic renderer bullets `- total: N` then an
|
|
1342
|
-
* `N items:` table (or `0 items.`), columns including `kind` (the deck's
|
|
1343
|
-
* own interaction kind — `decision`/`question`/etc, often empty for a plain
|
|
1344
|
-
* freeform ask; NOT whether it was posed via `ask` vs `review`). Returns
|
|
1345
|
-
* null when the total/items shape doesn't match, or when `total` is
|
|
1346
|
-
* nonzero but the returned page is empty (exhausted pagination, not a
|
|
1347
|
-
* true zero total — collapsing that to "none pending" would misreport). */
|
|
1348
|
-
function summarizeHumanList(text) {
|
|
1349
|
-
const total = parseIntBullet(text, 'total');
|
|
1350
|
-
const rows = parseTableRows(text, 'items');
|
|
1351
|
-
if (total === null || rows === null)
|
|
1352
|
-
return null;
|
|
1353
|
-
if (total === 0)
|
|
1354
|
-
return [{ text: 'none pending', tone: 'success' }];
|
|
1355
|
-
if (rows.length === 0)
|
|
1356
|
-
return null;
|
|
1357
|
-
const kinds = new Map();
|
|
1358
|
-
for (const row of rows) {
|
|
1359
|
-
const kind = row['kind'];
|
|
1360
|
-
if (kind !== undefined && kind !== '')
|
|
1361
|
-
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
1362
|
-
}
|
|
1363
|
-
const lines = [{ text: `${total} pending` }];
|
|
1364
|
-
const kindDigest = summarizeTopCounts(kinds, 5, (k, c) => `${k}×${c}`);
|
|
1365
|
-
if (kindDigest !== '')
|
|
1366
|
-
lines.push({ text: kindDigest, tone: 'muted' });
|
|
1367
|
-
return lines;
|
|
1368
|
-
}
|
|
1369
|
-
const DECK_PROMPT_TEASER_CHARS = 160;
|
|
1370
|
-
/** `crtr human deck` (humanDeck.run, src/commands/human/queue.ts:319) bullets
|
|
1371
|
-
* `id`/`interaction_count` (always short) then an `N interactions:` table
|
|
1372
|
-
* (columns `id,kind,prompt,options,allow_freetext`). `options` is a
|
|
1373
|
-
* cellEsc'd array of JSON-stringified `{id,label,...}` objects joined by
|
|
1374
|
-
* `, ` (core/render.ts's scalarStr for an array of objects) — counted via
|
|
1375
|
-
* the `"id":` occurrences each option object carries, not by splitting on
|
|
1376
|
-
* `, ` (a label/description could itself contain a comma). Only the FIRST
|
|
1377
|
-
* interaction is teased — decks built by `ask`/`review` always have exactly
|
|
1378
|
-
* one; a multi-interaction context-file deck gets an `interaction_count`
|
|
1379
|
-
* aside instead of every prompt. Returns null when the `id`/
|
|
1380
|
-
* `interaction_count` bullets or the interactions table don't match, or
|
|
1381
|
-
* when `allow_freetext` isn't exactly `true`/`false` (a malformed/
|
|
1382
|
-
* truncated row must not silently default to "no freetext"). */
|
|
1383
|
-
function summarizeHumanDeck(text) {
|
|
1384
|
-
const id = /^- id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1385
|
-
const interactionCount = parseIntBullet(text, 'interaction_count');
|
|
1386
|
-
if (id === undefined || interactionCount === null)
|
|
1387
|
-
return null;
|
|
1388
|
-
const rows = parseTableRows(text, 'interactions');
|
|
1389
|
-
if (rows === null || rows.length === 0)
|
|
1390
|
-
return null;
|
|
1391
|
-
const first = rows[0];
|
|
1392
|
-
const prompt = first['prompt'];
|
|
1393
|
-
if (prompt === undefined || prompt === '')
|
|
1394
|
-
return null;
|
|
1395
|
-
const optionCount = (first['options'] ?? '').match(/"id":/g)?.length ?? 0;
|
|
1396
|
-
const allowFreetextRaw = first['allow_freetext'];
|
|
1397
|
-
if (allowFreetextRaw !== 'true' && allowFreetextRaw !== 'false')
|
|
1398
|
-
return null;
|
|
1399
|
-
const allowFreetext = allowFreetextRaw === 'true';
|
|
1400
|
-
const lines = [{ text: truncateLine(prompt, DECK_PROMPT_TEASER_CHARS) }];
|
|
1401
|
-
const parts = [];
|
|
1402
|
-
if (optionCount > 0)
|
|
1403
|
-
parts.push(`${optionCount} option${optionCount === 1 ? '' : 's'}`);
|
|
1404
|
-
if (allowFreetext)
|
|
1405
|
-
parts.push('freetext');
|
|
1406
|
-
if (interactionCount > 1)
|
|
1407
|
-
parts.push(`${interactionCount} interactions`);
|
|
1408
|
-
if (parts.length > 0)
|
|
1409
|
-
lines.push({ text: parts.join(' · '), tone: 'muted' });
|
|
1410
|
-
return lines;
|
|
1411
|
-
}
|
|
1412
|
-
/** Notify has a durable queue receipt but its interaction directory becomes
|
|
1413
|
-
* prose on long homes. The title is the human-visible asset, recovered from
|
|
1414
|
-
* argv rather than exposing filesystem plumbing in the collapsed card. */
|
|
1415
|
-
function summarizeHumanNotify(text, tokens) {
|
|
1416
|
-
if (!/^- queued:\s*true\s*$/m.test(text))
|
|
1417
|
-
return null;
|
|
1418
|
-
const dir = readFieldFlexible(text, 'dir') ?? soloProseAfterBullets(text);
|
|
1419
|
-
const title = firstPositionalArg(tokens, 2, new Set(['--body']));
|
|
1420
|
-
if (dir === undefined || dir === '' || title === undefined || title.trim() === '')
|
|
1421
|
-
return null;
|
|
1422
|
-
return [
|
|
1423
|
-
{ text: 'notification queued', tone: 'success' },
|
|
1424
|
-
{ text: truncateLine(title.trim(), ASK_TEASER_CHARS), tone: 'muted' },
|
|
1425
|
-
];
|
|
1426
|
-
}
|
|
1427
|
-
/** Show either returns a short pane id or one of two intentional no-display
|
|
1428
|
-
* reasons. Any other prose is an unrecognized/error shape and stays raw. */
|
|
1429
|
-
function summarizeHumanShow(text) {
|
|
1430
|
-
const pane = /^- pane_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1431
|
-
if (pane !== undefined) {
|
|
1432
|
-
if (text.trim() !== `- pane_id: ${pane}`)
|
|
1433
|
-
return null;
|
|
1434
|
-
return [
|
|
1435
|
-
{ text: 'display opened', tone: 'success' },
|
|
1436
|
-
{ text: `pane ${pane}`, tone: 'muted' },
|
|
1437
|
-
];
|
|
1438
|
-
}
|
|
1439
|
-
const reason = text.trim();
|
|
1440
|
-
if (reason !== 'not in tmux' && reason !== 'renderer unavailable (termrender/uv missing)')
|
|
1441
|
-
return null;
|
|
1442
|
-
return [
|
|
1443
|
-
{ text: 'not displayed', tone: 'warning' },
|
|
1444
|
-
{ text: reason, tone: 'muted' },
|
|
1445
|
-
];
|
|
1446
|
-
}
|
|
1447
|
-
/** Cancel has a bespoke but verbose receipt; preserve its real success/no-op
|
|
1448
|
-
* distinction while reducing the queue action to outcome plus interaction id. */
|
|
1449
|
-
function summarizeHumanCancel(text) {
|
|
1450
|
-
const canceled = /^Canceled human interaction (\S+) — it left the human inbox and subscribers were notified no answer is coming\.$/.exec(text)?.[1];
|
|
1451
|
-
if (canceled !== undefined) {
|
|
1452
|
-
return [
|
|
1453
|
-
{ text: `canceled ${canceled}`, tone: 'success' },
|
|
1454
|
-
{ text: 'human inbox cleared', tone: 'muted' },
|
|
1455
|
-
];
|
|
1456
|
-
}
|
|
1457
|
-
const alreadyResolved = /^Nothing to cancel for (\S+) — already_resolved\.$/.exec(text)?.[1];
|
|
1458
|
-
return alreadyResolved === undefined ? null : [{ text: `already resolved · ${alreadyResolved}`, tone: 'muted' }];
|
|
1459
|
-
}
|
|
1460
|
-
/** Resolve uses bullets for a winning result; a losing race adds a sole prose
|
|
1461
|
-
* reason. Keep the finalized outcome distinct from claimed/already-resolved. */
|
|
1462
|
-
function summarizeHumanResolve(text) {
|
|
1463
|
-
const resolved = /^- resolved:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
1464
|
-
const jobId = /^- job_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1465
|
-
if (resolved === undefined || jobId === undefined)
|
|
1466
|
-
return null;
|
|
1467
|
-
if (resolved === 'true')
|
|
1468
|
-
return [{ text: `resolved ${jobId}`, tone: 'success' }];
|
|
1469
|
-
const reason = soloProseAfterBullets(text);
|
|
1470
|
-
if (reason !== 'already_resolved' && reason !== 'claimed')
|
|
1471
|
-
return null;
|
|
1472
|
-
return [
|
|
1473
|
-
{ text: `not resolved · ${jobId}`, tone: 'warning' },
|
|
1474
|
-
{ text: reason === 'claimed' ? 'another resolver holds the claim' : 'already resolved', tone: 'muted' },
|
|
1475
|
-
];
|
|
1476
|
-
}
|
|
1477
|
-
/** `crtr memory list` (listLeaf.run, src/commands/memory/list.ts) has no
|
|
1478
|
-
* custom `render`; the generic renderer bullets `- total: N` (next_cursor
|
|
1479
|
-
* omitted entirely when null — core/render.ts skips null/undefined fields)
|
|
1480
|
-
* then the follow_up prose then an `N items:` table (columns
|
|
1481
|
-
* `name,short_form,kind,scope,is_dir`, plus `path` only with --paths;
|
|
1482
|
-
* `title` was dropped from this leaf's output). `total` is the true corpus
|
|
1483
|
-
* count across every page; the kind/scope digest below is computed from the
|
|
1484
|
-
* RETURNED PAGE only — with the default 50-per-page limit this matches
|
|
1485
|
-
* `total` in practice, but a narrower --limit would undercount against the
|
|
1486
|
-
* true total (the same page-vs-total caveat other paginated summaries in
|
|
1487
|
-
* this file carry). Returns null when the total/items shape doesn't match,
|
|
1488
|
-
* or when `total` is nonzero but the returned page is empty (exhausted
|
|
1489
|
-
* pagination, not a true zero total). */
|
|
1490
|
-
function summarizeMemoryList(text) {
|
|
1491
|
-
const total = parseIntBullet(text, 'total');
|
|
1492
|
-
const rows = parseTableRows(text, 'items');
|
|
1493
|
-
if (total === null || rows === null)
|
|
1494
|
-
return null;
|
|
1495
|
-
if (total === 0)
|
|
1496
|
-
return [{ text: '0 docs', tone: 'muted' }];
|
|
1497
|
-
if (rows.length === 0)
|
|
1498
|
-
return null;
|
|
1499
|
-
const kinds = new Map();
|
|
1500
|
-
const scopes = new Map();
|
|
1501
|
-
for (const row of rows) {
|
|
1502
|
-
const kind = row['kind'];
|
|
1503
|
-
const scope = row['scope'];
|
|
1504
|
-
if (kind === undefined || kind === '' || scope === undefined || scope === '')
|
|
1505
|
-
return null;
|
|
1506
|
-
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
1507
|
-
scopes.set(scope, (scopes.get(scope) ?? 0) + 1);
|
|
1508
|
-
}
|
|
1509
|
-
const kindDigest = summarizeTopCounts(kinds, 5, (k, c) => `${k}×${c}`);
|
|
1510
|
-
const scopeDigest = summarizeTopCounts(scopes, 5, (s, c) => `${s}×${c}`);
|
|
1511
|
-
if (kindDigest === '' || scopeDigest === '')
|
|
1512
|
-
return null;
|
|
1513
|
-
return [
|
|
1514
|
-
{ text: `${total} docs` },
|
|
1515
|
-
{ text: `${kindDigest} / ${scopeDigest}`, tone: 'muted' },
|
|
1516
|
-
];
|
|
1517
|
-
}
|
|
1518
|
-
const MEMORY_FIND_NAME_CAP = 5;
|
|
1519
|
-
/** `crtr memory find` bullets `- query: <query>` then prose follow_up, then
|
|
1520
|
-
* either `0 hits.` or an `N hits:` table. Ranked rows have `name` and `score`;
|
|
1521
|
-
* grep rows have `name`, `line`, and `text`. Grep is deliberately rejected
|
|
1522
|
-
* (null, generic fallback) so the matching text remains visible. Results are
|
|
1523
|
-
* paginated now (--cursor replaces the old --all; next_cursor renders as
|
|
1524
|
-
* either a short bullet or, past ~80 chars, labeled/unlabeled prose — this
|
|
1525
|
-
* summarizer never reads next_cursor, so neither shape affects it). Returns
|
|
1526
|
-
* null when the query bullet or ranked table shape does not match. */
|
|
1527
|
-
function summarizeMemoryFind(text) {
|
|
1528
|
-
const query = /^- query:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
|
1529
|
-
if (query === undefined)
|
|
1530
|
-
return null;
|
|
1531
|
-
if (/^0 hits\.$/m.test(text))
|
|
1532
|
-
return [{ text: `0 hits for "${query}"`, tone: 'muted' }];
|
|
1533
|
-
const rows = parseTableRows(text, 'hits');
|
|
1534
|
-
if (rows === null || rows.length === 0)
|
|
1535
|
-
return null;
|
|
1536
|
-
if (rows.some((r) => r['score'] === undefined || r['score'] === ''))
|
|
1537
|
-
return null;
|
|
1538
|
-
const names = rows.map((r) => r['name']);
|
|
1539
|
-
if (names.some((n) => n === undefined || n === ''))
|
|
1540
|
-
return null;
|
|
1541
|
-
const shown = names.slice(0, MEMORY_FIND_NAME_CAP);
|
|
1542
|
-
const remaining = names.length - shown.length;
|
|
1543
|
-
const namesLine = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
|
|
1544
|
-
return [
|
|
1545
|
-
{ text: `${rows.length} hit${rows.length === 1 ? '' : 's'} for "${query}"` },
|
|
1546
|
-
{ text: namesLine, tone: 'muted' },
|
|
1547
|
-
];
|
|
1548
|
-
}
|
|
1549
|
-
/** `crtr memory write` confirms the invocation's selected frontmatter as an
|
|
1550
|
-
* ordered `{key,value}` table after its scalar identity fields and prose.
|
|
1551
|
-
* The collapsed card keeps the outcome first, then formats those choices as
|
|
1552
|
-
* aligned YAML-like key/value rows. Inherited update fields are intentionally
|
|
1553
|
-
* absent because the command result only returns options selected now.
|
|
1554
|
-
* Returns null when the identity or frontmatter table is unrecognized. */
|
|
1555
|
-
function summarizeMemoryWrite(text) {
|
|
1556
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1557
|
-
const createdMatch = /^- created:\s*(true|false)\s*$/m.exec(text);
|
|
1558
|
-
const frontmatter = parseTableRows(text, 'frontmatter');
|
|
1559
|
-
if (name === undefined || createdMatch === null || frontmatter === null || frontmatter.length === 0)
|
|
1560
|
-
return null;
|
|
1561
|
-
if (frontmatter.some((row) => row['key'] === undefined || row['key'] === '' || row['value'] === undefined))
|
|
1562
|
-
return null;
|
|
1563
|
-
const created = createdMatch[1] === 'true';
|
|
1564
|
-
return [
|
|
1565
|
-
{ text: `${created ? 'wrote' : 'updated'} ${name}`, tone: 'success' },
|
|
1566
|
-
{ text: '' },
|
|
1567
|
-
...keyValueLines(frontmatter.map((row) => [row['key'], row['value']])),
|
|
1568
|
-
];
|
|
1569
|
-
}
|
|
1570
|
-
/** `crtr node config` renders one outcome line containing only fields changed
|
|
1571
|
-
* by this call (`Configured <id> — key=value, ...`). Turn that dense receipt
|
|
1572
|
-
* into the shared mutation-card grammar: outcome first, then aligned changed
|
|
1573
|
-
* values. */
|
|
1574
|
-
function summarizeNodeConfig(text) {
|
|
1575
|
-
const match = /^Configured\s+(\S+)\s+—\s+(.+)$/m.exec(text);
|
|
1576
|
-
if (match === null)
|
|
1577
|
-
return null;
|
|
1578
|
-
const entries = [...match[2].matchAll(/(?:^|, )(model|lifecycle|kind|mode|name)=([^,]*(?:,(?! (?:model|lifecycle|kind|mode|name)=)[^,]*)*)/g)]
|
|
1579
|
-
.map((item) => [item[1], item[2]]);
|
|
1580
|
-
if (entries.length === 0)
|
|
1581
|
-
return null;
|
|
1582
|
-
return [
|
|
1583
|
-
{ text: `configured ${match[1]}`, tone: 'success' },
|
|
1584
|
-
{ text: '' },
|
|
1585
|
-
...keyValueLines(entries),
|
|
1586
|
-
];
|
|
1587
|
-
}
|
|
1588
|
-
/** Compact an inspection result into a primary state line plus a bounded set
|
|
1589
|
-
* of muted facts. Profile and config reads both use this
|
|
1590
|
-
* rather than each inventing a bespoke card grammar. */
|
|
1591
|
-
const INSPECTION_DETAIL_CAP = 5;
|
|
1592
|
-
function inspectionGlance(head, details) {
|
|
1593
|
-
const shown = details.slice(0, INSPECTION_DETAIL_CAP);
|
|
1594
|
-
const lines = [head, ...shown.map((text) => ({ text, tone: 'muted' }))];
|
|
1595
|
-
const remaining = details.length - shown.length;
|
|
1596
|
-
if (remaining > 0)
|
|
1597
|
-
lines.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
1598
|
-
return lines;
|
|
1599
|
-
}
|
|
1600
|
-
/** `crtr profile list` is JSONL rather than the generic markdown renderer:
|
|
1601
|
-
* one profile record per line followed by exactly one follow_up record.
|
|
1602
|
-
* Validate every record so an error or unrelated JSON stream stays raw. */
|
|
1603
|
-
function summarizeProfileList(text) {
|
|
1604
|
-
const lines = text.split('\n').filter((line) => line !== '');
|
|
1605
|
-
if (lines.length === 0)
|
|
1606
|
-
return null;
|
|
1607
|
-
const records = [];
|
|
1608
|
-
try {
|
|
1609
|
-
for (const line of lines)
|
|
1610
|
-
records.push(JSON.parse(line));
|
|
1611
|
-
}
|
|
1612
|
-
catch {
|
|
1613
|
-
return null;
|
|
1614
|
-
}
|
|
1615
|
-
const followUp = records.pop();
|
|
1616
|
-
if (followUp === null || typeof followUp !== 'object' || typeof followUp['follow_up'] !== 'string')
|
|
1617
|
-
return null;
|
|
1618
|
-
const profiles = [];
|
|
1619
|
-
for (const record of records) {
|
|
1620
|
-
if (record === null || typeof record !== 'object')
|
|
1621
|
-
return null;
|
|
1622
|
-
const row = record;
|
|
1623
|
-
if (typeof row['profile_id'] !== 'string' || typeof row['name'] !== 'string' || !Number.isInteger(row['projects_count']) || row['projects_count'] < 0)
|
|
1624
|
-
return null;
|
|
1625
|
-
if (row['last_used_at'] !== null && typeof row['last_used_at'] !== 'string')
|
|
1626
|
-
return null;
|
|
1627
|
-
profiles.push({ name: row['name'], projects: row['projects_count'] });
|
|
1628
|
-
}
|
|
1629
|
-
return inspectionGlance({ text: `${profiles.length} profile${profiles.length === 1 ? '' : 's'}` }, profiles.map((profile) => `${profile.name} · ${profile.projects} project${profile.projects === 1 ? '' : 's'}`));
|
|
1630
|
-
}
|
|
1631
|
-
/** Parse a scalar-array block from renderResult (`N name:` followed by `-`
|
|
1632
|
-
* rows, or `0 name.`). Scalar arrays are emitted after every bullet/prose
|
|
1633
|
-
* field, so any non-blank trailing output means this is not the exact shape. */
|
|
1634
|
-
function parseScalarList(text, name) {
|
|
1635
|
-
const lines = text.split('\n');
|
|
1636
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1637
|
-
if (lines[i].trim() === `0 ${name}.`) {
|
|
1638
|
-
return lines.slice(i + 1).every((line) => line.trim() === '') ? [] : null;
|
|
1639
|
-
}
|
|
1640
|
-
const heading = new RegExp(`^(\\d+) ${name}:$`).exec(lines[i].trim());
|
|
1641
|
-
if (heading === null)
|
|
1642
|
-
continue;
|
|
1643
|
-
const expected = Number(heading[1]);
|
|
1644
|
-
const values = [];
|
|
1645
|
-
let j = i + 1;
|
|
1646
|
-
while (j < lines.length && /^- (.+)$/.test(lines[j])) {
|
|
1647
|
-
values.push(/^- (.+)$/.exec(lines[j])[1]);
|
|
1648
|
-
j++;
|
|
1649
|
-
}
|
|
1650
|
-
if (values.length !== expected || !lines.slice(j).every((line) => line.trim() === ''))
|
|
1651
|
-
return null;
|
|
1652
|
-
return values;
|
|
1653
|
-
}
|
|
1654
|
-
return null;
|
|
1655
|
-
}
|
|
1656
|
-
function summarizeProfileShow(text) {
|
|
1657
|
-
const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1658
|
-
const name = readFieldFlexible(text, 'name');
|
|
1659
|
-
const configExists = /^- config_exists:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
1660
|
-
const projects = parseScalarList(text, 'projects');
|
|
1661
|
-
if (id === undefined || name === undefined || name === '' || configExists === undefined || projects === null)
|
|
1662
|
-
return null;
|
|
1663
|
-
return inspectionGlance({ text: `${name} · ${projects.length} project${projects.length === 1 ? '' : 's'}` }, [`${id} · config ${configExists === 'true' ? 'present' : 'absent'}`, ...projects.map(basename)]);
|
|
1664
|
-
}
|
|
1665
|
-
function summarizeProfileDefaultShow(text) {
|
|
1666
|
-
const dir = readFieldFlexible(text, 'dir');
|
|
1667
|
-
const pinned = /^- pinned:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
1668
|
-
if (dir === undefined || pinned === undefined)
|
|
1669
|
-
return null;
|
|
1670
|
-
if (pinned === 'false')
|
|
1671
|
-
return inspectionGlance({ text: 'no default profile', tone: 'muted' }, [basename(dir)]);
|
|
1672
|
-
const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1673
|
-
const name = readFieldFlexible(text, 'name');
|
|
1674
|
-
const stale = /^- stale:\s*true\s*$/m.test(text);
|
|
1675
|
-
if (id === undefined || (name === undefined && !stale) || (name !== undefined && stale))
|
|
1676
|
-
return null;
|
|
1677
|
-
return inspectionGlance({ text: stale ? 'stale default profile' : `default: ${name}`, tone: stale ? 'warning' : 'accent' }, [id, basename(dir)]);
|
|
1678
|
-
}
|
|
1679
|
-
function summarizeProfileEnvList(text) {
|
|
1680
|
-
const id = /^- profile_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1681
|
-
const count = parseIntBullet(text, 'count');
|
|
1682
|
-
const names = parseScalarList(text, 'names');
|
|
1683
|
-
if (id === undefined || count === null || names === null || names.length !== count)
|
|
1684
|
-
return null;
|
|
1685
|
-
return inspectionGlance({ text: `${count} env var${count === 1 ? '' : 's'}`, tone: count === 0 ? 'muted' : 'plain' }, [id, ...names]);
|
|
1686
|
-
}
|
|
1687
|
-
function summarizeCanvasConfigList(text) {
|
|
1688
|
-
const rows = text.split('\n').filter((line) => line.trim() !== '');
|
|
1689
|
-
if (rows.length < 2)
|
|
1690
|
-
return null;
|
|
1691
|
-
const details = [];
|
|
1692
|
-
for (const row of rows) {
|
|
1693
|
-
const match = /^(.+?) — .+ \((token \S+|no token stored)\)$/.exec(row);
|
|
1694
|
-
if (match === null)
|
|
1695
|
-
return null;
|
|
1696
|
-
details.push(`${match[1]} · ${match[2] === 'no token stored' ? 'token missing' : 'token stored'}`);
|
|
1697
|
-
}
|
|
1698
|
-
return inspectionGlance({ text: `${rows.length} remote canvas targets` }, details);
|
|
1699
|
-
}
|
|
1700
|
-
/** `sys config get` only needs a card when the value itself exceeds generic
|
|
1701
|
-
* truncation (whole objects such as model ladders/keybindings). */
|
|
1702
|
-
function summarizeSysConfigGet(text) {
|
|
1703
|
-
const key = /^- key:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
|
1704
|
-
const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1705
|
-
const value = readFieldFlexible(text, 'value') ?? soloProseAfterBullets(text);
|
|
1706
|
-
if (key === undefined || key === '' || scope === undefined || value === undefined || value.length <= 100)
|
|
1707
|
-
return null;
|
|
1708
|
-
return inspectionGlance({ text: `${key} · ${scope}` }, [truncateLine(value, 160)]);
|
|
1709
|
-
}
|
|
1710
|
-
function summarizeSysConfigPath(text) {
|
|
1711
|
-
const rows = parseTableRows(text, 'paths');
|
|
1712
|
-
if (rows === null || rows.length < 2)
|
|
1713
|
-
return null;
|
|
1714
|
-
const details = [];
|
|
1715
|
-
for (const row of rows) {
|
|
1716
|
-
const scope = row['scope'];
|
|
1717
|
-
const path = row['path'];
|
|
1718
|
-
if (scope === undefined || scope === '' || path === undefined || path === '')
|
|
1719
|
-
return null;
|
|
1720
|
-
details.push(`${scope} · ${truncateLine(path, 120)}`);
|
|
1721
|
-
}
|
|
1722
|
-
return inspectionGlance({ text: `${rows.length} config paths` }, details);
|
|
1723
|
-
}
|
|
1724
|
-
/** `crtr sys config set` uses the generic scalar result shape. The config key
|
|
1725
|
-
* itself is the meaningful changed field; scope stays as a second aligned
|
|
1726
|
-
* discriminator instead of repeating raw output bullets. */
|
|
1727
|
-
function summarizeSysConfigSet(text) {
|
|
1728
|
-
const key = /^- key:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
|
1729
|
-
const value = /^- value:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
|
1730
|
-
const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1731
|
-
if (key === undefined || value === undefined || scope === undefined)
|
|
1732
|
-
return null;
|
|
1733
|
-
return [
|
|
1734
|
-
{ text: `updated config`, tone: 'success' },
|
|
1735
|
-
{ text: '' },
|
|
1736
|
-
...keyValueLines([[key, value], ['scope', scope]]),
|
|
1737
|
-
];
|
|
1738
|
-
}
|
|
1739
|
-
/** Remote-canvas mutations already emit terse prose. Normalize them into the
|
|
1740
|
-
* same outcome/changed-values grammar without exposing the relay token. */
|
|
1741
|
-
function summarizeCanvasConfigAdd(text) {
|
|
1742
|
-
const match = /^Registered remote canvas target '([^']+)' → (.+) \(token stored\)\.$/m.exec(text);
|
|
1743
|
-
if (match === null)
|
|
1744
|
-
return null;
|
|
1745
|
-
return [
|
|
1746
|
-
{ text: `registered ${match[1]}`, tone: 'success' },
|
|
1747
|
-
{ text: '' },
|
|
1748
|
-
...keyValueLines([['preview', match[2]], ['relay-token', 'stored']]),
|
|
1749
|
-
];
|
|
1750
|
-
}
|
|
1751
|
-
function summarizeCanvasConfigRemove(text) {
|
|
1752
|
-
const removed = /^Removed remote canvas target '([^']+)'\.$/m.exec(text)?.[1];
|
|
1753
|
-
return removed === undefined ? null : [{ text: `removed ${removed}`, tone: 'success' }];
|
|
1754
|
-
}
|
|
1755
|
-
function summarizeCanvasUse(text) {
|
|
1756
|
-
const active = /^Active canvas:\s+(.+)\.$/m.exec(text)?.[1];
|
|
1757
|
-
return active === undefined ? null : [{ text: `active canvas: ${active}`, tone: 'accent' }];
|
|
1758
|
-
}
|
|
1759
|
-
/** Remaining node mutations use a mix of terse prose and generic bullets.
|
|
1760
|
-
* Normalize their receipts without repeating guidance or filesystem paths. */
|
|
1761
|
-
function summarizeNodeSubscribe(text) {
|
|
1762
|
-
const match = /^Subscribed\s+(\S+)\s+→\s+(\S+)\s+\((active|passive)\)\.$/m.exec(text);
|
|
1763
|
-
if (match === null)
|
|
1764
|
-
return null;
|
|
1765
|
-
return [
|
|
1766
|
-
{ text: 'subscribed', tone: 'success' },
|
|
1767
|
-
{ text: '' },
|
|
1768
|
-
...keyValueLines([['subscriber', match[1]], ['publisher', match[2]], ['mode', match[3]]]),
|
|
1769
|
-
];
|
|
1770
|
-
}
|
|
1771
|
-
function summarizeNodeUnsubscribe(text) {
|
|
1772
|
-
const match = /^Unsubscribed\s+(\S+)\s+from\s+(\S+)\.$/m.exec(text);
|
|
1773
|
-
if (match === null)
|
|
1774
|
-
return null;
|
|
1775
|
-
return [
|
|
1776
|
-
{ text: 'unsubscribed', tone: 'success' },
|
|
1777
|
-
{ text: '' },
|
|
1778
|
-
...keyValueLines([['subscriber', match[1]], ['publisher', match[2]]]),
|
|
1779
|
-
];
|
|
1780
|
-
}
|
|
1781
|
-
function summarizeNodePromote(text) {
|
|
1782
|
-
const field = (name) => new RegExp(`^-\\s+${name}:\\s*(.+)$`, 'm').exec(text)?.[1]?.trim();
|
|
1783
|
-
const node = field('node_id');
|
|
1784
|
-
const kind = field('kind');
|
|
1785
|
-
const mode = field('mode');
|
|
1786
|
-
const lifecycle = field('lifecycle');
|
|
1787
|
-
const roadmap = field('roadmap_written');
|
|
1788
|
-
if (node === undefined || kind === undefined || mode === undefined || lifecycle === undefined || roadmap === undefined)
|
|
1789
|
-
return null;
|
|
1790
|
-
const entries = [['kind', kind], ['mode', mode], ['lifecycle', lifecycle]];
|
|
1791
|
-
const model = field('model');
|
|
1792
|
-
if (model !== undefined)
|
|
1793
|
-
entries.push(['model', model]);
|
|
1794
|
-
entries.push(['roadmap', roadmap === 'true' ? 'seeded' : 'kept']);
|
|
1795
|
-
return [{ text: `promoted ${node}`, tone: 'success' }, { text: '' }, ...keyValueLines(entries)];
|
|
1796
|
-
}
|
|
1797
|
-
function summarizeNodeDemote(text) {
|
|
1798
|
-
const match = /^Demoted\s+(\S+)\s+—\s+lifecycle now\s+(\S+)\s+\(in place\)(, viewer closed \(broker still running off-screen\))?\.$/m.exec(text);
|
|
1799
|
-
if (match === null)
|
|
1800
|
-
return null;
|
|
1801
|
-
return [
|
|
1802
|
-
{ text: `demoted ${match[1]}`, tone: 'success' },
|
|
1803
|
-
{ text: '' },
|
|
1804
|
-
...keyValueLines([['lifecycle', match[2]], ['viewer', match[3] === undefined ? 'attached' : 'detached']]),
|
|
1805
|
-
];
|
|
1806
|
-
}
|
|
1807
|
-
function summarizeNodeRecycle(text) {
|
|
1808
|
-
const node = /^Recycled the pane — finished (\S+)\.$/m.exec(text)?.[1];
|
|
1809
|
-
if (node === undefined)
|
|
1810
|
-
return null;
|
|
1811
|
-
const finalized = /^- finalized:\s*(true|false)$/m.exec(text)?.[1];
|
|
1812
|
-
if (finalized === undefined)
|
|
1813
|
-
return null;
|
|
1814
|
-
const entries = [['finalized', finalized]];
|
|
1815
|
-
const delivered = /^- delivered:\s*(\d+)$/m.exec(text)?.[1];
|
|
1816
|
-
const root = /^- new root:\s*(\S+)$/m.exec(text)?.[1];
|
|
1817
|
-
if (delivered !== undefined)
|
|
1818
|
-
entries.push(['delivered', delivered]);
|
|
1819
|
-
if (root !== undefined)
|
|
1820
|
-
entries.push(['new root', root]);
|
|
1821
|
-
return [{ text: `recycled ${node}`, tone: 'success' }, { text: '' }, ...keyValueLines(entries)];
|
|
1822
|
-
}
|
|
1823
|
-
/** Generic plugin and marketplace mutation results become outcome-first cards;
|
|
1824
|
-
* paths stay in the expanded raw output rather than dominating the receipt. */
|
|
1825
|
-
function summarizePkgPluginToggle(text) {
|
|
1826
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1827
|
-
const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1828
|
-
const enabled = /^- enabled:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
1829
|
-
if (name === undefined || scope === undefined || enabled === undefined)
|
|
1830
|
-
return null;
|
|
1831
|
-
return [
|
|
1832
|
-
{ text: `${enabled === 'true' ? 'enabled' : 'disabled'} ${name}`, tone: 'success' },
|
|
1833
|
-
{ text: '' },
|
|
1834
|
-
...keyValueLines([['scope', scope]]),
|
|
1835
|
-
];
|
|
1836
|
-
}
|
|
1837
|
-
function summarizePkgPluginRemove(text, tokens) {
|
|
1838
|
-
if (!/^- removed:\s*true\s*$/m.test(text))
|
|
1839
|
-
return null;
|
|
1840
|
-
const name = tokens[3];
|
|
1841
|
-
const scopes = /^\d+ scopes:\s*\n((?:- .+\n?)*)/m.exec(text)?.[1]
|
|
1842
|
-
?.split('\n').map((line) => line.replace(/^-\s*/, '').trim()).filter(Boolean);
|
|
1843
|
-
if (name === undefined || scopes === undefined || scopes.length === 0)
|
|
1844
|
-
return null;
|
|
1845
|
-
return [{ text: `removed ${name}`, tone: 'success' }, { text: '' }, ...keyValueLines([['scopes', scopes.join(', ')]])];
|
|
1846
|
-
}
|
|
1847
|
-
function summarizePkgMarketAdd(text) {
|
|
1848
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1849
|
-
const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1850
|
-
if (name === undefined || scope === undefined)
|
|
1851
|
-
return null;
|
|
1852
|
-
return [{ text: `added marketplace ${name}`, tone: 'success' }, { text: '' }, ...keyValueLines([['scope', scope]])];
|
|
1853
|
-
}
|
|
1854
|
-
function summarizePkgMarketRemove(text) {
|
|
1855
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1856
|
-
if (name === undefined || !/^- removed:\s*true\s*$/m.test(text))
|
|
1857
|
-
return null;
|
|
1858
|
-
return [{ text: `removed marketplace ${name}`, tone: 'success' }];
|
|
1859
|
-
}
|
|
1860
|
-
/** `crtr pkg plugin install` (pluginInstall.run, src/commands/pkg/
|
|
1861
|
-
* plugin-manage.ts:418) has no custom `render`; the generic renderer
|
|
1862
|
-
* bullets `name`, `scope`, and optional `transport`, then `path` — almost
|
|
1863
|
-
* always long enough to cross the prose threshold, and since it is the sole
|
|
1864
|
-
* prose field it renders as a bare unlabeled paragraph (see
|
|
1865
|
-
* soloProseAfterBullets). Returns null when `name`/`scope` aren't present as
|
|
1866
|
-
* bullets — an error/unrecognized shape (path is a muted aside, not required
|
|
1867
|
-
* for a recognized digest). */
|
|
1868
|
-
function summarizePkgPluginInstall(text) {
|
|
1869
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1870
|
-
const scope = /^- scope:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1871
|
-
const transport = /^- transport:\s*(exec|http)\s*$/m.exec(text)?.[1];
|
|
1872
|
-
if (name === undefined || scope === undefined)
|
|
1873
|
-
return null;
|
|
1874
|
-
const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
|
|
1875
|
-
return [
|
|
1876
|
-
{ text: `installed ${name}${transport !== undefined ? ` · ${transport}` : ''}`, tone: 'success' },
|
|
1877
|
-
{ text: path !== undefined ? `${scope} · ${path}` : scope, tone: 'muted' },
|
|
1878
|
-
];
|
|
1879
|
-
}
|
|
1880
|
-
/** `crtr pkg plugin update` (pluginUpdate.run, src/commands/pkg/
|
|
1881
|
-
* plugin-manage.ts:571) has no custom `render`; the generic renderer emits
|
|
1882
|
-
* an `N updated:` table. HTTP rows carry `transport=http` and no sha because
|
|
1883
|
-
* they replace commands.json after refetch; git rows retain their optional
|
|
1884
|
-
* `sha`. Returns null when the table doesn't match this shape or an `updated`
|
|
1885
|
-
* cell is not exactly `true`/`false`. */
|
|
1886
|
-
function summarizePkgPluginUpdate(text) {
|
|
1887
|
-
const rows = parseTableRows(text, 'updated');
|
|
1888
|
-
if (rows === null)
|
|
1889
|
-
return null;
|
|
1890
|
-
if (rows.length === 0)
|
|
1891
|
-
return [{ text: 'nothing to update', tone: 'muted' }];
|
|
1892
|
-
if (rows.length === 1) {
|
|
1893
|
-
const row = rows[0];
|
|
1894
|
-
const name = row['name'];
|
|
1895
|
-
const updated = row['updated'];
|
|
1896
|
-
const transport = row['transport'];
|
|
1897
|
-
if (name === undefined || (updated !== 'true' && updated !== 'false') || (transport !== undefined && transport !== '' && transport !== 'exec' && transport !== 'http'))
|
|
1898
|
-
return null;
|
|
1899
|
-
const sha = (row['sha'] ?? '').slice(0, 7);
|
|
1900
|
-
const lines = [
|
|
1901
|
-
updated === 'true' ? { text: `${transport === 'http' ? 'refreshed' : 'updated'} ${name}`, tone: 'success' } : { text: `${name} already current`, tone: 'muted' },
|
|
1902
|
-
];
|
|
1903
|
-
if (transport !== undefined && transport !== '')
|
|
1904
|
-
lines.push({ text: transport, tone: 'muted' });
|
|
1905
|
-
if (sha !== '')
|
|
1906
|
-
lines.push({ text: sha, tone: 'muted' });
|
|
1907
|
-
return lines;
|
|
1908
|
-
}
|
|
1909
|
-
let updatedCount = 0;
|
|
1910
|
-
let httpCount = 0;
|
|
1911
|
-
for (const row of rows) {
|
|
1912
|
-
const updated = row['updated'];
|
|
1913
|
-
const transport = row['transport'];
|
|
1914
|
-
if (updated !== 'true' && updated !== 'false')
|
|
1915
|
-
return null;
|
|
1916
|
-
if (transport !== undefined && transport !== '' && transport !== 'exec' && transport !== 'http')
|
|
1917
|
-
return null;
|
|
1918
|
-
if (updated === 'true')
|
|
1919
|
-
updatedCount++;
|
|
1920
|
-
if (transport === 'http')
|
|
1921
|
-
httpCount++;
|
|
1922
|
-
}
|
|
1923
|
-
const current = rows.length - updatedCount;
|
|
1924
|
-
return [{ text: `${updatedCount} updated · ${current} already current${httpCount > 0 ? ` · ${httpCount} HTTP` : ''}` }];
|
|
1925
|
-
}
|
|
1926
|
-
const PKG_LIST_NAME_CAP = 8;
|
|
1927
|
-
/** `crtr pkg plugin list` (pluginList.run, src/commands/pkg/
|
|
1928
|
-
* plugin-inspect.ts:14) bullets `- total: N` then an `N items:` table
|
|
1929
|
-
* (columns `name,scope,version,enabled,transport,source_marketplace,path`).
|
|
1930
|
-
* `enabled`/name counts are read from the RETURNED page only — with the
|
|
1931
|
-
* default 50-per-page limit this matches `total` in practice, but a
|
|
1932
|
-
* narrower `--limit` would undercount enabled/shown names against the
|
|
1933
|
-
* true total (the same page-vs-total caveat other paginated summaries in
|
|
1934
|
-
* this file carry). Returns null when the total/items shape doesn't
|
|
1935
|
-
* match, or when `total` is nonzero but the returned page is empty
|
|
1936
|
-
* (exhausted pagination, not a true zero total). */
|
|
1937
|
-
function summarizePkgPluginList(text) {
|
|
1938
|
-
const total = parseIntBullet(text, 'total');
|
|
1939
|
-
const rows = parseTableRows(text, 'items');
|
|
1940
|
-
if (total === null || rows === null)
|
|
1941
|
-
return null;
|
|
1942
|
-
if (total === 0)
|
|
1943
|
-
return [{ text: '0 plugins', tone: 'muted' }];
|
|
1944
|
-
if (rows.length === 0)
|
|
1945
|
-
return null;
|
|
1946
|
-
let enabledCount = 0;
|
|
1947
|
-
const names = [];
|
|
1948
|
-
for (const row of rows) {
|
|
1949
|
-
const name = row['name'];
|
|
1950
|
-
const enabled = row['enabled'];
|
|
1951
|
-
const transport = row['transport'];
|
|
1952
|
-
if (name === undefined || name === '' || (enabled !== 'true' && enabled !== 'false') || (transport !== 'exec' && transport !== 'http' && transport !== ''))
|
|
1953
|
-
return null;
|
|
1954
|
-
if (enabled === 'true')
|
|
1955
|
-
enabledCount++;
|
|
1956
|
-
names.push(`${name}${transport === '' ? '' : ` (${transport})`}`);
|
|
1957
|
-
}
|
|
1958
|
-
const shown = names.slice(0, PKG_LIST_NAME_CAP);
|
|
1959
|
-
const remaining = names.length - shown.length;
|
|
1960
|
-
const namesLine = remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
|
|
1961
|
-
return [
|
|
1962
|
-
{ text: `${total} plugins · ${enabledCount} enabled` },
|
|
1963
|
-
{ text: namesLine, tone: 'muted' },
|
|
1964
|
-
];
|
|
1965
|
-
}
|
|
1966
|
-
/** `crtr pkg plugin show` (pluginShow.run, src/commands/pkg/
|
|
1967
|
-
* plugin-inspect.ts:88) bullets `name`/`scope`/`path`/`enabled` (path is
|
|
1968
|
-
* usually short here — a plugin dir, not a nested interaction path — so
|
|
1969
|
-
* stays a bullet) then `manifest` — an OBJECT, so core/render.ts's
|
|
1970
|
-
* scalarStr always JSON.stringifies it to a bullet regardless of length
|
|
1971
|
-
* (isProse only ever triggers for strings) — then an `N docs:` table (or
|
|
1972
|
-
* `0 docs.`). `version` and `transport.kind` are read from the parsed
|
|
1973
|
-
* manifest JSON, not top-level fields. Returns null when `name`/`enabled`/
|
|
1974
|
-
* `manifest` don't match, `manifest` isn't valid JSON, or the docs count
|
|
1975
|
-
* line is missing. */
|
|
1976
|
-
function summarizePkgPluginShow(text) {
|
|
1977
|
-
const name = /^- name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
1978
|
-
const enabledMatch = /^- enabled:\s*(true|false)\s*$/m.exec(text);
|
|
1979
|
-
const manifestMatch = /^- manifest:\s*(\{.*\})\s*$/m.exec(text);
|
|
1980
|
-
if (name === undefined || enabledMatch === null || manifestMatch === null)
|
|
1981
|
-
return null;
|
|
1982
|
-
let manifest;
|
|
1983
|
-
try {
|
|
1984
|
-
manifest = JSON.parse(manifestMatch[1]);
|
|
1985
|
-
}
|
|
1986
|
-
catch {
|
|
1987
|
-
return null;
|
|
1988
|
-
}
|
|
1989
|
-
const version = manifest !== null && typeof manifest === 'object' && 'version' in manifest && typeof manifest.version === 'string'
|
|
1990
|
-
? manifest.version
|
|
1991
|
-
: undefined;
|
|
1992
|
-
const transport = manifest !== null && typeof manifest === 'object' && 'transport' in manifest && manifest.transport !== null && typeof manifest.transport === 'object' && ['exec', 'http'].includes(manifest.transport.kind)
|
|
1993
|
-
? manifest.transport.kind
|
|
1994
|
-
: undefined;
|
|
1995
|
-
const docsCount = /^0 docs\.$/m.test(text) ? 0 : Number(/^(\d+) docs:$/m.exec(text)?.[1] ?? NaN);
|
|
1996
|
-
if (Number.isNaN(docsCount))
|
|
1997
|
-
return null;
|
|
1998
|
-
const enabled = enabledMatch[1] === 'true';
|
|
1999
|
-
return [
|
|
2000
|
-
{ text: `${name}${version !== undefined ? `@${version}` : ''}${transport !== undefined ? ` · ${transport}` : ''} · ${enabled ? 'enabled' : 'disabled'}`, tone: enabled ? 'success' : 'muted' },
|
|
2001
|
-
{ text: `${docsCount} substrate doc${docsCount === 1 ? '' : 's'}`, tone: 'muted' },
|
|
2002
|
-
];
|
|
2003
|
-
}
|
|
2004
|
-
/** `crtr pkg market list` (marketList.run, src/commands/pkg/
|
|
2005
|
-
* market-inspect.ts:14) bullets `- total: N` then an `N items:` table
|
|
2006
|
-
* (columns `name,scope,url,ref,path`). Returns null when the total/items
|
|
2007
|
-
* shape doesn't match, or when `total` is nonzero but the returned page
|
|
2008
|
-
* is empty (exhausted pagination, not a true zero total). */
|
|
2009
|
-
function summarizePkgMarketList(text) {
|
|
2010
|
-
const total = parseIntBullet(text, 'total');
|
|
2011
|
-
const rows = parseTableRows(text, 'items');
|
|
2012
|
-
if (total === null || rows === null)
|
|
2013
|
-
return null;
|
|
2014
|
-
if (total === 0)
|
|
2015
|
-
return [{ text: '0 marketplaces', tone: 'muted' }];
|
|
2016
|
-
if (rows.length === 0)
|
|
2017
|
-
return null;
|
|
2018
|
-
const names = rows.map((r) => r['name']);
|
|
2019
|
-
if (names.some((n) => n === undefined || n === ''))
|
|
2020
|
-
return null;
|
|
2021
|
-
return [
|
|
2022
|
-
{ text: `${total} marketplaces` },
|
|
2023
|
-
{ text: names.join(', '), tone: 'muted' },
|
|
2024
|
-
];
|
|
2025
|
-
}
|
|
2026
|
-
const MARKET_BROWSE_NAME_CAP = 8;
|
|
2027
|
-
/** `crtr pkg market browse` (marketBrowse.run, src/commands/pkg/
|
|
2028
|
-
* market-inspect.ts:77) bullets `marketplace`/`total` then an `N items:`
|
|
2029
|
-
* table (columns include `name,ref,source,version,description,keywords,
|
|
2030
|
-
* installed,installed_scope`). Returns null when the marketplace/total
|
|
2031
|
-
* bullets or the items table don't match, or when `total` is nonzero but
|
|
2032
|
-
* the returned page is empty (exhausted pagination, not a true zero
|
|
2033
|
-
* total). */
|
|
2034
|
-
function summarizePkgMarketBrowse(text) {
|
|
2035
|
-
const market = /^- marketplace:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
2036
|
-
const total = parseIntBullet(text, 'total');
|
|
2037
|
-
const rows = parseTableRows(text, 'items');
|
|
2038
|
-
if (market === undefined || total === null || rows === null)
|
|
2039
|
-
return null;
|
|
2040
|
-
if (total === 0)
|
|
2041
|
-
return [{ text: `0 plugins in ${market}`, tone: 'muted' }];
|
|
2042
|
-
if (rows.length === 0)
|
|
2043
|
-
return null;
|
|
2044
|
-
const names = rows.map((r) => r['name']);
|
|
2045
|
-
if (names.some((n) => n === undefined || n === ''))
|
|
2046
|
-
return null;
|
|
2047
|
-
const shown = names.slice(0, MARKET_BROWSE_NAME_CAP);
|
|
217
|
+
function topNames(rows, key, cap = 5) {
|
|
218
|
+
const names = rows.map((row) => stringField(row, key));
|
|
219
|
+
if (names.some((name) => name === undefined))
|
|
220
|
+
return undefined;
|
|
221
|
+
const shown = names.slice(0, cap);
|
|
2048
222
|
const remaining = names.length - shown.length;
|
|
2049
|
-
|
|
2050
|
-
return [
|
|
2051
|
-
{ text: `${total} plugins in ${market}` },
|
|
2052
|
-
{ text: namesLine, tone: 'muted' },
|
|
2053
|
-
];
|
|
2054
|
-
}
|
|
2055
|
-
// ---------------------------------------------------------------------------
|
|
2056
|
-
// Batch D — search/sys/surface. search's three leaves share a rendering
|
|
2057
|
-
// convention (a numbered `### i. title` heading per item, an optional meta
|
|
2058
|
-
// line, then highlight bullets or a text blockquote — see exa.ts's
|
|
2059
|
-
// renderResult) and a fixed, unconditional follow_up sentence per leaf that
|
|
2060
|
-
// doubles as a shape boundary; sys/surface leaves have no shared shape and
|
|
2061
|
-
// are handled independently below.
|
|
2062
|
-
// ---------------------------------------------------------------------------
|
|
2063
|
-
const SEARCH_RESULT_CAP = 3;
|
|
2064
|
-
/** Pull a bare host out of a search result's meta line (renderResult,
|
|
2065
|
-
* src/commands/search/exa.ts:150 — `url · date · author · via source`, url
|
|
2066
|
-
* first when present) for a compact `title — domain` digest row. Returns
|
|
2067
|
-
* undefined when the line isn't a `https?://` url — a highlight bullet with
|
|
2068
|
-
* no meta line at all, or a meta line that leads with the date/author
|
|
2069
|
-
* because url was absent. */
|
|
2070
|
-
function extractDomain(metaLine) {
|
|
2071
|
-
const first = (metaLine.split(' · ')[0] ?? '').trim();
|
|
2072
|
-
const m = /^https?:\/\/(?:www\.)?([^/\s]+)/.exec(first);
|
|
2073
|
-
return m?.[1];
|
|
223
|
+
return remaining > 0 ? `${shown.join(', ')}, … ${remaining} more` : shown.join(', ');
|
|
2074
224
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
* happens to start with a matching lead line still can't be mistaken for
|
|
2079
|
-
* this leaf's real output. */
|
|
2080
|
-
const SEARCH_WEB_FOLLOWUP_MARKER = 'Fetch full content for selected result URLs with the contents leaf after checking its schema.';
|
|
2081
|
-
/** `crtr search web` (webLeaf.render, src/commands/search/web.ts:70) has two
|
|
2082
|
-
* shapes: `No results for "<query>".` (zero hits) or `${N} results for
|
|
2083
|
-
* "${query}":` followed by one `### i. title` heading per result (each
|
|
2084
|
-
* optionally followed by a meta line — see extractDomain) then highlight
|
|
2085
|
-
* bullets or a text blockquote, blank-line separated, then the fixed
|
|
2086
|
-
* follow_up road sign (see SEARCH_WEB_FOLLOWUP_MARKER). Returns null when
|
|
2087
|
-
* the follow_up marker is absent, the lead line matches neither shape, or
|
|
2088
|
-
* the parsed heading count doesn't equal the declared total (a pi
|
|
2089
|
-
* bash-tool truncation mid-list, or an unrecognized layout). */
|
|
2090
|
-
function summarizeSearchWeb(text) {
|
|
2091
|
-
if (!text.includes(SEARCH_WEB_FOLLOWUP_MARKER))
|
|
225
|
+
function summarizeError(input) {
|
|
226
|
+
const error = input.error;
|
|
227
|
+
if (error === undefined)
|
|
2092
228
|
return null;
|
|
2093
|
-
const
|
|
2094
|
-
const
|
|
2095
|
-
if (
|
|
2096
|
-
return [{ text: `0 results for "${noResults[1]}"`, tone: 'muted' }];
|
|
2097
|
-
const head = /^(\d+) results for ".+":$/.exec(firstLine);
|
|
2098
|
-
if (head === null)
|
|
2099
|
-
return null;
|
|
2100
|
-
const count = Number(head[1]);
|
|
2101
|
-
const lines = text.split('\n');
|
|
2102
|
-
const items = [];
|
|
2103
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2104
|
-
const h = /^### \d+\. (.+)$/.exec(lines[i]);
|
|
2105
|
-
if (h === null)
|
|
2106
|
-
continue;
|
|
2107
|
-
items.push({ title: h[1], domain: extractDomain(lines[i + 1] ?? '') });
|
|
2108
|
-
}
|
|
2109
|
-
if (items.length !== count)
|
|
229
|
+
const code = stringField(error, 'error');
|
|
230
|
+
const message = stringField(error, 'message');
|
|
231
|
+
if (code === undefined || message === undefined)
|
|
2110
232
|
return null;
|
|
2111
|
-
|
|
2112
|
-
const out = [{ text: `${count} result${count === 1 ? '' : 's'}` }];
|
|
2113
|
-
for (const t of top)
|
|
2114
|
-
out.push({ text: t, tone: 'muted' });
|
|
2115
|
-
const remaining = items.length - top.length;
|
|
2116
|
-
if (remaining > 0)
|
|
2117
|
-
out.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
2118
|
-
return out;
|
|
233
|
+
return [{ text: code, tone: 'error' }, { text: truncateLine(message, 160), tone: 'muted' }];
|
|
2119
234
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
* begins, regardless of whether citations are present. */
|
|
2124
|
-
const SEARCH_ANSWER_FOLLOWUP_MARKER = 'Read any cited source in full with the contents leaf';
|
|
2125
|
-
/** `crtr search answer` (answerLeaf.render, src/commands/search/answer.ts:52)
|
|
2126
|
-
* has two shapes: `No answer was returned.` (empty) or `${answer}\n\n` then,
|
|
2127
|
-
* when citations exist, `Sources (${N}):` plus one numbered line per
|
|
2128
|
-
* citation and a blank line, then unconditionally the fixed follow_up
|
|
2129
|
-
* sentence. Citations are optional; the follow_up marker is not, so it
|
|
2130
|
-
* locates the answer/Sources boundary either way. Mirrors
|
|
2131
|
-
* summarizeMemoryRead's teaser+ellipsis shape for the answer body. Returns
|
|
2132
|
-
* null when the follow_up marker is absent (unrecognized shape) or the
|
|
2133
|
-
* recovered answer text is empty. */
|
|
2134
|
-
function summarizeSearchAnswer(text) {
|
|
2135
|
-
const followUpIdx = text.indexOf(SEARCH_ANSWER_FOLLOWUP_MARKER);
|
|
2136
|
-
if (followUpIdx === -1)
|
|
235
|
+
function summarizeMemoryRead(input) {
|
|
236
|
+
const value = record(input);
|
|
237
|
+
if (value === undefined)
|
|
2137
238
|
return null;
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
if (bodyEnd <= 0)
|
|
2144
|
-
return null;
|
|
2145
|
-
const answer = text.slice(0, bodyEnd).trim();
|
|
2146
|
-
if (answer === '')
|
|
239
|
+
const name = stringField(value, 'name');
|
|
240
|
+
const kind = stringField(value, 'kind');
|
|
241
|
+
const scope = stringField(value, 'scope');
|
|
242
|
+
const content = stringField(value, 'content');
|
|
243
|
+
if (name === undefined || kind === undefined || scope === undefined)
|
|
2147
244
|
return null;
|
|
2148
|
-
|
|
2149
|
-
if (
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
if (numberedLines.length !== citationCount)
|
|
2154
|
-
return null;
|
|
245
|
+
const lines = [{ text: `${name} · ${kind} · ${scope}` }];
|
|
246
|
+
if (content !== undefined) {
|
|
247
|
+
const teaser = content.trim().split('\n').slice(0, MEMORY_READ_CONTENT_LINES);
|
|
248
|
+
if (teaser.length > 0 && teaser.some((line) => line !== ''))
|
|
249
|
+
lines.push({ text: '' }, ...teaser.map((text) => ({ text })));
|
|
2155
250
|
}
|
|
2156
|
-
const answerLines = answer.split('\n').filter((l) => l.trim() !== '');
|
|
2157
|
-
const teaser = answerLines.slice(0, MEMORY_READ_CONTENT_LINES);
|
|
2158
|
-
const hasMore = answerLines.length > MEMORY_READ_CONTENT_LINES;
|
|
2159
|
-
const lines = teaser.map((l) => ({ text: l }));
|
|
2160
|
-
if (hasMore)
|
|
2161
|
-
lines.push({ text: '…', tone: 'muted' });
|
|
2162
|
-
lines.push({ text: `${citationCount} citation${citationCount === 1 ? '' : 's'}`, tone: 'muted' });
|
|
2163
251
|
return lines;
|
|
2164
252
|
}
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
if (/^No content extracted\.$/.test(firstLine)) {
|
|
2190
|
-
if (failedCount === 0)
|
|
2191
|
-
return [{ text: 'no content extracted', tone: 'muted' }];
|
|
2192
|
-
return [{ text: `0 extracted · ${failedCount} failed`, tone: 'error' }];
|
|
2193
|
-
}
|
|
2194
|
-
const head = /^Extracted (\d+) URLs?:$/.exec(firstLine);
|
|
2195
|
-
if (head === null)
|
|
2196
|
-
return null;
|
|
2197
|
-
const count = Number(head[1]);
|
|
2198
|
-
const lines = text.split('\n');
|
|
2199
|
-
const headingIdxs = [];
|
|
2200
|
-
for (let i = 0; i < lines.length; i++)
|
|
2201
|
-
if (/^### \d+\. /.test(lines[i]))
|
|
2202
|
-
headingIdxs.push(i);
|
|
2203
|
-
if (headingIdxs.length !== count)
|
|
2204
|
-
return null;
|
|
2205
|
-
const contentEndOffset = failedMatch !== null ? text.indexOf(failedMatch[0]) : followUpIdx;
|
|
2206
|
-
const contentEndLine = text.slice(0, contentEndOffset).split('\n').length - 1;
|
|
2207
|
-
const items = [];
|
|
2208
|
-
for (let k = 0; k < headingIdxs.length; k++) {
|
|
2209
|
-
const start = headingIdxs[k];
|
|
2210
|
-
const end = k + 1 < headingIdxs.length ? headingIdxs[k + 1] : contentEndLine;
|
|
2211
|
-
const heading = /^### \d+\. (.+)$/.exec(lines[start]);
|
|
2212
|
-
if (heading === null)
|
|
2213
|
-
return null;
|
|
2214
|
-
const body = lines.slice(start + 1, end);
|
|
2215
|
-
// renderResult (exa.ts:184–189) puts the meta line — url first when
|
|
2216
|
-
// present — immediately after the heading; drop it so `chars` reflects
|
|
2217
|
-
// only the extracted content, not the URL/date/author line.
|
|
2218
|
-
const contentLines = body.length > 0 && /^https?:\/\//.test(body[0]) ? body.slice(1) : body;
|
|
2219
|
-
items.push({ title: heading[1], chars: contentLines.join('\n').trim().length });
|
|
2220
|
-
}
|
|
2221
|
-
const shown = items.slice(0, SEARCH_CONTENTS_NAME_CAP).map((it) => `${it.title} · ${it.chars} chars`);
|
|
2222
|
-
const out = [{ text: `extracted ${count} URL${count === 1 ? '' : 's'}` }];
|
|
2223
|
-
for (const s of shown)
|
|
2224
|
-
out.push({ text: s, tone: 'muted' });
|
|
2225
|
-
const remaining = items.length - shown.length;
|
|
2226
|
-
if (remaining > 0)
|
|
2227
|
-
out.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
2228
|
-
if (failedCount > 0)
|
|
2229
|
-
out.push({ text: `${failedCount} failed`, tone: 'warning' });
|
|
2230
|
-
return out;
|
|
2231
|
-
}
|
|
2232
|
-
/** `crtr sys setup` opens a pi-tui wizard when stdout is a TTY (its `render`
|
|
2233
|
-
* is always `''` — the wizard writes directly via process.stdout, and its
|
|
2234
|
-
* raw ANSI curses frames never match any marker below, so this summarizer
|
|
2235
|
-
* safely returns null in that case). In the headless/agent path
|
|
2236
|
-
* (src/commands/sys/setup.ts's `!process.stdout.isTTY` branch,
|
|
2237
|
-
* `renderSetupStaticInstructions` in setup-core.ts — the shape every
|
|
2238
|
-
* bash-tool call to this leaf actually produces) it instead prints a
|
|
2239
|
-
* static inventory: the headline `crtr sys setup (non-interactive)`, a
|
|
2240
|
-
* `Packages:` section (`- <name> — <description> (installed|not
|
|
2241
|
-
* installed)`), a `System deps:` section (`- <tool>: ✓ installed` or `-
|
|
2242
|
-
* <tool>: <command/hint>`), then an `- Exa key: …` bullet and a footer line
|
|
2243
|
-
* — verified against the live command, 2026-07-01. Returns null when the
|
|
2244
|
-
* headline, both section headers, or the Exa-key marker bounding the
|
|
2245
|
-
* System deps section are missing, or any row's shape doesn't match — a
|
|
2246
|
-
* partial/truncated dump must not report a false step count. */
|
|
2247
|
-
function summarizeSysSetup(text) {
|
|
2248
|
-
if ((text.split('\n')[0] ?? '') !== 'crtr sys setup (non-interactive)')
|
|
2249
|
-
return null;
|
|
2250
|
-
const lines = text.split('\n');
|
|
2251
|
-
const pkgIdx = lines.indexOf('Packages:');
|
|
2252
|
-
const sysIdx = lines.indexOf('System deps:');
|
|
2253
|
-
if (pkgIdx === -1 || sysIdx === -1 || sysIdx <= pkgIdx)
|
|
2254
|
-
return null;
|
|
2255
|
-
const exaIdx = lines.findIndex((l, i) => i > sysIdx && /^- Exa key:/.test(l));
|
|
2256
|
-
if (exaIdx === -1)
|
|
2257
|
-
return null;
|
|
2258
|
-
const pkgLines = lines.slice(pkgIdx + 1, sysIdx).filter((l) => l.trim() !== '');
|
|
2259
|
-
const sysLines = lines.slice(sysIdx + 1, exaIdx).filter((l) => l.trim() !== '');
|
|
2260
|
-
if (pkgLines.length === 0 && sysLines.length === 0)
|
|
2261
|
-
return null;
|
|
2262
|
-
let ok = 0;
|
|
2263
|
-
const pending = [];
|
|
2264
|
-
for (const l of pkgLines) {
|
|
2265
|
-
const m = /^- (.+?) — .+\((installed|not installed)\)$/.exec(l);
|
|
2266
|
-
if (m === null)
|
|
2267
|
-
return null;
|
|
2268
|
-
if (m[2] === 'installed')
|
|
2269
|
-
ok++;
|
|
2270
|
-
else
|
|
2271
|
-
pending.push(m[1]);
|
|
2272
|
-
}
|
|
2273
|
-
for (const l of sysLines) {
|
|
2274
|
-
const m = /^- (\S+): (.+)$/.exec(l);
|
|
2275
|
-
if (m === null)
|
|
2276
|
-
return null;
|
|
2277
|
-
if (m[2] === '✓ installed')
|
|
2278
|
-
ok++;
|
|
2279
|
-
else
|
|
2280
|
-
pending.push(m[1]);
|
|
2281
|
-
}
|
|
2282
|
-
if (pending.length === 0)
|
|
2283
|
-
return [{ text: `${ok} steps ok`, tone: 'success' }];
|
|
2284
|
-
return [
|
|
2285
|
-
{ text: `${ok} ok · ${pending.length} pending`, tone: 'warning' },
|
|
2286
|
-
{ text: pending.join(', '), tone: 'muted' },
|
|
2287
|
-
];
|
|
2288
|
-
}
|
|
2289
|
-
const SYS_DOCTOR_ISSUE_CAP = 5;
|
|
2290
|
-
/** `crtr sys doctor` (sysDoctorLeaf.run, src/commands/sys/doctor.ts:283) has
|
|
2291
|
-
* no custom `render`; the generic renderer bullets `ok`/`failed` then an `N
|
|
2292
|
-
* checks:` table (columns scope,name,status,message,remediation, plus a
|
|
2293
|
-
* `fixed` column ONLY when at least one row set it — renderArray's column
|
|
2294
|
-
* set is the union of keys across every row, src/core/render.ts:60).
|
|
2295
|
-
* `failed` counts only UNRESOLVED fails (status:fail with no `fixed:true`)
|
|
2296
|
-
* — mirroring doctor.ts's own `ok`/`failed` computation, which excludes
|
|
2297
|
-
* `fixed:true` rows. Returns null when the ok/failed bullets or the checks
|
|
2298
|
-
* table don't match this shape, or when the open-fail-row count found in
|
|
2299
|
-
* the table doesn't equal the declared `failed` bullet (a truncated table,
|
|
2300
|
-
* or a row missing name/message). */
|
|
2301
|
-
function summarizeSysDoctor(text) {
|
|
2302
|
-
const okMatch = /^- ok:\s*(true|false)\s*$/m.exec(text);
|
|
2303
|
-
const failedMatch = /^- failed:\s*(\d+)\s*$/m.exec(text);
|
|
2304
|
-
if (okMatch === null || failedMatch === null)
|
|
2305
|
-
return null;
|
|
2306
|
-
const rows = parseTableRows(text, 'checks');
|
|
2307
|
-
if (rows === null)
|
|
2308
|
-
return null;
|
|
2309
|
-
const failed = Number(failedMatch[1]);
|
|
2310
|
-
const fixedCount = rows.filter((r) => r['status'] === 'fail' && r['fixed'] === 'true').length;
|
|
2311
|
-
if (failed === 0) {
|
|
2312
|
-
// A fail row with no `fixed:true` here would contradict the declared
|
|
2313
|
-
// `failed: 0` — an inconsistent/foreign shape, not a real PASS.
|
|
2314
|
-
if (rows.some((r) => r['status'] === 'fail' && r['fixed'] !== 'true'))
|
|
2315
|
-
return null;
|
|
2316
|
-
const lines = [{ text: `PASS · ${rows.length} checks`, tone: 'success' }];
|
|
2317
|
-
if (fixedCount > 0)
|
|
2318
|
-
lines.push({ text: `fixed ${fixedCount} automatically`, tone: 'muted' });
|
|
253
|
+
function summarizeCount(label, successZero = false) {
|
|
254
|
+
return (input) => {
|
|
255
|
+
const value = record(input);
|
|
256
|
+
const count = value === undefined ? undefined : numberField(value, 'count') ?? numberField(value, 'total');
|
|
257
|
+
if (count === undefined)
|
|
258
|
+
return null;
|
|
259
|
+
if (count === 0 && successZero)
|
|
260
|
+
return [{ text: `PASS · no ${label}`, tone: 'success' }];
|
|
261
|
+
return [{ text: `${count} ${label}`, tone: count === 0 ? 'muted' : 'plain' }];
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function summarizeArray(label, field = 'items') {
|
|
265
|
+
return (input) => {
|
|
266
|
+
const value = record(input);
|
|
267
|
+
if (value === undefined)
|
|
268
|
+
return null;
|
|
269
|
+
const rows = arrayField(value, field);
|
|
270
|
+
const total = numberField(value, 'total') ?? (rows?.length);
|
|
271
|
+
if (rows === undefined || total === undefined)
|
|
272
|
+
return null;
|
|
273
|
+
const lines = [{ text: `${total} ${label}`, tone: total === 0 ? 'muted' : 'plain' }];
|
|
274
|
+
const names = topNames(rows, 'name');
|
|
275
|
+
if (names !== undefined && names !== '')
|
|
276
|
+
lines.push({ text: names, tone: 'muted' });
|
|
2319
277
|
return lines;
|
|
2320
|
-
}
|
|
2321
|
-
const openIssues = rows.filter((r) => r['status'] === 'fail' && r['fixed'] !== 'true');
|
|
2322
|
-
if (openIssues.length !== failed)
|
|
2323
|
-
return null;
|
|
2324
|
-
const shown = [];
|
|
2325
|
-
for (const row of openIssues.slice(0, SYS_DOCTOR_ISSUE_CAP)) {
|
|
2326
|
-
const name = row['name'];
|
|
2327
|
-
const message = row['message'];
|
|
2328
|
-
if (name === undefined || message === undefined)
|
|
2329
|
-
return null;
|
|
2330
|
-
shown.push(`${name}: ${message}`);
|
|
2331
|
-
}
|
|
2332
|
-
const lines = [{ text: `${failed} issue${failed === 1 ? '' : 's'}`, tone: 'error' }];
|
|
2333
|
-
for (const s of shown)
|
|
2334
|
-
lines.push({ text: s, tone: 'warning' });
|
|
2335
|
-
const remaining = openIssues.length - shown.length;
|
|
2336
|
-
if (remaining > 0)
|
|
2337
|
-
lines.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
2338
|
-
if (fixedCount > 0)
|
|
2339
|
-
lines.push({ text: `fixed ${fixedCount} automatically`, tone: 'muted' });
|
|
2340
|
-
return lines;
|
|
278
|
+
};
|
|
2341
279
|
}
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
* latest,up_to_date,unreachable — no version field at the top level, so
|
|
2347
|
-
* command-icons.md's `vX.Y.Z` framing doesn't fit this leaf; that's `sys
|
|
2348
|
-
* version`'s shape, not this one); or apply — `- target: self|content|all`
|
|
2349
|
-
* + `- status: done|failed` + `- error: <msg>` (only on failure). Verified
|
|
2350
|
-
* against the live `--check` path, 2026-07-01; the apply path is read from
|
|
2351
|
-
* source only — running it mutates the install (spawns npm/git). Returns
|
|
2352
|
-
* null when neither shape's required bullets/table are present. */
|
|
2353
|
-
function summarizeSysUpdate(text) {
|
|
2354
|
-
const upToDateMatch = /^- up_to_date:\s*(true|false)\s*$/m.exec(text);
|
|
2355
|
-
if (upToDateMatch !== null) {
|
|
2356
|
-
const rows = parseTableRows(text, 'updates');
|
|
2357
|
-
if (rows === null)
|
|
2358
|
-
return null;
|
|
2359
|
-
if (upToDateMatch[1] === 'true') {
|
|
2360
|
-
// sys update's own computation is `up_to_date = updates.every(u =>
|
|
2361
|
-
// u.up_to_date || u.unreachable)` (update.ts:104) — a row that
|
|
2362
|
-
// disagrees (reachable and NOT up to date) contradicts the bullet.
|
|
2363
|
-
if (!rows.every((r) => r['up_to_date'] === 'true' || r['unreachable'] === 'true'))
|
|
2364
|
-
return null;
|
|
2365
|
-
return [{ text: `PASS · ${rows.length} up to date`, tone: 'success' }];
|
|
2366
|
-
}
|
|
2367
|
-
const outdated = [];
|
|
2368
|
-
let unreachable = 0;
|
|
2369
|
-
for (const row of rows) {
|
|
2370
|
-
const name = row['name'];
|
|
2371
|
-
const current = row['current'];
|
|
2372
|
-
const latest = row['latest'];
|
|
2373
|
-
const isUpToDate = row['up_to_date'];
|
|
2374
|
-
const isUnreachable = row['unreachable'];
|
|
2375
|
-
if (name === undefined || isUpToDate === undefined || isUnreachable === undefined)
|
|
2376
|
-
return null;
|
|
2377
|
-
if (isUnreachable === 'true') {
|
|
2378
|
-
unreachable++;
|
|
2379
|
-
continue;
|
|
2380
|
-
}
|
|
2381
|
-
if (isUpToDate === 'false')
|
|
2382
|
-
outdated.push(`${name}: ${current} → ${latest}`);
|
|
2383
|
-
}
|
|
2384
|
-
const lines = [{ text: `${outdated.length} outdated · ${rows.length} checked`, tone: 'warning' }];
|
|
2385
|
-
const shown = outdated.slice(0, SYS_UPDATE_OUTDATED_CAP);
|
|
2386
|
-
for (const s of shown)
|
|
2387
|
-
lines.push({ text: s, tone: 'muted' });
|
|
2388
|
-
const remaining = outdated.length - shown.length;
|
|
2389
|
-
if (remaining > 0)
|
|
2390
|
-
lines.push({ text: `… ${remaining} more`, tone: 'muted' });
|
|
2391
|
-
if (unreachable > 0)
|
|
2392
|
-
lines.push({ text: `${unreachable} unreachable`, tone: 'muted' });
|
|
2393
|
-
return lines;
|
|
2394
|
-
}
|
|
2395
|
-
const statusMatch = /^- status:\s*(done|failed)\s*$/m.exec(text);
|
|
2396
|
-
if (statusMatch === null)
|
|
280
|
+
function summarizeAttentionMap(input) {
|
|
281
|
+
const value = record(input);
|
|
282
|
+
const counts = value?.['counts'];
|
|
283
|
+
if (counts === null || typeof counts !== 'object' || Array.isArray(counts))
|
|
2397
284
|
return null;
|
|
2398
|
-
const
|
|
2399
|
-
if (
|
|
285
|
+
const entries = Object.entries(counts);
|
|
286
|
+
if (entries.some(([, count]) => typeof count !== 'number'))
|
|
2400
287
|
return null;
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
const
|
|
2405
|
-
|
|
2406
|
-
lines.push({ text: error, tone: 'muted' });
|
|
2407
|
-
return lines;
|
|
288
|
+
const total = entries.reduce((sum, [, count]) => sum + count, 0);
|
|
289
|
+
if (total === 0)
|
|
290
|
+
return [{ text: 'PASS · no pending asks', tone: 'success' }];
|
|
291
|
+
const hottest = entries.filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1])[0];
|
|
292
|
+
return hottest === undefined ? null : [{ text: `${total} asks`, tone: 'warning' }, { text: `hottest: ${hottest[0]} (${hottest[1]})`, tone: 'muted' }];
|
|
2408
293
|
}
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
* can cross the prose threshold with a long home dir, and `error` is a
|
|
2414
|
-
* free-form spawn/gh message that often does — read via
|
|
2415
|
-
* readFieldFlexible/soloProseAfterBullets, the same two-shape pattern
|
|
2416
|
-
* Batch C's human/memory summarizers use for a field whose bullet-vs-prose
|
|
2417
|
-
* form depends on string length). Read from source only — running it files
|
|
2418
|
-
* a real GitHub issue or writes a local report, so it was not invoked
|
|
2419
|
-
* live. Returns null when `status` isn't `filed`/`saved-locally`, or the
|
|
2420
|
-
* shape's required companion field is missing. */
|
|
2421
|
-
function summarizeSysFeedback(text) {
|
|
2422
|
-
const statusMatch = /^- status:\s*(filed|saved-locally)\s*$/m.exec(text);
|
|
2423
|
-
if (statusMatch === null)
|
|
2424
|
-
return null;
|
|
2425
|
-
if (statusMatch[1] === 'filed') {
|
|
2426
|
-
const url = readFieldFlexible(text, 'url') ?? soloProseAfterBullets(text);
|
|
2427
|
-
if (url === undefined)
|
|
294
|
+
function summarizeDaemon(field, active, inactive) {
|
|
295
|
+
return (input) => {
|
|
296
|
+
const value = record(input);
|
|
297
|
+
if (value === undefined)
|
|
2428
298
|
return null;
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
{ text: url, tone: 'muted' },
|
|
2432
|
-
];
|
|
2433
|
-
}
|
|
2434
|
-
const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
|
|
2435
|
-
if (path === undefined)
|
|
2436
|
-
return null;
|
|
2437
|
-
// The reason (gh missing, unauthenticated, or the API call itself
|
|
2438
|
-
// failing — feedback.ts:188–195) is prose the digest doesn't try to
|
|
2439
|
-
// classify; "not filed" states the fact without guessing the cause.
|
|
2440
|
-
return [
|
|
2441
|
-
{ text: 'saved locally — not filed', tone: 'warning' },
|
|
2442
|
-
{ text: basename(path), tone: 'muted' },
|
|
2443
|
-
];
|
|
2444
|
-
}
|
|
2445
|
-
// ---------------------------------------------------------------------------
|
|
2446
|
-
// Sys sync/support/diagnostic batch. The three migration leaves have the
|
|
2447
|
-
// same bespoke `header + optional GFM table` render shape; support has the
|
|
2448
|
-
// generic result-object shape; and logs is a bounded normalized event stream.
|
|
2449
|
-
// These helpers retain error fallback rather than treating a near match as a
|
|
2450
|
-
// successful operation.
|
|
2451
|
-
// ---------------------------------------------------------------------------
|
|
2452
|
-
function parseSyncRows(text) {
|
|
2453
|
-
const lines = text.split('\n');
|
|
2454
|
-
let i = 1;
|
|
2455
|
-
while (i < lines.length && lines[i].trim() === '')
|
|
2456
|
-
i++;
|
|
2457
|
-
const header = splitTableRow(lines[i] ?? '');
|
|
2458
|
-
const separator = splitTableRow(lines[i + 1] ?? '');
|
|
2459
|
-
if (header === null ||
|
|
2460
|
-
separator === null ||
|
|
2461
|
-
header.length !== 4 ||
|
|
2462
|
-
header.join('\u0000') !== 'status\u0000memory doc\u0000target\u0000source' ||
|
|
2463
|
-
separator.length !== header.length ||
|
|
2464
|
-
separator.some((cell) => cell !== '---'))
|
|
2465
|
-
return null;
|
|
2466
|
-
const rows = [];
|
|
2467
|
-
i += 2;
|
|
2468
|
-
while (i < lines.length && lines[i].trim() !== '') {
|
|
2469
|
-
const cells = splitTableRow(lines[i]);
|
|
2470
|
-
if (cells === null || cells.length !== header.length)
|
|
299
|
+
const state = booleanField(value, field);
|
|
300
|
+
if (state === undefined)
|
|
2471
301
|
return null;
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
rows.push(row);
|
|
2476
|
-
i++;
|
|
2477
|
-
}
|
|
2478
|
-
while (i < lines.length && lines[i].trim() === '')
|
|
2479
|
-
i++;
|
|
2480
|
-
return i === lines.length ? rows : null;
|
|
302
|
+
const pid = numberField(value, 'pid') ?? numberField(value, 'existing_pid');
|
|
303
|
+
return [{ text: state ? `${active}${pid === undefined ? '' : ` · pid ${pid}`}` : inactive, tone: state ? 'success' : 'muted' }];
|
|
304
|
+
};
|
|
2481
305
|
}
|
|
2482
|
-
function
|
|
2483
|
-
const
|
|
2484
|
-
|
|
2485
|
-
? { text: `all ${primary.count} candidates accounted for`, tone: 'muted' }
|
|
2486
|
-
: {
|
|
2487
|
-
text: nonzero.map((item) => `${item.count} ${item.label}`).join(' · '),
|
|
2488
|
-
tone: nonzero.some((item) => item.tone === 'warning') ? 'warning' : 'muted',
|
|
2489
|
-
};
|
|
2490
|
-
return [{ text: `${primary.count} ${primary.label}`, tone: primary.tone }, aside];
|
|
2491
|
-
}
|
|
2492
|
-
/** The three `sys sync` leaves render an exact count header followed by either
|
|
2493
|
-
* `No sync candidates.` or a fixed four-column table. The header deliberately
|
|
2494
|
-
* repeats the table counts, so both are checked before collapsing the batch. */
|
|
2495
|
-
function summarizeSysSync(text, lane, allowedStatuses, tokens) {
|
|
2496
|
-
const first = text.split('\n')[0] ?? '';
|
|
2497
|
-
const escapedLane = lane.replace(/-/g, '\\-');
|
|
2498
|
-
const head = new RegExp(`^crtr sys sync ${escapedLane} — (?:(dry run): )?(\\d+) (would import|imported|would ignore|ignored), (\\d+) skipped(?:, (\\d+) (ignored|excluded))?$`).exec(first);
|
|
2499
|
-
if (head === null)
|
|
2500
|
-
return null;
|
|
2501
|
-
const primaryLabel = head[3];
|
|
2502
|
-
const primaryStatus = primaryLabel.replace(/ /g, '-');
|
|
2503
|
-
const primaryCount = Number(head[2]);
|
|
2504
|
-
const skippedCount = Number(head[4]);
|
|
2505
|
-
const extraCount = head[5] === undefined ? 0 : Number(head[5]);
|
|
2506
|
-
const extraStatus = head[6];
|
|
2507
|
-
const headerDryRun = head[1] === 'dry run';
|
|
2508
|
-
if (!allowedStatuses.includes(primaryStatus) || !allowedStatuses.includes('skipped') || (extraStatus !== undefined && !allowedStatuses.includes(extraStatus)))
|
|
2509
|
-
return null;
|
|
2510
|
-
if (headerDryRun !== (primaryStatus === 'would-import'))
|
|
306
|
+
function summarizeHumanAsk(input, tokens) {
|
|
307
|
+
const value = record(input);
|
|
308
|
+
if (value === undefined || stringField(value, 'job_id') === undefined)
|
|
2511
309
|
return null;
|
|
2512
|
-
|
|
2513
|
-
// skipped/excluded. argv remains authoritative for that otherwise identical
|
|
2514
|
-
// rendered shape.
|
|
2515
|
-
const dryRun = headerDryRun || tokens.includes('--dry-run');
|
|
2516
|
-
const summaryPrimaryLabel = dryRun
|
|
2517
|
-
? primaryStatus === 'imported' ? 'would import' : primaryStatus === 'ignored' ? 'would ignore' : primaryLabel
|
|
2518
|
-
: primaryLabel;
|
|
2519
|
-
const noCandidates = `crtr sys sync ${lane} — ${first.split(' — ')[1]}\n\nNo sync candidates.`;
|
|
2520
|
-
if (text === noCandidates) {
|
|
2521
|
-
if (primaryCount !== 0 || skippedCount !== 0 || extraCount !== 0)
|
|
2522
|
-
return null;
|
|
2523
|
-
return [
|
|
2524
|
-
{ text: 'no sync candidates', tone: 'muted' },
|
|
2525
|
-
{ text: lane, tone: 'muted' },
|
|
2526
|
-
];
|
|
2527
|
-
}
|
|
2528
|
-
const rows = parseSyncRows(text);
|
|
2529
|
-
if (rows === null || rows.length === 0)
|
|
2530
|
-
return null;
|
|
2531
|
-
const counts = new Map();
|
|
2532
|
-
for (const row of rows) {
|
|
2533
|
-
const status = row['status'];
|
|
2534
|
-
if (status === undefined || !allowedStatuses.includes(status))
|
|
2535
|
-
return null;
|
|
2536
|
-
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
2537
|
-
}
|
|
2538
|
-
if ((counts.get(primaryStatus) ?? 0) !== primaryCount ||
|
|
2539
|
-
(counts.get('skipped') ?? 0) !== skippedCount ||
|
|
2540
|
-
(extraStatus === undefined ? 0 : counts.get(extraStatus) ?? 0) !== extraCount)
|
|
2541
|
-
return null;
|
|
2542
|
-
for (const [status, count] of counts) {
|
|
2543
|
-
if (count > 0 && status !== primaryStatus && status !== 'skipped' && status !== extraStatus)
|
|
2544
|
-
return null;
|
|
2545
|
-
}
|
|
2546
|
-
const hasSkipped = skippedCount > 0;
|
|
2547
|
-
return summarizeBatchDigest({
|
|
2548
|
-
count: primaryCount,
|
|
2549
|
-
label: summaryPrimaryLabel,
|
|
2550
|
-
tone: hasSkipped ? 'warning' : primaryCount === 0 ? 'muted' : dryRun || primaryStatus === 'would-ignore' ? 'accent' : 'success',
|
|
2551
|
-
}, [
|
|
2552
|
-
{ count: skippedCount, label: 'skipped', tone: 'warning' },
|
|
2553
|
-
...(extraStatus === undefined ? [] : [{ count: extraCount, label: extraStatus, tone: 'muted' }]),
|
|
2554
|
-
]);
|
|
2555
|
-
}
|
|
2556
|
-
/** Shared health framing for a diagnostic stream: the top line communicates
|
|
2557
|
-
* severity while the muted aside preserves the useful bounded breakdown. */
|
|
2558
|
-
function summarizeHealthStatus(recordCount, errors, warnings, aside) {
|
|
2559
|
-
if (errors > 0) {
|
|
2560
|
-
return [
|
|
2561
|
-
{ text: `${recordCount} records · ${errors} errors${warnings > 0 ? ` · ${warnings} warnings` : ''}`, tone: 'error' },
|
|
2562
|
-
{ text: aside, tone: 'muted' },
|
|
2563
|
-
];
|
|
2564
|
-
}
|
|
2565
|
-
if (warnings > 0) {
|
|
2566
|
-
return [
|
|
2567
|
-
{ text: `${recordCount} records · ${warnings} warnings`, tone: 'warning' },
|
|
2568
|
-
{ text: aside, tone: 'muted' },
|
|
2569
|
-
];
|
|
2570
|
-
}
|
|
310
|
+
const question = tokens.slice(2).find((token) => !token.startsWith('--'));
|
|
2571
311
|
return [
|
|
2572
|
-
{ text:
|
|
2573
|
-
{ text:
|
|
312
|
+
{ text: 'ask posted → waiting on human', tone: 'accent' },
|
|
313
|
+
...(question === undefined ? [] : [{ text: truncateLine(question.split('\n')[0].trim(), 160), tone: 'muted' }]),
|
|
2574
314
|
];
|
|
2575
315
|
}
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
if (text === '')
|
|
2580
|
-
return summarizeHealthStatus(0, 0, 0, 'no matching diagnostics');
|
|
2581
|
-
const componentCounts = new Map();
|
|
2582
|
-
let errors = 0;
|
|
2583
|
-
let warnings = 0;
|
|
2584
|
-
let raw = 0;
|
|
2585
|
-
const lines = text.split('\n');
|
|
2586
|
-
if (lines.at(-1) === '')
|
|
2587
|
-
lines.pop();
|
|
2588
|
-
if (lines.length === 0 || lines.some((line) => line === ''))
|
|
316
|
+
function summarizeHumanResolve(input) {
|
|
317
|
+
const value = record(input);
|
|
318
|
+
if (value === undefined)
|
|
2589
319
|
return null;
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
componentCounts.set(event[2], (componentCounts.get(event[2]) ?? 0) + 1);
|
|
2594
|
-
if (event[1] === 'ERROR')
|
|
2595
|
-
errors++;
|
|
2596
|
-
else if (event[1] === 'WARN')
|
|
2597
|
-
warnings++;
|
|
2598
|
-
continue;
|
|
2599
|
-
}
|
|
2600
|
-
const residue = /^RAW \S+ \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}) (.+)$/.exec(line);
|
|
2601
|
-
if (residue === null)
|
|
2602
|
-
return null;
|
|
2603
|
-
try {
|
|
2604
|
-
JSON.parse(residue[1]);
|
|
2605
|
-
}
|
|
2606
|
-
catch {
|
|
2607
|
-
return null;
|
|
2608
|
-
}
|
|
2609
|
-
raw++;
|
|
2610
|
-
}
|
|
2611
|
-
const componentDigest = summarizeOrderedCounts(componentCounts, ['daemon', 'broker']);
|
|
2612
|
-
const aside = [componentDigest, raw > 0 ? `${raw} raw` : ''].filter((part) => part !== '').join(' · ');
|
|
2613
|
-
return summarizeHealthStatus(lines.length, errors, warnings, aside === '' ? 'no component detail' : aside);
|
|
2614
|
-
}
|
|
2615
|
-
function summarizeSysSupportPrepare(text) {
|
|
2616
|
-
if (!/^- status:\s*prepared\s*$/m.test(text))
|
|
2617
|
-
return null;
|
|
2618
|
-
const reference = /^- reference:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
2619
|
-
const node = /^- node_id:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
2620
|
-
const sourceCount = /^- source_count:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
2621
|
-
const bytes = /^- bytes:\s*(\d+)\s*$/m.exec(text)?.[1];
|
|
2622
|
-
const path = readFieldFlexible(text, 'path') ?? soloProseAfterBullets(text);
|
|
2623
|
-
if (reference === undefined || node === undefined || sourceCount === undefined || bytes === undefined || path === undefined)
|
|
320
|
+
const resolved = booleanField(value, 'resolved');
|
|
321
|
+
const job = stringField(value, 'job_id');
|
|
322
|
+
if (resolved === undefined || job === undefined)
|
|
2624
323
|
return null;
|
|
2625
|
-
return [
|
|
2626
|
-
{ text: `prepared ${reference}`, tone: 'success' },
|
|
2627
|
-
{ text: `${node} · ${sourceCount} sources · ${bytes} bytes`, tone: 'muted' },
|
|
2628
|
-
];
|
|
324
|
+
return [{ text: `${resolved ? 'resolved' : 'not resolved'} ${job}`, tone: resolved ? 'success' : 'warning' }];
|
|
2629
325
|
}
|
|
2630
|
-
function
|
|
2631
|
-
|
|
326
|
+
function summarizeStatus(input) {
|
|
327
|
+
const value = record(input);
|
|
328
|
+
if (value === undefined)
|
|
2632
329
|
return null;
|
|
2633
|
-
const
|
|
2634
|
-
|
|
2635
|
-
const url = readFieldFlexible(text, 'url') ?? soloProseAfterBullets(text);
|
|
2636
|
-
if (reference === undefined || repository === undefined || url === undefined)
|
|
330
|
+
const status = stringField(value, 'status');
|
|
331
|
+
if (status === undefined)
|
|
2637
332
|
return null;
|
|
2638
|
-
return [
|
|
2639
|
-
{ text: `submitted ${reference}`, tone: 'success' },
|
|
2640
|
-
{ text: repository, tone: 'muted' },
|
|
2641
|
-
{ text: url, tone: 'muted' },
|
|
2642
|
-
];
|
|
333
|
+
return [{ text: status, tone: ['done', 'success', 'submitted', 'filed'].includes(status) ? 'success' : status === 'failed' ? 'error' : 'plain' }];
|
|
2643
334
|
}
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
function summarizeSurfaceNodeFocus(text, tokens) {
|
|
2648
|
-
const focused = /^- focused:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
2649
|
-
const revived = /^- revived:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
2650
|
-
const inPlace = /^- in_place:\s*(true|false)\s*$/m.exec(text)?.[1];
|
|
2651
|
-
const nodeId = firstPositionalArg(tokens, 3, new Set(['--pane']));
|
|
2652
|
-
if (focused === undefined || revived === undefined || inPlace === undefined || nodeId === undefined)
|
|
335
|
+
function summarizeProfileList(input) {
|
|
336
|
+
const records = input.jsonl;
|
|
337
|
+
if (records === undefined)
|
|
2653
338
|
return null;
|
|
2654
|
-
const
|
|
2655
|
-
|
|
2656
|
-
? { text: `focused ${nodeId}`, tone: 'success' }
|
|
2657
|
-
: { text: `could not focus ${nodeId}`, tone: 'warning' },
|
|
2658
|
-
];
|
|
2659
|
-
if (revived === 'true')
|
|
2660
|
-
lines.push({ text: 'broker revived', tone: 'accent' });
|
|
2661
|
-
else if (focused === 'true')
|
|
2662
|
-
lines.push({ text: inPlace === 'true' ? 'existing surface selected' : 'opened', tone: 'muted' });
|
|
2663
|
-
return lines;
|
|
2664
|
-
}
|
|
2665
|
-
/** `surface edit` renders a single receipt whose absolute path can dominate a
|
|
2666
|
-
* collapsed card. Keep the user-facing file asset and pane, with the full path
|
|
2667
|
-
* still available in the expanded raw result. */
|
|
2668
|
-
function summarizeSurfaceEdit(text) {
|
|
2669
|
-
const match = /^opened (.+) in a new pane \((%[^)]+)\)$/.exec(text);
|
|
2670
|
-
if (match === null)
|
|
339
|
+
const profiles = records.filter((item) => stringField(item, 'name') !== undefined && numberField(item, 'projects_count') !== undefined);
|
|
340
|
+
if (profiles.length === 0 && records.length > 0)
|
|
2671
341
|
return null;
|
|
2672
342
|
return [
|
|
2673
|
-
{ text:
|
|
2674
|
-
{ text: `${
|
|
343
|
+
{ text: `${profiles.length} profile${profiles.length === 1 ? '' : 's'}` },
|
|
344
|
+
...profiles.slice(0, 5).map((profile) => ({ text: `${stringField(profile, 'name')} · ${numberField(profile, 'projects_count')} projects`, tone: 'muted' })),
|
|
2675
345
|
];
|
|
2676
346
|
}
|
|
2677
347
|
const SUMMARIZERS = [
|
|
2678
348
|
{ path: 'memory read', run: summarizeMemoryRead },
|
|
2679
|
-
{ path: 'memory
|
|
2680
|
-
{ path: '
|
|
2681
|
-
{ path: '
|
|
2682
|
-
{ path: 'canvas attention
|
|
2683
|
-
{ path: 'canvas attention
|
|
2684
|
-
{ path: 'canvas attention map', run: summarizeCanvasAttentionMap },
|
|
2685
|
-
{ path: 'canvas prune', run: summarizeCanvasPrune },
|
|
2686
|
-
{ path: 'canvas browse', run: summarizeCanvasBrowse },
|
|
2687
|
-
{ path: 'canvas revive', run: summarizeCanvasRevive },
|
|
2688
|
-
{ path: 'canvas chord', run: summarizeCanvasChord },
|
|
2689
|
-
{ path: 'node lifecycle revive', run: summarizeNodeLifecycleRevive },
|
|
2690
|
-
{ path: 'canvas history search', run: summarizeCanvasHistorySearch },
|
|
2691
|
-
{ path: 'canvas history read', run: summarizeCanvasHistoryRead },
|
|
2692
|
-
{ path: 'canvas history grep', run: summarizeCanvasHistoryGrep },
|
|
2693
|
-
{ path: 'node inspect artifacts', run: summarizeNodeInspectArtifacts },
|
|
2694
|
-
{ path: 'node inspect context', run: summarizeNodeInspectContext },
|
|
2695
|
-
{ path: 'node inspect transcript', run: summarizeNodeInspectTranscript },
|
|
2696
|
-
{ path: 'node fork', run: summarizeNodeFork },
|
|
2697
|
-
{ path: 'node worktree close', run: summarizeNodeWorktreeClose },
|
|
2698
|
-
{ path: 'sys daemon start', run: summarizeSysDaemonStart },
|
|
2699
|
-
{ path: 'sys daemon status', run: summarizeSysDaemonStatus },
|
|
2700
|
-
{ path: 'sys daemon stop', run: summarizeSysDaemonStop },
|
|
2701
|
-
{ path: 'canvas issue new', run: summarizeCanvasIssueNew },
|
|
2702
|
-
{ path: 'node new', run: summarizeNodeNew },
|
|
2703
|
-
{ path: 'node message send', run: summarizeNodeMessageEngine },
|
|
2704
|
-
{ path: 'node message request', run: summarizeNodeMessageEngine },
|
|
2705
|
-
{ path: 'node wait deadline', run: summarizeNodeWaitDeadline },
|
|
2706
|
-
{ path: 'node yield', run: summarizeNodeYield },
|
|
2707
|
-
{ path: 'node lifecycle close', run: summarizeNodeLifecycleClose },
|
|
2708
|
-
{ path: 'push urgent', run: summarizePushUrgent },
|
|
2709
|
-
{ path: 'push final', run: summarizePushFinal },
|
|
349
|
+
{ path: 'memory list', run: summarizeArray('docs') },
|
|
350
|
+
{ path: 'memory find', run: summarizeArray('hits', 'hits') },
|
|
351
|
+
{ path: 'canvas attention count', run: summarizeCount('pending asks', true) },
|
|
352
|
+
{ path: 'canvas attention list', run: summarizeArray('pending asks') },
|
|
353
|
+
{ path: 'canvas attention map', run: summarizeAttentionMap },
|
|
2710
354
|
{ path: 'human ask', run: summarizeHumanAsk },
|
|
2711
|
-
{ path: 'human
|
|
2712
|
-
{ path: 'human list', run: summarizeHumanList },
|
|
2713
|
-
{ path: 'human deck', run: summarizeHumanDeck },
|
|
2714
|
-
{ path: 'human notify', run: summarizeHumanNotify },
|
|
2715
|
-
{ path: 'human show', run: summarizeHumanShow },
|
|
2716
|
-
{ path: 'human cancel', run: summarizeHumanCancel },
|
|
355
|
+
{ path: 'human list', run: summarizeArray('pending') },
|
|
2717
356
|
{ path: 'human resolve', run: summarizeHumanResolve },
|
|
2718
|
-
{ path: '
|
|
2719
|
-
{ path: '
|
|
2720
|
-
{ path: '
|
|
2721
|
-
{ path: '
|
|
2722
|
-
{ path: 'node
|
|
2723
|
-
{ path: '
|
|
2724
|
-
{ path: '
|
|
2725
|
-
{ path: '
|
|
2726
|
-
{ path: '
|
|
357
|
+
{ path: 'pkg plugin list', run: summarizeArray('plugins') },
|
|
358
|
+
{ path: 'pkg market list', run: summarizeArray('marketplaces') },
|
|
359
|
+
{ path: 'pkg market browse', run: summarizeArray('plugins') },
|
|
360
|
+
{ path: 'canvas dashboard', run: summarizeArray('nodes', 'rows') },
|
|
361
|
+
{ path: 'node inspect list', run: summarizeArray('nodes', 'nodes') },
|
|
362
|
+
{ path: 'sys daemon start', run: summarizeDaemon('started', 'started', 'already running') },
|
|
363
|
+
{ path: 'sys daemon status', run: summarizeDaemon('running', 'running', 'not running') },
|
|
364
|
+
{ path: 'sys daemon stop', run: summarizeDaemon('stopped', 'stopped', 'was not running') },
|
|
365
|
+
{ path: 'sys update', run: summarizeStatus },
|
|
366
|
+
{ path: 'sys feedback', run: summarizeStatus },
|
|
2727
367
|
{ path: 'profile list', run: summarizeProfileList },
|
|
2728
|
-
{ path: 'profile show', run: summarizeProfileShow },
|
|
2729
|
-
{ path: 'profile default show', run: summarizeProfileDefaultShow },
|
|
2730
|
-
{ path: 'profile env list', run: summarizeProfileEnvList },
|
|
2731
|
-
{ path: 'canvas config list', run: summarizeCanvasConfigList },
|
|
2732
|
-
{ path: 'sys config get', run: summarizeSysConfigGet },
|
|
2733
|
-
{ path: 'sys config path', run: summarizeSysConfigPath },
|
|
2734
|
-
{ path: 'sys config set', run: summarizeSysConfigSet },
|
|
2735
|
-
{ path: 'canvas config add', run: summarizeCanvasConfigAdd },
|
|
2736
|
-
{ path: 'canvas config remove', run: summarizeCanvasConfigRemove },
|
|
2737
|
-
{ path: 'canvas use', run: summarizeCanvasUse },
|
|
2738
|
-
{ path: 'pkg plugin install', run: summarizePkgPluginInstall },
|
|
2739
|
-
{ path: 'pkg plugin remove', run: summarizePkgPluginRemove },
|
|
2740
|
-
{ path: 'pkg plugin enable', run: summarizePkgPluginToggle },
|
|
2741
|
-
{ path: 'pkg plugin disable', run: summarizePkgPluginToggle },
|
|
2742
|
-
{ path: 'pkg plugin update', run: summarizePkgPluginUpdate },
|
|
2743
|
-
{ path: 'pkg market add', run: summarizePkgMarketAdd },
|
|
2744
|
-
{ path: 'pkg market remove', run: summarizePkgMarketRemove },
|
|
2745
|
-
{ path: 'pkg market update', run: summarizePkgPluginUpdate },
|
|
2746
|
-
{ path: 'pkg plugin list', run: summarizePkgPluginList },
|
|
2747
|
-
{ path: 'pkg plugin show', run: summarizePkgPluginShow },
|
|
2748
|
-
{ path: 'pkg market list', run: summarizePkgMarketList },
|
|
2749
|
-
{ path: 'pkg market browse', run: summarizePkgMarketBrowse },
|
|
2750
|
-
{ path: 'search web', run: summarizeSearchWeb },
|
|
2751
|
-
{ path: 'search answer', run: summarizeSearchAnswer },
|
|
2752
|
-
{ path: 'search contents', run: summarizeSearchContents },
|
|
2753
|
-
{ path: 'sys setup', run: summarizeSysSetup },
|
|
2754
|
-
{ path: 'sys doctor', run: summarizeSysDoctor },
|
|
2755
|
-
{ path: 'sys update', run: summarizeSysUpdate },
|
|
2756
|
-
{ path: 'sys feedback', run: summarizeSysFeedback },
|
|
2757
|
-
{ path: 'sys sync skills', run: (text, tokens) => summarizeSysSync(text, 'skills', ['imported', 'would-import', 'skipped', 'ignored', 'would-ignore'], tokens) },
|
|
2758
|
-
{ path: 'sys sync project-guidance', run: (text, tokens) => summarizeSysSync(text, 'project-guidance', ['imported', 'would-import', 'skipped'], tokens) },
|
|
2759
|
-
{ path: 'sys sync deps', run: (text, tokens) => summarizeSysSync(text, 'deps', ['imported', 'would-import', 'skipped', 'excluded'], tokens) },
|
|
2760
|
-
{ path: 'sys logs', run: summarizeSysLogs },
|
|
2761
|
-
{ path: 'sys support prepare', run: summarizeSysSupportPrepare },
|
|
2762
|
-
{ path: 'sys support submit', run: summarizeSysSupportSubmit },
|
|
2763
|
-
{ path: 'surface node focus', run: summarizeSurfaceNodeFocus },
|
|
2764
|
-
{ path: 'surface edit', run: summarizeSurfaceEdit },
|
|
2765
368
|
];
|
|
2766
|
-
/**
|
|
2767
|
-
*
|
|
2768
|
-
*
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
best = { words: words.length, run };
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
return best?.run(rawText, tokens) ?? null;
|
|
369
|
+
/** Summarize an exact CLI result record. The recorded path is authoritative:
|
|
370
|
+
* shell tokenization is used only for an invocation's static icon/help chrome,
|
|
371
|
+
* never to identify or decode the returned leaf data. */
|
|
372
|
+
export function summarizePath(preview, tokens = []) {
|
|
373
|
+
const error = summarizeError(preview);
|
|
374
|
+
if (error !== null)
|
|
375
|
+
return error;
|
|
376
|
+
const best = SUMMARIZERS.find(({ path }) => path === preview.path);
|
|
377
|
+
return best?.run(preview, tokens) ?? null;
|
|
2779
378
|
}
|