@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.
Files changed (63) hide show
  1. package/dist/api/dto/nodes.d.ts +7 -1
  2. package/dist/api/dto/profiles.d.ts +3 -0
  3. package/dist/clients/attach/render/crtr-output.d.ts +3 -3
  4. package/dist/clients/attach/render/crtr-output.js +25 -13
  5. package/dist/clients/attach/viewer.js +531 -561
  6. package/dist/commands/node/create.js +7 -3
  7. package/dist/commands/profile/list.js +3 -1
  8. package/dist/commands/profile/new.js +4 -1
  9. package/dist/commands/profile/project.js +23 -9
  10. package/dist/commands/profile/show.js +3 -1
  11. package/dist/commands/profile.js +2 -2
  12. package/dist/commands/sys/logs.js +4 -2
  13. package/dist/core/__tests__/branded-host-concurrency.test.js +1 -1
  14. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +1 -1
  15. package/dist/core/__tests__/on-read-dedup-resume.test.js +1 -1
  16. package/dist/core/canvas/extensions.d.ts +1 -1
  17. package/dist/core/canvas/extensions.js +7 -1
  18. package/dist/core/command-plugins/transport/http-invoke.js +10 -2
  19. package/dist/core/command.js +3 -1
  20. package/dist/core/config.js +4 -27
  21. package/dist/core/exclusive-lock.d.ts +14 -0
  22. package/dist/core/exclusive-lock.js +124 -0
  23. package/dist/core/human/claim.js +2 -1
  24. package/dist/core/human/convention.d.ts +0 -10
  25. package/dist/core/human/convention.js +1 -123
  26. package/dist/core/io.d.ts +23 -0
  27. package/dist/core/io.js +60 -0
  28. package/dist/core/preview-registry.d.ts +10 -5
  29. package/dist/core/preview-registry.js +136 -2537
  30. package/dist/core/profiles/manifest.d.ts +12 -1
  31. package/dist/core/profiles/manifest.js +57 -48
  32. package/dist/core/review/store.d.ts +4 -1
  33. package/dist/core/review/store.js +10 -0
  34. package/dist/core/runtime/branded-host.js +6 -84
  35. package/dist/core/runtime/broker-extension-render.js +1 -1
  36. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  37. package/dist/core/runtime/canvas-extensions.js +2 -0
  38. package/dist/core/runtime/tmux-bindings.js +1 -1
  39. package/dist/core/runtime/warm-pool.d.ts +9 -8
  40. package/dist/core/runtime/warm-pool.js +58 -62
  41. package/dist/core/substrate/index.d.ts +2 -1
  42. package/dist/core/substrate/index.js +2 -1
  43. package/dist/core/substrate/on-read-node.d.ts +7 -0
  44. package/dist/core/substrate/on-read-node.js +37 -0
  45. package/dist/core/substrate/on-read.d.ts +7 -5
  46. package/dist/core/substrate/on-read.js +15 -23
  47. package/dist/core/termrender/termrender.js +16 -96
  48. package/dist/core/worktree.js +11 -100
  49. package/dist/daemon/api/handlers/nodes.js +32 -9
  50. package/dist/daemon/api/handlers/review-comments.js +5 -44
  51. package/dist/daemon/api/handlers/reviews.js +5 -53
  52. package/dist/daemon/api/handlers/validate.d.ts +22 -0
  53. package/dist/daemon/api/handlers/validate.js +59 -0
  54. package/dist/daemon/api/map.js +1 -0
  55. package/dist/daemon/review/finish.js +1 -10
  56. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  57. package/dist/pi-extensions/canvas-preview-result.d.ts +22 -0
  58. package/dist/pi-extensions/canvas-preview-result.js +60 -0
  59. package/dist/types.d.ts +4 -0
  60. package/package.json +1 -1
  61. package/runtime.lock.json +2 -2
  62. package/dist/core/substrate/on-read-pure.d.ts +0 -7
  63. package/dist/core/substrate/on-read-pure.js +0 -272
@@ -7,70 +7,22 @@ import { isSafeNodeId, reviewResultPath } from '../../../core/canvas/paths.js';
7
7
  import { responsePath, reviewPath } from '../../../core/human/convention.js';
