@crouton-kit/crouter 0.3.46 → 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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="color-scheme" content="dark light" />
7
7
  <title>crouter</title>
8
- <script type="module" crossorigin src="/assets/index-Di-gSsVn.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-dpd0Rzuw.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DrkcvANq.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.46",
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.21",
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, "&amp;")
91
- .replace(/"/g, "&quot;")
92
- .replace(/</g, "&lt;")
93
- .replace(/>/g, "&gt;");
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
- }