@north-light/crouter 0.3.217 → 0.3.218

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/api/client.d.ts +5 -2
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/dto/canvas.d.ts +9 -0
  4. package/dist/api/dto/nodes.d.ts +27 -1
  5. package/dist/builtin-memory/04-base-worker.md +7 -1
  6. package/dist/builtin-memory/internal/plugins.md +67 -0
  7. package/dist/builtin-memory/memory-read-orientation.md +13 -0
  8. package/dist/clients/attach/chrome/bash-jobs.js +1 -1
  9. package/dist/clients/attach/photon_rs_bg.wasm +0 -0
  10. package/dist/clients/attach/viewer.js +429 -429
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/memory/edit.js +6 -1
  13. package/dist/commands/memory/lint.d.ts +1 -6
  14. package/dist/commands/memory/lint.js +18 -126
  15. package/dist/commands/memory/list.d.ts +1 -0
  16. package/dist/commands/memory/list.js +17 -2
  17. package/dist/commands/memory/read.js +15 -6
  18. package/dist/commands/memory/shared.d.ts +32 -2
  19. package/dist/commands/memory/shared.js +179 -0
  20. package/dist/commands/memory/write.js +6 -1
  21. package/dist/commands/pkg/browse/catalog.js +2 -0
  22. package/dist/commands/pkg/browse/model.d.ts +4 -1
  23. package/dist/commands/pkg/plugin-manage.js +209 -148
  24. package/dist/core/bash-jobs.d.ts +2 -5
  25. package/dist/core/bash-jobs.js +4 -8
  26. package/dist/core/command-plugins/bundle.d.ts +4 -1
  27. package/dist/core/command-plugins/bundle.js +16 -2
  28. package/dist/core/human/component-docs.js +1 -0
  29. package/dist/core/human/scan.d.ts +5 -4
  30. package/dist/core/human/scan.js +7 -4
  31. package/dist/core/io.js +3 -2
  32. package/dist/core/manifest.d.ts +2 -0
  33. package/dist/core/manifest.js +5 -0
  34. package/dist/core/memory/extensions.d.ts +30 -0
  35. package/dist/core/memory/extensions.js +219 -0
  36. package/dist/core/preview-result-path.d.ts +4 -0
  37. package/dist/core/preview-result-path.js +25 -0
  38. package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
  39. package/dist/core/substrate/frontmatter-validation.js +101 -0
  40. package/dist/core/substrate/index.d.ts +1 -0
  41. package/dist/core/substrate/index.js +1 -0
  42. package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
  43. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
  44. package/dist/daemon/api/handlers/canvas.js +3 -0
  45. package/dist/daemon/api/handlers/inbox.js +4 -3
  46. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  47. package/dist/daemon/api/handlers/nodes.js +101 -19
  48. package/dist/daemon/api/handlers/reports.d.ts +4 -0
  49. package/dist/daemon/api/handlers/reports.js +14 -8
  50. package/dist/daemon/crtrd.js +7 -5
  51. package/dist/daemon/manage.d.ts +3 -0
  52. package/dist/daemon/manage.js +14 -0
  53. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
  54. package/dist/pi-extensions/canvas-bash-valve.js +19 -6
  55. package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
  56. package/dist/pi-extensions/canvas-preview-result.js +9 -23
  57. package/dist/types.d.ts +31 -0
  58. package/package.json +1 -1
  59. package/runtime.lock.json +2 -2
@@ -7,12 +7,13 @@
7
7
  //
8
8
  // `{id}` is the target node.
9
9
  import { readdirSync } from 'node:fs';
10
- import { resolve } from 'node:path';
10
+ import { basename, resolve } from 'node:path';
11
11
  import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
12
12
  import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
13
13
  import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
14
14
  import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
15
15
  import { subtreeIds } from '../../../core/canvas/nav-model.js';
16
+ import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
16
17
  import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
17
18
  import { nodeArtifacts } from '../../../core/canvas/history.js';
18
19
  import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
@@ -34,6 +35,7 @@ import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
34
35
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
35
36
  import { assertProfileActive, profileHome } from '../../../core/profiles/manifest.js';
36
37
  import { fullName } from '../../../core/canvas/labels.js';
38
+ import { reportsForNode } from './reports.js';
37
39
  import { getCron } from '../../../core/canvas/crons.js';
38
40
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
39
41
  import { notFound, usage } from '../../../core/errors.js';
