@crouton-kit/crouter 0.3.47 → 0.3.48
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/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +5 -3
- package/dist/clients/attach/attach-cmd.js +442 -442
- package/dist/commands/sys/__tests__/sync-import.test.js +89 -1
- package/dist/commands/sys/sync.js +242 -12
- package/dist/core/__tests__/broker-sdk-wiring.test.js +7 -5
- package/dist/core/__tests__/context-intro.test.js +12 -19
- package/dist/core/runtime/bearings.d.ts +15 -7
- package/dist/core/runtime/bearings.js +26 -85
- package/dist/core/runtime/broker.js +5 -4
- package/dist/core/substrate/on-read.js +9 -6
- package/package.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
// the source's whole conversation and then impersonates it. A non-fork
|
|
14
14
|
// node's bearings are its FIRST entry, so there is nothing earlier to
|
|
15
15
|
// disown; it gets only the declarative identity line. The message also
|
|
16
|
-
// carries the <project_context> block (buildProjectContextBlock) —
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// carries the <project_context> block (buildProjectContextBlock) — now just
|
|
17
|
+
// the cwd/git <environment> snapshot. Project instructions (what used to be
|
|
18
|
+
// a re-rendered AGENTS.md/CLAUDE.md here) come from the document substrate
|
|
19
|
+
// instead: `crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that
|
|
20
|
+
// dir's own `.crouter/memory/AGENTS.md` at content/content, so it rides the
|
|
21
|
+
// <memory kind="knowledge"> block below (renderKnowledgeBlock) via the
|
|
22
|
+
// profile-widened boot render, exactly like any other substrate doc — no
|
|
23
|
+
// parallel re-implementation of pi's project-context loader lives here
|
|
24
|
+
// anymore (see `crtr memory read taste/document-substrate`);
|
|
20
25
|
// • promote.ts folds orchestratorContextNote() into the promotion guidance
|
|
21
26
|
// dump, so a node that becomes an orchestrator MID-LIFE gets the
|
|
22
27
|
// orchestrator framing it never received at spawn — it spawned as a base
|
|
@@ -36,78 +41,10 @@
|
|
|
36
41
|
// This across-cycles note is the ONE thing a terminal worker's bearings drop.
|
|
37
42
|
import { contextDir, getNode, fullName, subscriptionsOf, } from '../canvas/index.js';
|
|
38
43
|
import { execSync } from 'node:child_process';
|
|
39
|
-
import {
|
|
40
|
-
import { homedir } from 'node:os';
|
|
41
|
-
import { join, resolve } from 'node:path';
|
|
44
|
+
import { readdirSync } from 'node:fs';
|
|
42
45
|
import { cadenceDisplay } from '../wake.js';
|
|
43
46
|
import { renderKnowledgeBlock } from '../substrate/index.js';
|
|
44
47
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
// Project context (<project_context>) — the AGENTS.md/CLAUDE.md files pi would
|
|
47
|
-
// otherwise inject into its SYSTEM PROMPT, rendered here so they ride the
|
|
48
|
-
// first-message bearings instead (pi's copy is suppressed via
|
|
49
|
-
// resourceLoaderOptions.noContextFiles in broker.ts). We re-implement pi's
|
|
50
|
-
// loadProjectContextFiles + getAgentDir (resource-loader.js / config.js) rather
|
|
51
|
-
// than IMPORT pi: bearings.ts is statically imported by the thin daemon (crtrd,
|
|
52
|
-
// for wakeOriginFrom), so a static @earendil-works import would pull the whole
|
|
53
|
-
// pi engine into the supervisor. The discovery is a stable convention + pure fs.
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
/** pi's getAgentDir(): the global agent config dir holding a user-wide context
|
|
56
|
-
* file. Honors PI_CODING_AGENT_DIR (tilde-expanded), else ~/.pi/agent (pi's
|
|
57
|
-
* piConfig.configDir is ".pi"). */
|
|
58
|
-
function piAgentDir() {
|
|
59
|
-
const env = process.env['PI_CODING_AGENT_DIR'];
|
|
60
|
-
if (env !== undefined && env !== '') {
|
|
61
|
-
return env.startsWith('~') ? join(homedir(), env.slice(1)) : env;
|
|
62
|
-
}
|
|
63
|
-
return join(homedir(), '.pi', 'agent');
|
|
64
|
-
}
|
|
65
|
-
/** Per-dir context-file candidates, in pi's precedence order (first match wins). */
|
|
66
|
-
const CONTEXT_FILE_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD'];
|
|
67
|
-
function loadContextFileFromDir(dir) {
|
|
68
|
-
for (const name of CONTEXT_FILE_CANDIDATES) {
|
|
69
|
-
const p = join(dir, name);
|
|
70
|
-
if (existsSync(p)) {
|
|
71
|
-
try {
|
|
72
|
-
return { path: p, content: readFileSync(p, 'utf8') };
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
// Unreadable candidate — skip, mirroring pi (best-effort).
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
/** Mirror of pi's loadProjectContextFiles: the global agent-dir file first, then
|
|
82
|
-
* every AGENTS.md/CLAUDE.md up the cwd's ancestry, ordered root→cwd (so the
|
|
83
|
-
* most-specific project file reads last). Deduped by absolute path. */
|
|
84
|
-
function loadProjectContextFiles(cwd) {
|
|
85
|
-
const files = [];
|
|
86
|
-
const seen = new Set();
|
|
87
|
-
const global = loadContextFileFromDir(piAgentDir());
|
|
88
|
-
if (global !== null) {
|
|
89
|
-
files.push(global);
|
|
90
|
-
seen.add(global.path);
|
|
91
|
-
}
|
|
92
|
-
const ancestors = [];
|
|
93
|
-
let dir = resolve(cwd);
|
|
94
|
-
const root = resolve('/');
|
|
95
|
-
for (;;) {
|
|
96
|
-
const f = loadContextFileFromDir(dir);
|
|
97
|
-
if (f !== null && !seen.has(f.path)) {
|
|
98
|
-
ancestors.unshift(f);
|
|
99
|
-
seen.add(f.path);
|
|
100
|
-
}
|
|
101
|
-
if (dir === root)
|
|
102
|
-
break;
|
|
103
|
-
const parent = resolve(dir, '..');
|
|
104
|
-
if (parent === dir)
|
|
105
|
-
break;
|
|
106
|
-
dir = parent;
|
|
107
|
-
}
|
|
108
|
-
files.push(...ancestors);
|
|
109
|
-
return files;
|
|
110
|
-
}
|
|
111
48
|
function escapeAttribute(value) {
|
|
112
49
|
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
113
50
|
}
|
|
@@ -175,18 +112,19 @@ function parseWorktrees(lines) {
|
|
|
175
112
|
}
|
|
176
113
|
return worktrees;
|
|
177
114
|
}
|
|
178
|
-
/** The `<project_context>` block
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
115
|
+
/** The `<project_context>` block — now just the cwd/git `<environment>`
|
|
116
|
+
* snapshot (dir listing + git status/worktrees). This USED to also carry a
|
|
117
|
+
* `<project_instructions>` block re-rendering the node's AGENTS.md/CLAUDE.md;
|
|
118
|
+
* that machinery is gone (hard cut, 2026-07-06) — project guidance now lives
|
|
119
|
+
* in the document substrate (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md
|
|
120
|
+
* into that dir's own `.crouter/memory/AGENTS.md` at content/content, so it
|
|
121
|
+
* surfaces via the `<memory kind="knowledge">` block instead). Always emits
|
|
122
|
+
* the wrapper because the environment snapshot is always present. Exported
|
|
123
|
+
* for testing. */
|
|
182
124
|
export function buildProjectContextBlock(cwd) {
|
|
183
|
-
const files = loadProjectContextFiles(cwd);
|
|
184
125
|
const envLines = directoryListingLines(cwd);
|
|
185
126
|
const git = gitSnapshot(cwd);
|
|
186
|
-
let out = '<project_context>\n\
|
|
187
|
-
for (const { path, content } of files) {
|
|
188
|
-
out += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
189
|
-
}
|
|
127
|
+
let out = '<project_context>\n\n';
|
|
190
128
|
out += `<environment cwd="${escapeAttribute(cwd)}">\n`;
|
|
191
129
|
out += `Directory:\n${envLines.map((line) => ` ${line}`).join('\n')}\n`;
|
|
192
130
|
out += `${git.lines.map((line) => `${line}`).join('\n')}\n`;
|
|
@@ -432,9 +370,12 @@ export function buildWakeBearings(origin) {
|
|
|
432
370
|
* from.
|
|
433
371
|
* 2. `<memory kind="knowledge">` — the consultable catalog, a SIBLING of the
|
|
434
372
|
* bearings (never nested under the context dir). Dropped when empty.
|
|
435
|
-
* 3. `<project_context>` — the
|
|
436
|
-
*
|
|
437
|
-
*
|
|
373
|
+
* 3. `<project_context>` — the cwd/git environment snapshot. pi's own
|
|
374
|
+
* AGENTS.md/CLAUDE.md system-prompt injection stays suppressed
|
|
375
|
+
* (noContextFiles in broker.ts); project guidance instead rides the
|
|
376
|
+
* `<memory kind="knowledge">` block above via the document substrate
|
|
377
|
+
* (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that dir's own
|
|
378
|
+
* `.crouter/memory/AGENTS.md`). */
|
|
438
379
|
export function buildContextBearings(nodeId) {
|
|
439
380
|
const node = getNode(nodeId);
|
|
440
381
|
const bearings = ['<crtr-bearings>', buildIdentityAssertion(nodeId)];
|
|
@@ -1706,10 +1706,11 @@ export async function buildBrokerSession(engine, cfg) {
|
|
|
1706
1706
|
resourceLoaderOptions: {
|
|
1707
1707
|
additionalExtensionPaths: cfg.extensionPaths,
|
|
1708
1708
|
appendSystemPrompt: cfg.appendSystemPromptPath !== undefined ? [cfg.appendSystemPromptPath] : undefined,
|
|
1709
|
-
// Suppress pi's <project_context> in the
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1709
|
+
// Suppress pi's <project_context> AGENTS.md/CLAUDE.md injection in the
|
|
1710
|
+
// SYSTEM PROMPT unconditionally — crouter reads project guidance through
|
|
1711
|
+
// the document substrate instead (a migrated AGENTS.md doc rides the
|
|
1712
|
+
// <memory kind="knowledge"> block in the first-message bearings; see
|
|
1713
|
+
// bearings.ts and `crtr sys sync`), never pi's own file-based loader.
|
|
1713
1714
|
noContextFiles: true,
|
|
1714
1715
|
},
|
|
1715
1716
|
});
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
// • POSITIONAL — walk the read file's ancestor dirs; any doc living in an
|
|
10
10
|
// ancestor PROJECT `.crouter/memory/` surfaces (a doc surfaces when a file
|
|
11
11
|
// beside/under its own project scope dir is read), UNLESS it carries an
|
|
12
|
-
// explicit read-trigger (see D5 below). This
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// explicit read-trigger (see D5 below). This is the substrate's own
|
|
13
|
+
// ancestor walk keyed on `.crouter/memory/` — the same shape the retired
|
|
14
|
+
// nested-context pi-extension used for `.claude/rules` before that path
|
|
15
|
+
// was migrated into substrate docs (`crtr sys sync`). The user-global
|
|
16
|
+
// `~/.crouter/memory/` store is NOT positional; user docs only fire on
|
|
17
|
+
// reads through explicit `applies-to` / `read-when` triggers.
|
|
16
18
|
// • applies-to GLOB — any RESOLVED substrate doc (user/project/builtin scope)
|
|
17
19
|
// whose `appliesTo` glob matches the read file path surfaces, regardless of
|
|
18
20
|
// where the read file sits relative to the doc.
|
|
@@ -63,8 +65,9 @@ import { cachedSubstrateDocs } from './session-cache.js';
|
|
|
63
65
|
// is the segment we explicitly join onto each surviving ancestor).
|
|
64
66
|
const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
66
|
-
// Small path helpers (mirror the on-read precedent —
|
|
67
|
-
//
|
|
68
|
+
// Small path helpers (mirror the on-read precedent — frontmatter-rules, and
|
|
69
|
+
// the retired nested-context extension — so the injected envelope matches
|
|
70
|
+
// their faithful shape).
|
|
68
71
|
// ---------------------------------------------------------------------------
|
|
69
72
|
function realpathOrSelf(p) {
|
|
70
73
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.48",
|
|
4
4
|
"description": "crtr — agent runtime with memory, plugins, and marketplaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@crouton-kit/humanloop": "^0.3.
|
|
50
|
+
"@crouton-kit/humanloop": "^0.3.25",
|
|
51
51
|
"@earendil-works/pi-agent-core": "0.80.2",
|
|
52
52
|
"@earendil-works/pi-ai": "0.80.3",
|
|
53
53
|
"@earendil-works/pi-coding-agent": "0.80.2",
|
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nested Context Loader
|
|
3
|
-
*
|
|
4
|
-
* Pi only loads AGENTS.md / CLAUDE.md from the cwd's ancestor chain at startup.
|
|
5
|
-
* Anything *below* the launch dir (subproject CLAUDE.md files, .claude/rules/)
|
|
6
|
-
* is never pulled in. This extension fills that gap:
|
|
7
|
-
*
|
|
8
|
-
* When the `read` tool reads a file, we walk from that file's directory up to
|
|
9
|
-
* the session cwd and surface, appended to the read result:
|
|
10
|
-
* 1. Each directory's CLAUDE.md / AGENTS.md (first match per dir).
|
|
11
|
-
* 2. Each directory's .claude/rules/*.md — unconditionally if the rule has no
|
|
12
|
-
* `paths:` frontmatter, or only when the read file matches one of its
|
|
13
|
-
* `paths:` globs.
|
|
14
|
-
*
|
|
15
|
-
* Guarantees:
|
|
16
|
-
* - No duplicates. A `seen` set tracks every absolute path already loaded,
|
|
17
|
-
* including the files pi loaded at startup (seeded in session_start) and any
|
|
18
|
-
* context/rule file the agent reads directly.
|
|
19
|
-
* - Bounded. Anchored to the file's own parent chain (not the session cwd),
|
|
20
|
-
* stops at $HOME, and skips generated/dependency dirs (node_modules, ...).
|
|
21
|
-
*
|
|
22
|
-
* Install: drop in ~/.pi/agent/extensions/ (global) or .pi/extensions/ (project).
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import * as fs from "node:fs";
|
|
27
|
-
import * as os from "node:os";
|
|
28
|
-
import * as path from "node:path";
|
|
29
|
-
|
|
30
|
-
// First-match-per-dir order mirrors pi's own loader (AGENTS.md wins over CLAUDE.md).
|
|
31
|
-
const CONTEXT_FILENAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
|
32
|
-
|
|
33
|
-
// Directory names we never descend into / load context from.
|
|
34
|
-
const JUNK_DIRS = new Set([
|
|
35
|
-
"node_modules",
|
|
36
|
-
".git",
|
|
37
|
-
".venv",
|
|
38
|
-
"venv",
|
|
39
|
-
"dist",
|
|
40
|
-
"build",
|
|
41
|
-
".next",
|
|
42
|
-
".cache",
|
|
43
|
-
".yalc",
|
|
44
|
-
".sisyphus",
|
|
45
|
-
".crouter",
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
interface RuleFile {
|
|
49
|
-
name: string;
|
|
50
|
-
paths: string[];
|
|
51
|
-
body: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function realpathOrSelf(p: string): string {
|
|
55
|
-
try {
|
|
56
|
-
return fs.realpathSync(p);
|
|
57
|
-
} catch {
|
|
58
|
-
return p;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function isJunkPath(absDir: string): boolean {
|
|
63
|
-
return absDir.split(path.sep).some((seg) => JUNK_DIRS.has(seg));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Nearest enclosing git repo root for a path (walk up looking for `.git`).
|
|
67
|
-
function gitRoot(p: string): string | null {
|
|
68
|
-
let d = p;
|
|
69
|
-
const root = path.parse(d).root;
|
|
70
|
-
while (true) {
|
|
71
|
-
if (fs.existsSync(path.join(d, ".git"))) return d;
|
|
72
|
-
if (d === root) return null;
|
|
73
|
-
const parent = path.dirname(d);
|
|
74
|
-
if (parent === d) return null;
|
|
75
|
-
d = parent;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Display paths relative to the nearest git repo root, else absolute.
|
|
80
|
-
function disp(p: string): string {
|
|
81
|
-
const root = gitRoot(p);
|
|
82
|
-
if (!root) return p;
|
|
83
|
-
const rel = path.relative(root, p);
|
|
84
|
-
return rel === "" ? "." : rel;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Escape a value for use inside an XML-ish attribute in the injected block.
|
|
88
|
-
function attr(s: string): string {
|
|
89
|
-
return s
|
|
90
|
-
.replace(/&/g, "&")
|
|
91
|
-
.replace(/"/g, """)
|
|
92
|
-
.replace(/</g, "<")
|
|
93
|
-
.replace(/>/g, ">");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const AUTO_CONTEXT_RE = /<auto-loaded-context(?:\s[^>]*)?>\n([\s\S]*?)\n<\/auto-loaded-context>/;
|
|
97
|
-
|
|
98
|
-
function mergeAutoLoadedContext(items: string[], content: Array<{ type: string; text?: string }>) {
|
|
99
|
-
const existingText = content.map((block) => block.text ?? "").join("\n");
|
|
100
|
-
const fresh = items.map((item) => item.trim()).filter((item) => item !== "" && !existingText.includes(item));
|
|
101
|
-
if (fresh.length === 0) return content;
|
|
102
|
-
const inner = fresh.join("\n");
|
|
103
|
-
|
|
104
|
-
const existingIdx = content.findIndex((block) => typeof block.text === "string" && AUTO_CONTEXT_RE.test(block.text));
|
|
105
|
-
if (existingIdx !== -1) {
|
|
106
|
-
return content.map((block, idx) => {
|
|
107
|
-
if (idx !== existingIdx || typeof block.text !== "string") return block;
|
|
108
|
-
return {
|
|
109
|
-
...block,
|
|
110
|
-
text: block.text.replace(AUTO_CONTEXT_RE, (_whole, current: string) => {
|
|
111
|
-
const trimmed = String(current).trim();
|
|
112
|
-
return `<auto-loaded-context>\n${inner}${trimmed === "" ? "" : `\n${trimmed}`}\n</auto-loaded-context>`;
|
|
113
|
-
}),
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return [{ type: "text" as const, text: `<auto-loaded-context>\n${inner}\n</auto-loaded-context>` }, ...content];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function firstContextFile(dir: string): string | null {
|
|
122
|
-
for (const name of CONTEXT_FILENAMES) {
|
|
123
|
-
const fp = path.join(dir, name);
|
|
124
|
-
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) return fp;
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function findMarkdown(dir: string): string[] {
|
|
130
|
-
const out: string[] = [];
|
|
131
|
-
let entries: fs.Dirent[];
|
|
132
|
-
try {
|
|
133
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
134
|
-
} catch {
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
for (const e of entries) {
|
|
138
|
-
const fp = path.join(dir, e.name);
|
|
139
|
-
if (e.isDirectory()) out.push(...findMarkdown(fp));
|
|
140
|
-
else if (e.isFile() && e.name.endsWith(".md")) out.push(fp);
|
|
141
|
-
}
|
|
142
|
-
return out;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Minimal frontmatter parse: pulls `name` and the `paths:` list. Avoids a YAML dep.
|
|
146
|
-
function parseRule(filePath: string): RuleFile {
|
|
147
|
-
let raw = "";
|
|
148
|
-
try {
|
|
149
|
-
raw = fs.readFileSync(filePath, "utf-8");
|
|
150
|
-
} catch {
|
|
151
|
-
return { name: path.basename(filePath, ".md"), paths: [], body: "" };
|
|
152
|
-
}
|
|
153
|
-
const fm = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
154
|
-
if (!fm) return { name: path.basename(filePath, ".md"), paths: [], body: raw };
|
|
155
|
-
|
|
156
|
-
const [, front, body] = fm;
|
|
157
|
-
const lines = front.split("\n");
|
|
158
|
-
let name = path.basename(filePath, ".md");
|
|
159
|
-
const paths: string[] = [];
|
|
160
|
-
let inPaths = false;
|
|
161
|
-
|
|
162
|
-
const clean = (s: string) => s.trim().replace(/^["']|["']$/g, "");
|
|
163
|
-
for (const line of lines) {
|
|
164
|
-
const nameMatch = line.match(/^name:\s*(.+)$/);
|
|
165
|
-
if (nameMatch) {
|
|
166
|
-
name = clean(nameMatch[1]);
|
|
167
|
-
inPaths = false;
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const pathsInline = line.match(/^paths:\s*(.*)$/);
|
|
171
|
-
if (pathsInline) {
|
|
172
|
-
const rest = pathsInline[1].trim();
|
|
173
|
-
if (rest === "" || rest === "[]") {
|
|
174
|
-
inPaths = true; // YAML list form follows on subsequent lines
|
|
175
|
-
} else {
|
|
176
|
-
// Inline form: a single glob or a comma/space-separated list,
|
|
177
|
-
// optionally wrapped in [ ... ].
|
|
178
|
-
inPaths = false;
|
|
179
|
-
for (const part of rest.replace(/^\[|\]$/g, "").split(/[,]/)) {
|
|
180
|
-
const g = clean(part);
|
|
181
|
-
if (g) paths.push(g);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
if (inPaths) {
|
|
187
|
-
const item = line.match(/^\s*-\s*(.+)$/);
|
|
188
|
-
if (item) {
|
|
189
|
-
paths.push(clean(item[1]));
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
if (/^\S/.test(line)) inPaths = false; // dedented to next key
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return { name, paths, body: body.trim() };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function ruleMatches(absFile: string, baseDir: string, globs: string[]): boolean {
|
|
199
|
-
if (globs.length === 0) return true; // unconditional rule
|
|
200
|
-
const rel = path.relative(baseDir, absFile);
|
|
201
|
-
return globs.some((g) => {
|
|
202
|
-
try {
|
|
203
|
-
return path.matchesGlob(rel, g) || path.matchesGlob(absFile, g);
|
|
204
|
-
} catch {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export default function nestedContext(pi: ExtensionAPI) {
|
|
211
|
-
const seen = new Set<string>();
|
|
212
|
-
|
|
213
|
-
// Seed `seen` with everything pi already loaded at startup: the global
|
|
214
|
-
// agent file plus every CLAUDE.md/AGENTS.md on the cwd ancestor chain.
|
|
215
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
216
|
-
const globalDir = path.join(os.homedir(), ".pi", "agent");
|
|
217
|
-
const globalCtx = firstContextFile(globalDir);
|
|
218
|
-
if (globalCtx) seen.add(realpathOrSelf(globalCtx));
|
|
219
|
-
|
|
220
|
-
let dir = path.resolve(ctx.cwd);
|
|
221
|
-
const root = path.parse(dir).root;
|
|
222
|
-
while (true) {
|
|
223
|
-
const cf = firstContextFile(dir);
|
|
224
|
-
if (cf) seen.add(realpathOrSelf(cf));
|
|
225
|
-
if (dir === root) break;
|
|
226
|
-
const parent = path.dirname(dir);
|
|
227
|
-
if (parent === dir) break;
|
|
228
|
-
dir = parent;
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
233
|
-
if (event.toolName !== "read") return;
|
|
234
|
-
if (event.isError) return;
|
|
235
|
-
|
|
236
|
-
const rawPath = (event.input as { path?: string; file_path?: string })?.path
|
|
237
|
-
?? (event.input as { file_path?: string })?.file_path;
|
|
238
|
-
if (!rawPath) return;
|
|
239
|
-
|
|
240
|
-
const cwd = path.resolve(ctx.cwd);
|
|
241
|
-
// The model often passes a leading ~ ; pi's read tool expands it but
|
|
242
|
-
// event.input keeps the raw form, so expand it ourselves before resolving.
|
|
243
|
-
const expanded = rawPath.startsWith("~")
|
|
244
|
-
? path.join(os.homedir(), rawPath.slice(1))
|
|
245
|
-
: rawPath;
|
|
246
|
-
const absFile = realpathOrSelf(path.resolve(cwd, expanded));
|
|
247
|
-
|
|
248
|
-
// Anchor to the FILE's own location, not the session cwd: walk its parent
|
|
249
|
-
// chain up to the home directory (or filesystem root if outside home).
|
|
250
|
-
const home = os.homedir();
|
|
251
|
-
const fsRoot = path.parse(absFile).root;
|
|
252
|
-
|
|
253
|
-
// If the agent read a context/rule file directly, mark it loaded so we
|
|
254
|
-
// never re-inject it later, and don't append it to its own read.
|
|
255
|
-
seen.add(absFile);
|
|
256
|
-
|
|
257
|
-
type Block = { order: number; text: string };
|
|
258
|
-
const blocks: Block[] = [];
|
|
259
|
-
|
|
260
|
-
let dir = path.dirname(absFile);
|
|
261
|
-
let depth = 0;
|
|
262
|
-
while (true) {
|
|
263
|
-
if (!isJunkPath(dir)) {
|
|
264
|
-
// 1) Directory context file.
|
|
265
|
-
const cf = firstContextFile(dir);
|
|
266
|
-
if (cf) {
|
|
267
|
-
const real = realpathOrSelf(cf);
|
|
268
|
-
if (!seen.has(real)) {
|
|
269
|
-
seen.add(real);
|
|
270
|
-
try {
|
|
271
|
-
const content = fs.readFileSync(cf, "utf-8").trim();
|
|
272
|
-
blocks.push({
|
|
273
|
-
order: depth,
|
|
274
|
-
text:
|
|
275
|
-
`<project-guidance src="${attr(disp(cf))}" scope="${attr(`${disp(dir)}/`)}">\n` +
|
|
276
|
-
`${content}\n` +
|
|
277
|
-
`</project-guidance>`,
|
|
278
|
-
});
|
|
279
|
-
} catch {
|
|
280
|
-
/* unreadable; skip */
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// 2) .claude/rules for this directory.
|
|
286
|
-
const rulesDir = path.join(dir, ".claude", "rules");
|
|
287
|
-
for (const rf of findMarkdown(rulesDir)) {
|
|
288
|
-
// Skip stray context files living inside a rules dir; they are not rules.
|
|
289
|
-
if (CONTEXT_FILENAMES.includes(path.basename(rf))) continue;
|
|
290
|
-
const real = realpathOrSelf(rf);
|
|
291
|
-
if (seen.has(real)) continue;
|
|
292
|
-
const rule = parseRule(rf);
|
|
293
|
-
if (!ruleMatches(absFile, dir, rule.paths)) continue; // may match on a later read
|
|
294
|
-
seen.add(real);
|
|
295
|
-
blocks.push({
|
|
296
|
-
order: depth,
|
|
297
|
-
text:
|
|
298
|
-
`<rule name="${attr(rule.name)}" src="${attr(disp(rf))}">\n` +
|
|
299
|
-
`${rule.body}\n` +
|
|
300
|
-
`</rule>`,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (dir === home || dir === fsRoot) break;
|
|
306
|
-
const parent = path.dirname(dir);
|
|
307
|
-
if (parent === dir) break;
|
|
308
|
-
dir = parent;
|
|
309
|
-
depth += 1;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (blocks.length === 0) return;
|
|
313
|
-
|
|
314
|
-
// Outermost-first, so the most specific (nearest) guidance reads last/closest
|
|
315
|
-
// to the file content that follows it.
|
|
316
|
-
blocks.sort((a, b) => b.order - a.order);
|
|
317
|
-
const merged = mergeAutoLoadedContext(blocks.map((b) => b.text), event.content);
|
|
318
|
-
if (merged === event.content) return;
|
|
319
|
-
|
|
320
|
-
if (ctx.hasUI) {
|
|
321
|
-
ctx.ui.notify(`Loaded ${blocks.length} nested context file(s)`, "info");
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Prepend or merge, so the governing guidance appears before the file output.
|
|
325
|
-
return { content: merged };
|
|
326
|
-
});
|
|
327
|
-
}
|