8
8
  import { ticketDir } from '../../../core/human/root.js';
9
9
  import { createReview } from '../../../core/review/birth.js';
10
- import { readAnchorBase, getReview, getReviewByBridge, listReviews } from '../../../core/review/store.js';
10
+ import { readAnchorBase, getReviewByBridge, listReviews, requireVisibleReview } from '../../../core/review/store.js';
11
11
  import { ReviewOperationError } from '../../../core/review/types.js';
12
12
  import { cancelReview, submitReviewApproval } from '../../review/finish.js';
13
13
  import { toFeedbackResultDTO, toReviewDTO } from '../map.js';
14
+ import { invalidRequest as rejectRequest, objectBody, optionalString, requireParam, requiredString, } from './validate.js';
14
15
  const REVIEW_STATES = new Set(['open', 'approved', 'canceled']);
15
16
  const CREATE_FIELDS = ['origin_kind', 'origin_node_id', 'file', 'idempotency_key', 'subtitle', 'output_path'];
16
17
  const CANCEL_FIELDS = ['reason', 'actor'];
17
18
  function invalidRequest(violations) {
18
- throw new ApiError(400, 'invalid_request', 'review request is invalid', { violations });
19
- }
20
- function isRecord(value) {
21
- return typeof value === 'object' && value !== null && !Array.isArray(value);
22
- }
23
- function objectBody(body, allowed, violations) {
24
- if (!isRecord(body)) {
25
- violations.push({ field: 'body', problem: 'must be an object', received: body });
26
- return {};
27
- }
28
- for (const key of Object.keys(body)) {
29
- if (!allowed.includes(key))
30
- violations.push({ field: key, problem: 'is not allowed', received: body[key] });
31
- }
32
- return body;
33
- }
34
- function requiredString(body, field, violations) {
35
- const value = body[field];
36
- if (typeof value !== 'string' || value === '') {
37
- violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
38
- return undefined;
39
- }
40
- return value;
41
- }
42
- function optionalString(body, field, violations) {
43
- const value = body[field];
44
- if (value === undefined)
45
- return undefined;
46
- if (typeof value !== 'string' || value === '') {
47
- violations.push({ field, problem: 'must be a non-empty string', received: value });
48
- return undefined;
49
- }
50
- return value;
19
+ rejectRequest('review request', violations);
51
20
  }
52
21
  function requireReviewId(ctx, violations) {
53
- const reviewId = ctx.params['review_id'];
54
- if (reviewId === undefined || !isSafeNodeId(reviewId)) {
55
- violations.push({ field: 'review_id', problem: 'must be a safe review id', ...(reviewId === undefined ? {} : { received: reviewId }) });
56
- return undefined;
57
- }
58
- return reviewId;
22
+ return requireParam(ctx, 'review_id', 'must be a safe review id', isSafeNodeId, violations);
59
23
  }
60
24
  function requireBridgeNodeId(ctx, violations) {
61
- const bridgeNodeId = ctx.params['bridge_node_id'];
62
- if (bridgeNodeId === undefined || !isSafeNodeId(bridgeNodeId)) {
63
- violations.push({ field: 'bridge_node_id', problem: 'must be a safe node id', ...(bridgeNodeId === undefined ? {} : { received: bridgeNodeId }) });
64
- return undefined;
65
- }
66
- return bridgeNodeId;
67
- }
68
- function requireVisibleReview(reviewId) {
69
- const review = getReview(reviewId);
70
- if (review === null || review.state === 'binding') {
71
- throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
72
- }
73
- return review;
25
+ return requireParam(ctx, 'bridge_node_id', 'must be a safe node id', isSafeNodeId, violations);
74
26
  }
75
27
  function approvedResultPath(review) {
76
28
  if (review.origin_kind === 'inline')
@@ -0,0 +1,22 @@
1
+ import type { RequestContext } from '../router.js';
2
+ export type Violation = {
3
+ field: string;
4
+ problem: string;
5
+ received?: unknown;
6
+ };
7
+ /** Reject the request with every violation collected so far. `subject` names
8
+ * what was being parsed, e.g. "review comment request". */
9
+ export declare function invalidRequest(subject: string, violations: Violation[]): never;
10
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
11
+ /** Narrow a request body to an object and reject any field outside `allowed`.
12
+ * A non-object body yields `{}` so the caller's field reads all miss and
13
+ * report their own violations rather than throwing. */
14
+ export declare function objectBody(body: unknown, allowed: readonly string[], violations: Violation[]): Record<string, unknown>;
15
+ /** A required field that must be a string carrying at least one non-whitespace
16
+ * character — whitespace alone is not a value. */
17
+ export declare function requiredString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
18
+ /** `requiredString`'s rule when present; absence is allowed. */
19
+ export declare function optionalString(body: Record<string, unknown>, field: string, violations: Violation[]): string | undefined;
20
+ /** A path parameter that must be present and satisfy `accepts`. `problem`
21
+ * describes the shape the caller failed to supply. */
22
+ export declare function requireParam(ctx: RequestContext, field: string, problem: string, accepts: (value: string) => boolean, violations: Violation[]): string | undefined;
@@ -0,0 +1,59 @@
1
+ // Shared wire-request validation for the daemon's `/v1` handlers.
2
+ //
3
+ // A handler collects `Violation`s while parsing a request, then calls
4
+ // `invalidRequest` once so a caller sees every problem with its body in a
5
+ // single 400 rather than one per round trip.
6
+ import { ApiError } from '../../../api/index.js';
7
+ /** Reject the request with every violation collected so far. `subject` names
8
+ * what was being parsed, e.g. "review comment request". */
9
+ export function invalidRequest(subject, violations) {
10
+ throw new ApiError(400, 'invalid_request', `${subject} is invalid`, { violations });
11
+ }
12
+ export function isRecord(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+ /** Narrow a request body to an object and reject any field outside `allowed`.
16
+ * A non-object body yields `{}` so the caller's field reads all miss and
17
+ * report their own violations rather than throwing. */
18
+ export function objectBody(body, allowed, violations) {
19
+ if (!isRecord(body)) {
20
+ violations.push({ field: 'body', problem: 'must be an object', received: body });
21
+ return {};
22
+ }
23
+ for (const key of Object.keys(body)) {
24
+ if (!allowed.includes(key))
25
+ violations.push({ field: key, problem: 'is not allowed', received: body[key] });
26
+ }
27
+ return body;
28
+ }
29
+ /** A required field that must be a string carrying at least one non-whitespace
30
+ * character — whitespace alone is not a value. */
31
+ export function requiredString(body, field, violations) {
32
+ const value = body[field];
33
+ if (typeof value !== 'string' || value.trim() === '') {
34
+ violations.push({ field, problem: 'must be a non-empty string', ...(value === undefined ? {} : { received: value }) });
35
+ return undefined;
36
+ }
37
+ return value;
38
+ }
39
+ /** `requiredString`'s rule when present; absence is allowed. */
40
+ export function optionalString(body, field, violations) {
41
+ const value = body[field];
42
+ if (value === undefined)
43
+ return undefined;
44
+ if (typeof value !== 'string' || value.trim() === '') {
45
+ violations.push({ field, problem: 'must be a non-empty string', received: value });
46
+ return undefined;
47
+ }
48
+ return value;
49
+ }
50
+ /** A path parameter that must be present and satisfy `accepts`. `problem`
51
+ * describes the shape the caller failed to supply. */
52
+ export function requireParam(ctx, field, problem, accepts, violations) {
53
+ const value = ctx.params[field];
54
+ if (value === undefined || !accepts(value)) {
55
+ violations.push({ field, problem, ...(value === undefined ? {} : { received: value }) });
56
+ return undefined;
57
+ }
58
+ return value;
59
+ }
@@ -259,6 +259,7 @@ export function toProfileDTO(entry) {
259
259
  id: entry.profileId,
260
260
  name: entry.manifest.name,
261
261
  projects: [...entry.manifest.projects],
262
+ home: entry.manifest.home,
262
263
  default_dir: null,
263
264
  };
264
265
  }
@@ -8,7 +8,7 @@ import { reviewResultPath } from '../../core/canvas/paths.js';
8
8
  import { snapshotCommentsLocked } from '../../core/review/comments.js';
9
9
  import { readReviewSource } from '../../core/review/document.js';
10
10
  import { signalReviewActivity } from '../../core/review/signal.js';
11
- import { approveReviewLocked, cancelReviewRow, getReview, markDelivered, markDeliveryError, markProjected, markProjectionError, } from '../../core/review/store.js';
11
+ import { approveReviewLocked, cancelReviewRow, markDelivered, markDeliveryError, markProjected, markProjectionError, requireVisibleReview, } from '../../core/review/store.js';
12
12
  import { ReviewOperationError } from '../../core/review/types.js';
13
13
  import { transition } from '../../core/runtime/lifecycle.js';
14
14
  import { headlessBrokerHost } from '../../core/runtime/host.js';
@@ -22,15 +22,6 @@ function now() {
22
22
  function errorText(error) {
23
23
  return error instanceof Error ? error.message : String(error);
24
24
  }
25
- function unknownReview(reviewId) {
26
- throw new ReviewOperationError('unknown_review', 'review was not found', { received: reviewId });
27
- }
28
- function requireVisibleReview(reviewId) {
29
- const review = getReview(reviewId);
30
- if (review === null || review.state === 'binding')
31
- return unknownReview(reviewId);
32
- return review;
33
- }
34
25
  function terminalConflict(review, requested) {
35
26
  throw new ReviewOperationError('review_terminal', 'review already has a terminal outcome', {
36
27
  state: review.state,
@@ -34,7 +34,7 @@ import { homedir } from 'node:os';
34
34
  import { join, resolve } from 'node:path';
35
35
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
36
36
  import { renderPreferencesFromState } from '../core/runtime/broker-extension-render.js';
37
- import { renderOnReadDocsForSubject } from '../core/substrate/on-read-pure.js';
37
+ import { renderOnReadDocsForSubject } from '../core/substrate/on-read.js';
38
38
  import { clearSessionCache } from '../core/substrate/session-cache.js';
39
39
  import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
40
40
  import { autoLoadedContextInner, mergeAutoLoadedContext } from './envelope-merge.js';
@@ -0,0 +1,22 @@
1
+ interface ToolCallEvent {
2
+ toolName: string;
3
+ toolCallId: string;
4
+ input: {
5
+ command?: string;
6
+ };
7
+ }
8
+ interface ToolResultEvent {
9
+ toolName: string;
10
+ toolCallId: string;
11
+ details?: Record<string, unknown>;
12
+ }
13
+ interface PiLike {
14
+ on(event: 'tool_call', handler: (event: ToolCallEvent) => void): void;
15
+ on(event: 'tool_result', handler: (event: ToolResultEvent) => {
16
+ details: Record<string, unknown>;
17
+ } | void): void;
18
+ }
19
+ /** Loaded into every canvas broker. Plain Pi sessions have no node id and
20
+ * deliberately receive no filesystem transport. */
21
+ export declare function registerCanvasPreviewResult(pi: PiLike): void;
22
+ export default registerCanvasPreviewResult;
@@ -0,0 +1,60 @@
1
+ // canvas-preview-result.ts — private structured result transport for crtr bash calls.
2
+ //
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';
11
+ function isPreviewRecord(value) {
12
+ if (value === null || typeof value !== 'object' || typeof value['path'] !== 'string')
13
+ return false;
14
+ const record = value;
15
+ return (record.result !== undefined && record.result !== null && typeof record.result === 'object' && !Array.isArray(record.result))
16
+ || (Array.isArray(record.jsonl) && record.jsonl.every((item) => item !== null && typeof item === 'object' && !Array.isArray(item)))
17
+ || (record.error !== undefined && record.error !== null && typeof record.error === 'object' && !Array.isArray(record.error));
18
+ }
19
+ /** Loaded into every canvas broker. Plain Pi sessions have no node id and
20
+ * deliberately receive no filesystem transport. */
21
+ export function registerCanvasPreviewResult(pi) {
22
+ if (process.env['CRTR_NODE_ID'] === undefined || process.env['CRTR_NODE_ID'] === '')
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
+ pi.on('tool_result', (event) => {
38
+ const path = paths.get(event.toolCallId);
39
+ if (path === undefined)
40
+ return;
41
+ paths.delete(event.toolCallId);
42
+ if (!existsSync(path))
43
+ return;
44
+ try {
45
+ const record = JSON.parse(readFileSync(path, 'utf8'));
46
+ if (!isPreviewRecord(record))
47
+ return;
48
+ return { details: { ...(event.details ?? {}), crtrPreview: record } };
49
+ }
50
+ catch {
51
+ // A malformed side-channel record is not agent-visible output and must
52
+ // not change the bash call's actual result.
53
+ return;
54
+ }
55
+ finally {
56
+ rmSync(path, { force: true });
57
+ }
58
+ });
59
+ }
60
+ export default registerCanvasPreviewResult;
package/dist/types.d.ts CHANGED
@@ -323,6 +323,10 @@ export interface ProfileManifest {
323
323
  schema_version: number;
324
324
  name: string;
325
325
  projects: string[];
326
+ /** The directory nodes under this profile are pinned to (see
327
+ * `profileHome`). Normalized on read to the first project when the manifest
328
+ * carries none, so a profile written before homes existed still has one. */
329
+ home: string | null;
326
330
  created_at: string;
327
331
  last_used_at: string | null;
328
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.175",
3
+ "version": "0.3.176",
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.175",
3
+ "version": "0.3.176",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.175",
9
+ "version": "0.3.176",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,7 +0,0 @@
1
- import type { NodeConfigSubject } from './subject-fields.js';
2
- /** Surface docs matched by a successful read tool call using the caller-supplied
3
- * daemon snapshot subject. */
4
- export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, seen?: Set<string>): string;
5
- /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
6
- * lookup. */
7
- export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null): string;
@@ -1,272 +0,0 @@
1
- // on-read.ts — file-context delivery for the document substrate.
2
- //
3
- // A memory with file-read visibility participates only through an explicit
4
- // `applies-to` path glob (authoring lint requires one). Two events use that one
5
- // routing mechanism:
6
- //
7
- // • A successful `read` tool call evaluates the read file against every
8
- // visible substrate doc. Project stores encountered between that file and
9
- // the filesystem root join the resolved cwd/profile corpus, so a nested
10
- // workspace can contribute guidance exactly when a file beneath it is read.
11
- // • Opening a workspace evaluates the reserved `applies-to: "."` target for
12
- // every project store mounted by the cwd and selected profile. This happens
13
- // while the first-message bearings are assembled, so workspace-wide context
14
- // arrives before the task rather than waiting for an arbitrary first file.
15
- //
16
- // `read-when` remains an additional OR trigger over a read markdown file's own
17
- // frontmatter. It does not replace `applies-to`: every non-none file-read rung
18
- // still declares its path boundary explicitly.
19
- //
20
- // Every candidate renders at its own fileReadVisibility rung. Explicit routing
21
- // is the whole on-read model, so directory INDEX ceilings remain a boot-catalog
22
- // concern and do not cap a deliberately matched document.
23
- import { realpathSync } from 'node:fs';
24
- import { homedir } from 'node:os';
25
- import { basename, dirname, matchesGlob, parse, relative, sep } from 'node:path';
26
- import { CRTR_DIR_NAME } from '../../types.js';
27
- import { pathExists, readText, walkFiles } from '../fs-utils.js';
28
- import { parseFrontmatterGeneric } from '../frontmatter.js';
29
- import { evalCondition } from '../predicate.js';
30
- import { listAllMemoryDocs, listProjectMemoryDocs } from '../memory-resolver.js';
31
- import { userScopeRoot } from '../scope.js';
32
- import { displayName } from './ceiling.js';
33
- import { gatePasses } from './gate.js';
34
- import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName } from './schema.js';
35
- import { cachedSubstrateDocs } from './session-cache.js';
36
- const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
37
- function realpathOrSelf(p) {
38
- try {
39
- return realpathSync(p);
40
- }
41
- catch {
42
- return p;
43
- }
44
- }
45
- function attr(s) {
46
- return s
47
- .replace(/&/g, '&amp;')
48
- .replace(/"/g, '&quot;')
49
- .replace(/</g, '&lt;')
50
- .replace(/>/g, '&gt;');
51
- }
52
- function gitRootOf(p) {
53
- let d = p;
54
- const root = parse(d).root;
55
- while (true) {
56
- if (pathExists(`${d}${sep}.git`))
57
- return d;
58
- if (d === root)
59
- return null;
60
- const parent = dirname(d);
61
- if (parent === d)
62
- return null;
63
- d = parent;
64
- }
65
- }
66
- function disp(p) {
67
- const root = gitRootOf(p);
68
- if (!root)
69
- return p;
70
- const rel = relative(root, p);
71
- return rel === '' ? '.' : rel;
72
- }
73
- function isJunkAncestor(dir) {
74
- return dir.split(sep).some((segment) => JUNK_DIRS.has(segment));
75
- }
76
- function owningRootOf(doc) {
77
- const parts = doc.path.split(sep);
78
- const idx = parts.lastIndexOf(CRTR_DIR_NAME);
79
- if (idx <= 0)
80
- return null;
81
- return parts.slice(0, idx).join(sep) || sep;
82
- }
83
- function loadProjectDoc(file, memoryDir) {
84
- try {
85
- const raw = relative(memoryDir, file)
86
- .replace(/\.md$/i, '')
87
- .split(sep)
88
- .join('/');
89
- const fallbackName = normalizeDocName(raw);
90
- if (fallbackName === '')
91
- return null;
92
- const { data, body } = parseFrontmatterGeneric(readText(file));
93
- const schema = parseSubstrateFrontmatter(data);
94
- if (schema === null)
95
- return null;
96
- return {
97
- ...schema,
98
- name: resolveDocName(data, fallbackName),
99
- scope: 'project',
100
- path: file,
101
- body,
102
- };
103
- }
104
- catch {
105
- return null;
106
- }
107
- }
108
- function safeWalkMarkdown(dir) {
109
- try {
110
- return walkFiles(dir, (name) => name.toLowerCase().endsWith('.md'));
111
- }
112
- catch {
113
- return [];
114
- }
115
- }
116
- /** Project docs from every workspace store enclosing the read file. This is a
117
- * discovery walk only: each returned doc must still match an explicit trigger. */
118
- function enclosingProjectDocs(absReadFile) {
119
- const docs = [];
120
- const seen = new Set();
121
- const home = realpathOrSelf(homedir());
122
- const userRoot = realpathOrSelf(userScopeRoot());
123
- const fsRoot = parse(absReadFile).root;
124
- let dir = dirname(absReadFile);
125
- while (true) {
126
- if (dir !== home && dir !== userRoot && !isJunkAncestor(dir)) {
127
- const memoryDir = `${dir}${sep}${CRTR_DIR_NAME}${sep}memory`;
128
- if (pathExists(memoryDir)) {
129
- for (const file of safeWalkMarkdown(memoryDir)) {
130
- const real = realpathOrSelf(file);
131
- if (seen.has(real))
132
- continue;
133
- seen.add(real);
134
- const doc = loadProjectDoc(file, memoryDir);
135
- if (doc !== null)
136
- docs.push(doc);
137
- }
138
- }
139
- }
140
- if (dir === home || dir === fsRoot)
141
- break;
142
- const parent = dirname(dir);
143
- if (parent === dir)
144
- break;
145
- dir = parent;
146
- }
147
- return docs;
148
- }
149
- function resolvedDocs() {
150
- try {
151
- return cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
152
- }
153
- catch {
154
- return [];
155
- }
156
- }
157
- function dedupeByPhysicalPath(docs) {
158
- const seen = new Set();
159
- const out = [];
160
- for (const doc of docs) {
161
- const real = realpathOrSelf(doc.path);
162
- if (seen.has(real))
163
- continue;
164
- seen.add(real);
165
- out.push(doc);
166
- }
167
- return out;
168
- }
169
- function globMatches(glob, absReadFile, owningRoot) {
170
- const targets = [absReadFile, basename(absReadFile)];
171
- if (owningRoot !== null)
172
- targets.push(relative(owningRoot, absReadFile));
173
- return targets.some((target) => {
174
- try {
175
- return matchesGlob(target, glob);
176
- }
177
- catch {
178
- return false;
179
- }
180
- });
181
- }
182
- function readFileFrontmatter(absReadFile) {
183
- if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
184
- return {};
185
- try {
186
- return parseFrontmatterGeneric(readText(absReadFile)).data ?? {};
187
- }
188
- catch {
189
- return {};
190
- }
191
- }
192
- function matchesReadEvent(doc, absReadFile, readFrontmatter) {
193
- const pathMatch = doc.appliesTo?.some((glob) => globMatches(glob, absReadFile, owningRootOf(doc))) === true;
194
- const frontmatterMatch = doc.readWhen !== undefined &&
195
- Object.keys(readFrontmatter).length > 0 &&
196
- evalCondition(doc.readWhen, readFrontmatter);
197
- return pathMatch || frontmatterMatch;
198
- }
199
- function envelopeName(doc) {
200
- const displayed = displayName(doc.name);
201
- if (displayed !== '')
202
- return displayed;
203
- const root = owningRootOf(doc);
204
- return root === null ? doc.name : basename(root);
205
- }
206
- function renderDocEnvelope(doc) {
207
- const rung = doc.fileReadVisibility;
208
- if (rung === 'none')
209
- return null;
210
- let body = '';
211
- if (rung === 'content')
212
- body = doc.body.trim();
213
- else if (rung === 'preview')
214
- body = previewLine(doc);
215
- const attrs = `kind="${attr(doc.kind)}" name="${attr(envelopeName(doc))}" src="${attr(disp(doc.path))}"`;
216
- return body === '' ? `<memory ${attrs} />` : `<memory ${attrs}>\n${body}\n</memory>`;
217
- }
218
- function renderCandidates(subject, docs, seen) {
219
- const rendered = [];
220
- for (const doc of docs) {
221
- const real = realpathOrSelf(doc.path);
222
- if (seen.has(real))
223
- continue;
224
- try {
225
- if (!gatePasses(doc, subject))
226
- continue;
227
- const block = renderDocEnvelope(doc);
228
- if (block === null)
229
- continue;
230
- seen.add(real);
231
- rendered.push(block);
232
- }
233
- catch {
234
- continue;
235
- }
236
- }
237
- return rendered.length === 0
238
- ? ''
239
- : `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
240
- }
241
- /** Surface docs matched by a successful read tool call using the caller-supplied
242
- * daemon snapshot subject. */
243
- export function renderOnReadDocsForSubject(subject, readFilePath, seen = new Set()) {
244
- const absReadFile = realpathOrSelf(readFilePath);
245
- const readFrontmatter = readFileFrontmatter(absReadFile);
246
- const docs = dedupeByPhysicalPath([...enclosingProjectDocs(absReadFile), ...resolvedDocs()])
247
- .filter((doc) => realpathOrSelf(doc.path) !== absReadFile)
248
- .filter((doc) => matchesReadEvent(doc, absReadFile, readFrontmatter));
249
- return renderCandidates(subject, docs, seen);
250
- }
251
- /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
252
- * lookup. */
253
- export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
254
- let docs;
255
- try {
256
- docs = listProjectMemoryDocs(cwd, profileId)
257
- .map(parseSubstrateDoc)
258
- .filter((doc) => doc !== null);
259
- }
260
- catch {
261
- return '';
262
- }
263
- docs = dedupeByPhysicalPath(docs)
264
- .filter((doc) => doc.appliesTo?.some((glob) => glob.trim() === '.') === true)
265
- .sort((a, b) => {
266
- const aRoot = owningRootOf(a) ?? '';
267
- const bRoot = owningRootOf(b) ?? '';
268
- const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
269
- return depth || aRoot.localeCompare(bRoot) || a.path.localeCompare(b.path);
270
- });
271
- return renderCandidates(subject, docs, new Set());
272
- }