@@ -266,26 +268,106 @@ export async function handleCreate(ctx, deps = {}) {
266
268
  // ---------------------------------------------------------------------------
267
269
  // GET /v1/nodes — list (filters) ; GET /v1/nodes/{id} — detail
268
270
  // ---------------------------------------------------------------------------
269
- function handleList(ctx) {
270
- const status = ctx.query.get('status');
271
- const kind = ctx.query.get('kind');
272
- const mode = ctx.query.get('mode');
273
- const under = ctx.query.get('under');
274
- const hanging = ctx.query.get('hanging');
275
- let rows = status !== null ? listNodes({ status }) : listNodes();
276
- if (kind !== null)
277
- rows = rows.filter((r) => r.kind === kind);
278
- if (mode !== null)
279
- rows = rows.filter((r) => r.mode === mode);
280
- if (under !== null) {
281
- const scope = new Set(subtreeIds(under));
282
- rows = rows.filter((r) => scope.has(r.node_id));
271
+ function singleQueryValue(ctx, name) {
272
+ const values = ctx.query.getAll(name);
273
+ if (values.length > 1)
274
+ throw usage(`${name} must appear at most once`);
275
+ return values[0];
276
+ }
277
+ function optionalBoolean(value, name) {
278
+ if (value === undefined)
279
+ return undefined;
280
+ if (value === '1' || value === 'true')
281
+ return true;
282
+ if (value === '0' || value === 'false')
283
+ return false;
284
+ throw usage(`${name} must be true or false`);
285
+ }
286
+ function oneOf(value, name, allowed) {
287
+ if (value === undefined)
288
+ return undefined;
289
+ if (allowed.includes(value))
290
+ return value;
291
+ throw usage(`${name} must be one of: ${allowed.join(', ')}`);
292
+ }
293
+ function parseListQuery(ctx) {
294
+ const allowed = new Set([
295
+ 'status', 'lifecycle', 'kind', 'mode', 'profile_id', 'profile_prefix',
296
+ 'parent', 'top_level', 'under', 'hanging', 'include',
297
+ ]);
298
+ for (const [name] of ctx.query) {
299
+ if (!allowed.has(name))
300
+ throw usage(`unknown nodes query parameter: ${name}`);
283
301
  }
284
- if (hanging === '1' || hanging === 'true') {
285
- // A dangling manager edge: a non-root whose recorded parent is gone.
286
- rows = rows.filter((r) => r.parent !== null && getNode(r.parent) === null);
302
+ const status = oneOf(singleQueryValue(ctx, 'status'), 'status', ['active', 'idle', 'done', 'dead', 'canceled']);
303
+ const lifecycle = oneOf(singleQueryValue(ctx, 'lifecycle'), 'lifecycle', ['terminal', 'resident']);
304
+ const kind = singleQueryValue(ctx, 'kind');
305
+ const mode = oneOf(singleQueryValue(ctx, 'mode'), 'mode', ['base', 'orchestrator']);
306
+ const profileId = singleQueryValue(ctx, 'profile_id');
307
+ const profilePrefix = singleQueryValue(ctx, 'profile_prefix');
308
+ const parent = singleQueryValue(ctx, 'parent');
309
+ const topLevel = optionalBoolean(singleQueryValue(ctx, 'top_level'), 'top_level');
310
+ const under = singleQueryValue(ctx, 'under');
311
+ const hanging = optionalBoolean(singleQueryValue(ctx, 'hanging'), 'hanging');
312
+ const include = oneOf(singleQueryValue(ctx, 'include'), 'include', ['activity']);
313
+ if (parent !== undefined && topLevel === true) {
314
+ throw usage('parent and top_level=true cannot be combined');
287
315
  }
288
- const body = rows.map(toNodeSummaryDTO);
316
+ return {
317
+ ...(status !== undefined ? { status } : {}),
318
+ ...(lifecycle !== undefined ? { lifecycle } : {}),
319
+ ...(kind !== undefined ? { kind } : {}),
320
+ ...(mode !== undefined ? { mode } : {}),
321
+ ...(profileId !== undefined ? { profile_id: profileId } : {}),
322
+ ...(profilePrefix !== undefined ? { profile_prefix: profilePrefix } : {}),
323
+ ...(parent !== undefined ? { parent } : {}),
324
+ ...(topLevel !== undefined ? { top_level: topLevel } : {}),
325
+ ...(under !== undefined ? { under } : {}),
326
+ ...(hanging !== undefined ? { hanging } : {}),
327
+ ...(include !== undefined ? { include: include } : {}),
328
+ };
329
+ }
330
+ export function handleList(ctx) {
331
+ const query = parseListQuery(ctx);
332
+ let rows = query.status !== undefined ? listNodes({ status: query.status }) : listNodes();
333
+ if (query.lifecycle !== undefined)
334
+ rows = rows.filter((row) => row.lifecycle === query.lifecycle);
335
+ if (query.kind !== undefined)
336
+ rows = rows.filter((row) => row.kind === query.kind);
337
+ if (query.mode !== undefined)
338
+ rows = rows.filter((row) => row.mode === query.mode);
339
+ if (query.profile_id !== undefined)
340
+ rows = rows.filter((row) => row.profile_id === query.profile_id);
341
+ if (query.profile_prefix !== undefined)
342
+ rows = rows.filter((row) => row.profile_id?.startsWith(query.profile_prefix));
343
+ if (query.parent !== undefined)
344
+ rows = rows.filter((row) => row.parent === query.parent);
345
+ if (query.top_level === true)
346
+ rows = rows.filter((row) => row.parent === null);
347
+ if (query.under !== undefined) {
348
+ const scope = new Set(subtreeIds(query.under));
349
+ rows = rows.filter((row) => scope.has(row.node_id));
350
+ }
351
+ if (query.hanging === true) {
352
+ rows = rows.filter((row) => row.parent !== null && getNode(row.parent) === null);
353
+ }
354
+ const counts = query.include === 'activity'
355
+ ? ticketCountsForNodes(rows.map((row) => row.node_id))
356
+ : null;
357
+ const body = rows.map((row) => {
358
+ const node = toNodeSummaryDTO(row);
359
+ if (counts !== null) {
360
+ const reports = reportsForNode(row.node_id);
361
+ node.activity = {
362
+ latest_report: reports[0] ?? null,
363
+ final_report: row.final_report === null
364
+ ? null
365
+ : reports.find((report) => basename(report.path) === row.final_report) ?? null,
366
+ pending_human_count: counts[row.node_id] ?? 0,
367
+ };
368
+ }
369
+ return node;
370
+ });
289
371
  return { status: 200, body };
290
372
  }
291
373
  function handleDetail(ctx) {
@@ -1,2 +1,6 @@
1
+ import type { ReportDTO } from '../../../api/index.js';
1
2
  import type { RouteTable } from '../router.js';
3
+ /** Read one node's pushed reports, newest first. This stays in the daemon so
4
+ * collection projections can include report activity without client-side N+1s. */
5
+ export declare function reportsForNode(id: string): ReportDTO[];
2
6
  export declare const reportRoutes: RouteTable;
@@ -110,12 +110,9 @@ function compactToIso(stamp) {
110
110
  const m = stamp.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
111
111
  return m === null ? stamp : `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`;
112
112
  }
113
- function handleList(ctx) {
114
- const id = ctx.params['id'];
115
- requireNode(id);
116
- const tierFilter = ctx.query.get('tier');
117
- const limitRaw = ctx.query.get('limit');
118
- const limit = limitRaw !== null ? Number(limitRaw) : undefined;
113
+ /** Read one node's pushed reports, newest first. This stays in the daemon so
114
+ * collection projections can include report activity without client-side N+1s. */
115
+ export function reportsForNode(id) {
119
116
  const dir = reportsDir(id);
120
117
  let files;
121
118
  try {
@@ -136,9 +133,18 @@ function handleList(ctx) {
136
133
  (m ? compactToIso(m[1]) : new Date(statSync(abs).mtimeMs).toISOString());
137
134
  reports.push({ path: abs, tier, body: body.trim(), created });
138
135
  }
139
- // Newest first, then apply the optional tier + limit filters.
140
136
  reports.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0));
141
- let out = tierFilter !== null ? reports.filter((r) => r.tier === tierFilter) : reports;
137
+ return reports;
138
+ }
139
+ function handleList(ctx) {
140
+ const id = ctx.params['id'];
141
+ requireNode(id);
142
+ const tierFilter = ctx.query.get('tier');
143
+ const limitRaw = ctx.query.get('limit');
144
+ const limit = limitRaw !== null ? Number(limitRaw) : undefined;
145
+ let out = reportsForNode(id);
146
+ if (tierFilter !== null)
147
+ out = out.filter((report) => report.tier === tierFilter);
142
148
  if (limit !== undefined && Number.isFinite(limit) && limit >= 0)
143
149
  out = out.slice(0, limit);
144
150
  return { status: 200, body: out };
@@ -62,7 +62,7 @@ import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.
62
62
  import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
63
63
  import { bindFleet, boundFleet } from '../core/runtime/fleet.js';
64
64
  import { bindDaemonControl, clearDaemonControl } from './control.js';
65
- import { spawnDaemon } from './manage.js';
65
+ import { execDaemon, spawnDaemon } from './manage.js';
66
66
  import { reconcileAndMigrateBoot } from '../core/canvas/boot.js';
67
67
  import { resolveInstallId } from '../core/canvas/install-id.js';
68
68
  import { setInstallId } from '../core/runtime/nodes.js';
@@ -480,11 +480,10 @@ export async function runDaemon(opts = {}) {
480
480
  }, HANDOVER_ACK_GRACE_MS);
481
481
  return { graceMs: HANDOVER_ACK_GRACE_MS };
482
482
  }
483
- /** Quiesce exactly as a SIGTERM would, then spawn the successor and exit.
483
+ /** Quiesce exactly as a SIGTERM would, then start the successor.
484
484
  * Ordering is forced: the successor's claim can only be won after this
485
- * daemon has released its own, so teardown strictly precedes the spawn. The
486
- * successor resolves its entry through `runtime/selected`, which is how it
487
- * picks up a generation this process never loaded. */
485
+ * daemon has released its own. PID 1 is replaced in place so a container
486
+ * does not stop between daemon generations; ordinary hosts spawn and exit. */
488
487
  async function performHandover() {
489
488
  try {
490
489
  await requestCleanup();
@@ -495,6 +494,9 @@ export async function runDaemon(opts = {}) {
495
494
  process.exit(1);
496
495
  }
497
496
  try {
497
+ if (process.pid === 1) {
498
+ await execDaemon();
499
+ }
498
500
  const spawned = await spawnDaemon();
499
501
  operationIdContext.fresh(() => {
500
502
  emitEvent({
@@ -36,6 +36,9 @@ export declare function findDaemonPids(): number[];
36
36
  * reaped. Best-effort throughout: a pid that dies between enumeration and signal
37
37
  * just no-ops. */
38
38
  export declare function sweepStrayDaemons(keepPid: number | null): number[];
39
+ /** Replace PID 1 with the selected daemon generation. Docker keeps a container
40
+ * alive only while PID 1 lives, so a handover there must preserve its pid. */
41
+ export declare function execDaemon(): Promise<never>;
39
42
  export interface SpawnDaemonResult {
40
43
  /** True when a new daemon process was spawned. */
41
44
  started: boolean;
@@ -190,6 +190,20 @@ export function sweepStrayDaemons(keepPid) {
190
190
  }
191
191
  return strays;
192
192
  }
193
+ // ---------------------------------------------------------------------------
194
+ // daemon launch — low-level spawn/exec
195
+ // ---------------------------------------------------------------------------
196
+ /** Replace PID 1 with the selected daemon generation. Docker keeps a container
197
+ * alive only while PID 1 lives, so a handover there must preserve its pid. */
198
+ export async function execDaemon() {
199
+ const execve = process.execve;
200
+ if (execve === undefined) {
201
+ throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);
202
+ }
203
+ const executable = hostExecPath();
204
+ const launchArgs = await resolveDaemonLaunch();
205
+ return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
206
+ }
193
207
  // Daemon lifecycle transitions are bounded so callers never claim a state
194
208
  // change before the pidfile and process liveness agree.
195
209
  //
@@ -4,9 +4,10 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
4
  * not open with a sleep (or its duration isn't statically knowable). */
5
5
  export declare function leadingSleepSeconds(command: string): number | null;
6
6
  /** The valve's BashOperations backend. Each tool execution closes over its own
7
- * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
8
- * when the command is invalid or refused, leaving no state to leak later. */
9
- export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null): BashOperations;
7
+ * `takePurpose` and its own preview-result path, so concurrent calls cannot
8
+ * exchange labels or result mirrors. The purpose is consumed even when the
9
+ * command is invalid or refused, leaving no state to leak later. */
10
+ export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null, previewPath?: string): BashOperations;
10
11
  type BashToolDefinition = ReturnType<typeof createBashToolDefinition>;
11
12
  /** Build the valve-backed bash tool and, when configured, add `purpose` to its
12
13
  * schema. Bind each purpose directly to its own execution: Pi may preflight a
@@ -27,8 +27,10 @@
27
27
  import { spawn } from 'node:child_process';
28
28
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
+ import { dirname } from 'node:path';
30
31
  import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
31
32
  import { readConfig } from '../core/config.js';
33
+ import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
32
34
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
33
35
  import { Type } from 'typebox';
34
36
  import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
@@ -218,9 +220,10 @@ function resolveExecutionCwd(cwd) {
218
220
  };
219
221
  }
220
222
  /** The valve's BashOperations backend. Each tool execution closes over its own
221
- * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
222
- * when the command is invalid or refused, leaving no state to leak later. */
223
- export function createValveOperations(nodeId, contextDir, takePurpose = () => null) {
223
+ * `takePurpose` and its own preview-result path, so concurrent calls cannot
224
+ * exchange labels or result mirrors. The purpose is consumed even when the
225
+ * command is invalid or refused, leaving no state to leak later. */
226
+ export function createValveOperations(nodeId, contextDir, takePurpose = () => null, previewPath) {
224
227
  return {
225
228
  exec: (command, cwd, { onData, signal, timeout, env }) => {
226
229
  return new Promise((resolve, reject) => {
@@ -309,12 +312,20 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
309
312
  const execution = resolveExecutionCwd(cwd);
310
313
  if (execution.warning)
311
314
  onData(Buffer.from(execution.warning));
312
- const shellPath = resolveAdmittedHostCommand('bash', env ?? process.env, execution.cwd);
315
+ // The preview path reaches crtr through the environment, never through
316
+ // the command text: cmd.sh, job inspection, and the doc substrate's
317
+ // `command` surfaces all read that text as the agent's own words.
318
+ let childEnv = env ?? process.env;
319
+ if (previewPath !== undefined) {
320
+ mkdirSync(dirname(previewPath), { recursive: true });
321
+ childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
322
+ }
323
+ const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
313
324
  const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath], {
314
325
  cwd: execution.cwd,
315
326
  detached: true,
316
327
  stdio: 'ignore',
317
- env: env ?? process.env,
328
+ env: childEnv,
318
329
  });
319
330
  child.on('error', (err) => {
320
331
  finish(() => reject(err));
@@ -418,7 +429,9 @@ export function createValveToolDefinition(nodeId, contextDir, cwd, exposePurpose
418
429
  const schemaDefinition = exposePurpose ? withPurpose(baseDefinition) : baseDefinition;
419
430
  const execute = (toolCallId, params, signal, onUpdate, ctx) => {
420
431
  const purpose = exposePurpose ? normalizeBashJobPurpose(params['purpose']) : null;
421
- const definition = createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir, () => purpose) });
432
+ const definition = createBashToolDefinition(cwd, {
433
+ operations: createValveOperations(nodeId, contextDir, () => purpose, previewResultPath(toolCallId)),
434
+ });
422
435
  return definition.execute(toolCallId, params, signal, onUpdate, ctx);
423
436
  };
424
437
  return { ...schemaDefinition, execute };
@@ -1,17 +1,9 @@
1
- interface ToolCallEvent {
2
- toolName: string;
3
- toolCallId: string;
4
- input: {
5
- command?: string;
6
- };
7
- }
8
1
  interface ToolResultEvent {
9
2
  toolName: string;
10
3
  toolCallId: string;
11
4
  details?: Record<string, unknown>;
12
5
  }
13
6
  interface PiLike {
14
- on(event: 'tool_call', handler: (event: ToolCallEvent) => void): void;
15
7
  on(event: 'tool_result', handler: (event: ToolResultEvent) => {
16
8
  details: Record<string, unknown>;
17
9
  } | void): void;
@@ -1,13 +1,13 @@
1
1
  // canvas-preview-result.ts — private structured result transport for crtr bash calls.
2
2
  //
3
3
  // A crtr leaf returns a record, but the CLI normally renders that record to
4
- // stdout for the agent. stdout is model context, so this hook assigns every
5
- // bash call a private file, lets crtr atomically mirror its record there, then
6
- // attaches the record to Pi's ToolResult.details. The attach viewer can render
7
- // a structured preview without parsing the agent-facing text.
8
- import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
4
+ // stdout for the agent. stdout is model context, so the bash valve hands every
5
+ // bash call a private file path in the child environment, crtr atomically
6
+ // mirrors its record there, and this hook attaches the record to Pi's
7
+ // ToolResult.details. The attach viewer can render a structured preview
8
+ // without parsing the agent-facing text.
9
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
10
+ import { previewResultPath } from '../core/preview-result-path.js';
11
11
  function isPreviewRecord(value) {
12
12
  if (value === null || typeof value !== 'object' || typeof value['path'] !== 'string')
13
13
  return false;
@@ -21,24 +21,10 @@ function isPreviewRecord(value) {
21
21
  export function registerCanvasPreviewResult(pi) {
22
22
  if (process.env['CRTR_NODE_ID'] === undefined || process.env['CRTR_NODE_ID'] === '')
23
23
  return;
24
- const directory = join(tmpdir(), 'crouter-preview-results');
25
- const paths = new Map();
26
- pi.on('tool_call', (event) => {
27
- if (event.toolName !== 'bash' || typeof event.input.command !== 'string')
28
- return;
29
- mkdirSync(directory, { recursive: true });
30
- const path = join(directory, `${encodeURIComponent(event.toolCallId)}.json`);
31
- paths.set(event.toolCallId, path);
32
- // Export before the complete script rather than prefixing its first simple
33
- // command: agent bash calls routinely start with `cd … &&`, a compound
34
- // statement, or setup commands before crtr itself.
35
- event.input.command = `export CRTR_PREVIEW_RESULT_PATH=${JSON.stringify(path)}\n${event.input.command}`;
36
- });
37
24
  pi.on('tool_result', (event) => {
38
- const path = paths.get(event.toolCallId);
39
- if (path === undefined)
25
+ if (event.toolName !== 'bash')
40
26
  return;
41
- paths.delete(event.toolCallId);
27
+ const path = previewResultPath(event.toolCallId);
42
28
  if (!existsSync(path))
43
29
  return;
44
30
  try {
package/dist/types.d.ts CHANGED
@@ -27,6 +27,33 @@ export interface PluginBundle {
27
27
  endpoint: string;
28
28
  authEnv?: string;
29
29
  }
30
+ export type MemoryExtensionScalar = string | boolean | number;
31
+ export type MemoryExtensionFieldType = 'boolean' | 'string' | 'number' | 'enum';
32
+ interface MemoryExtensionDeclarationBase {
33
+ write_help: string;
34
+ edit_help: string;
35
+ }
36
+ export interface BooleanMemoryExtensionDeclaration extends MemoryExtensionDeclarationBase {
37
+ type: 'boolean';
38
+ default?: boolean;
39
+ }
40
+ export interface StringMemoryExtensionDeclaration extends MemoryExtensionDeclarationBase {
41
+ type: 'string';
42
+ default?: string;
43
+ }
44
+ export interface NumberMemoryExtensionDeclaration extends MemoryExtensionDeclarationBase {
45
+ type: 'number';
46
+ default?: number;
47
+ }
48
+ export interface EnumMemoryExtensionDeclaration extends MemoryExtensionDeclarationBase {
49
+ type: 'enum';
50
+ values: string[];
51
+ default?: string;
52
+ }
53
+ export type MemoryExtensionDeclaration = BooleanMemoryExtensionDeclaration | StringMemoryExtensionDeclaration | NumberMemoryExtensionDeclaration | EnumMemoryExtensionDeclaration;
54
+ /** Field declarations owned by one plugin manifest. The plugin's `name` is
55
+ * the namespace; keys here are its lowercase kebab-case local field names. */
56
+ export type MemoryExtensionDeclarations = Record<string, MemoryExtensionDeclaration>;
30
57
  export interface PluginManifest {
31
58
  name: string;
32
59
  version?: string;
@@ -47,6 +74,9 @@ export interface PluginManifest {
47
74
  * Archive plugins declare this in `bundle.json`; the installer copies the
48
75
  * validated block into this synthesized manifest. */
49
76
  kinds?: Record<string, Partial<KindConfig>>;
77
+ /** Plugin-owned typed metadata declarations for memory document frontmatter.
78
+ * Values live at `extensions.<this manifest name>.<field>`. */
79
+ memory_extensions?: MemoryExtensionDeclarations;
50
80
  /** Page-component contributions: the same registration objects a scope
51
81
  * `config.json` `page_components` block carries (kind plus its display,
52
82
  * description, use guidance, and docs metadata). `resolvePageComponents`
@@ -446,3 +476,4 @@ export declare function defaultRemoteCanvasConfig(): RemoteCanvasConfig;
446
476
  export declare function defaultKindsConfig(): Record<string, KindConfig>;
447
477
  export declare function defaultModelLaddersConfig(): ModelLaddersConfig;
448
478
  export declare function defaultScopeState(): ScopeState;
479
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.217",
3
+ "version": "0.3.218",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.217",
3
+ "version": "0.3.218",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.217",
9
+ "version": "0.3.218",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {