@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
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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, '&')
|
|
48
|
-
.replace(/"/g, '"')
|
|
49
|
-
.replace(/</g, '<')
|
|
50
|
-
.replace(/>/g, '>');
|
|
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
|
-
}
|