@bugabinga/pi-ext-llmiterate 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-05-21
4
+
5
+ - a40a427 prepare extensions for npm release
6
+ - 133cb7d chore(pi): migrate extensions to earendil packages
7
+ - 5ca1296 Rework Pi agent extensions
8
+
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # llmiterate
2
+
3
+ File-save prompt markers for pi.
4
+
5
+ ## Marker syntax
6
+
7
+ A prompt line starts with a configured marker as the first word after whitespace.
8
+ Default markers: `LLM`, `PROMPT`.
9
+
10
+ ```java
11
+ void main(final String[] a) {
12
+ LLM some prompt text
13
+ if (a.length == 0)
14
+ LLM deal with this
15
+ LLM open ui
16
+ LLM and then fix perf
17
+ }
18
+ ```
19
+
20
+ Parsing result:
21
+
22
+ - `LLM some prompt text` → one agent request
23
+ - `LLM deal with this` → one agent request
24
+ - consecutive `LLM open ui` + `LLM and then fix perf` → one multi-line agent request
25
+
26
+ Comment markers are not special. Configure them explicitly if wanted:
27
+
28
+ ```json
29
+ {
30
+ "llmiterate": {
31
+ "markers": ["LLM", "//LLM", "/// LLM", "* LLM"]
32
+ }
33
+ }
34
+ ```
35
+
36
+ ```rust
37
+ fn fancy_algorithm() {
38
+ //LLM implement knapsack algorithm
39
+ }
40
+ ```
41
+
42
+ Rules:
43
+
44
+ - marker must be first word/token on line, modulo leading whitespace
45
+ - no automatic comment-prefix stripping for matching
46
+ - use configured markers like `//LLM`, `* LLM`, `/// LLM` for comments
47
+ - consecutive lines with the same marker are grouped
48
+ - no `:` and no `{{ }}` syntax
49
+ - empty marker lines are ignored
50
+ - fenced code blocks in Markdown are ignored
51
+
52
+ ## Replacement behavior
53
+
54
+ llmiterate instructs the agent to treat marker lines as inline prompts/TODOs and replace the whole marker span with the requested implementation/change.
55
+
56
+ ## Rerun policy
57
+
58
+ Once queued, a marker key is recorded in the platform state dir:
59
+
60
+ | Platform | State path |
61
+ |----------|------------|
62
+ | Linux/BSD | `${XDG_STATE_HOME:-~/.local/state}/pi/llmiterate/<projectHash>/state.json` |
63
+ | macOS | `~/Library/Application Support/pi/llmiterate/<projectHash>/state.json` |
64
+ | Windows | `%LOCALAPPDATA%\\pi\\llmiterate\\<projectHash>\\state.json` |
65
+
66
+ ```text
67
+ file:startLine:hash(promptText)
68
+ ```
69
+
70
+ Same marker will not rerun on later saves. To rerun, edit the prompt text or run:
71
+
72
+ ```text
73
+ /llmiterate reset
74
+ ```
75
+
76
+ ## Execution model
77
+
78
+ llmiterate always runs marker work in an isolated background Pi RPC worker.
79
+ It never injects marker prompts into the current chat session.
80
+
81
+ The worker is launched as:
82
+
83
+ ```bash
84
+ pi --mode rpc --no-extensions --session-dir <platform-state-dir>/llmiterate/<projectHash>/sessions
85
+ ```
86
+
87
+ Each marker job gets a fresh RPC session. Jobs are serialized per project: one background job runs at a time, queued jobs wait.
88
+ Each job revalidates the marker span before launch; stale markers are skipped.
89
+
90
+ ## Editor safety
91
+
92
+ llmiterate never edits files itself. File mutation comes only from the isolated Pi job via normal tools.
93
+
94
+ ## Watch behavior
95
+
96
+ - handles normal writes, atomic-save rename patterns, moves into watched dirs, and new directories
97
+ - refreshes directory watchers after `rename` events
98
+ - removes watchers for deleted/moved-away directories
99
+ - tracks watcher errors in status
100
+ - skips queued requests when their marker span changed before background agent start
101
+ - watches only directories that can contain paths configured in `llmiterate.include`
102
+ - treats `llmiterate.exclude` as an optional veto after the include whitelist
103
+
104
+ ## Singleton behavior
105
+
106
+ llmiterate is a singleton per detected project root, not raw shell cwd. The first Pi process creates `<platform-state-dir>/llmiterate/<projectHash>/lock` and owns the watcher. Other Pi processes launched anywhere under the same project root go into standby and only show status.
107
+
108
+ Project root detection:
109
+
110
+ 1. nearest ancestor containing `.pi/` or `AGENTS.md`
111
+ 2. else nearest ancestor containing `.git`
112
+ 3. else current cwd
113
+
114
+ - duplicate watchers do not queue duplicate agent runs
115
+ - singleton roots are canonicalized with `realpath` so symlinked launches share a lock
116
+ - stale global per-project locks are recovered when the owning PID is gone
117
+ - subprojects are separate when they have their own `.pi/`, `AGENTS.md`, or `.git`
118
+
119
+ ## Config
120
+
121
+ All config lives under the `llmiterate` key in Pi `settings.json`.
122
+ Global: `~/.pi/agent/settings.json`. Project override: `.pi/settings.json`.
123
+
124
+ Default `include` is empty, so llmiterate watches nothing until explicitly enabled for paths.
125
+
126
+ ```json
127
+ {
128
+ "llmiterate": {
129
+ "enabled": true,
130
+ "include": ["src/**/*.{ts,tsx,rs,java,md}", "docs/**/*.md"],
131
+ "exclude": [
132
+ ".git/**",
133
+ ".pi/**",
134
+ "node_modules/**",
135
+ "target/**"
136
+ ],
137
+ "debounceMs": 3000,
138
+ "maxFileBytes": 1000000,
139
+ "markers": ["LLM", "PROMPT"]
140
+ }
141
+ }
142
+ ```
143
+
144
+ ## Commands
145
+
146
+ ```text
147
+ /llmiterate toggle panel
148
+ /llmiterate show show panel
149
+ /llmiterate hide hide panel
150
+ /llmiterate status show panel
151
+ /llmiterate scan scan all matching files now
152
+ /llmiterate reset forget processed marker ledger
153
+ /llmiterate reload reload config + watcher
154
+ ```
155
+
156
+ ## Demo
157
+
158
+ <!-- demo:workflow_suite:start -->
159
+ ![Workflow suite](assets/workflow_suite.gif)
160
+ <!-- demo:workflow_suite:end -->
@@ -0,0 +1,47 @@
1
+ # llmiterate performance notes
2
+
3
+ ## Workflow
4
+
5
+ Profile command:
6
+
7
+ ```bash
8
+ bun --cpu-prof --cpu-prof-md --cpu-prof-dir=__bench__/profiles --cpu-prof-name=baseline.cpuprofile.md __bench__/bench.ts
9
+ ```
10
+
11
+ Benchmark command:
12
+
13
+ ```bash
14
+ bun __bench__/bench.ts
15
+ /tmp/go-bin/benchstat __bench__/baseline.txt __bench__/optimized.txt
16
+ ```
17
+
18
+ ## Profile findings
19
+
20
+ Baseline CPU profile hotspots:
21
+
22
+ - path filtering dominated scans: `shouldIncludePath`, regex excludes, and `toSlash` split/join
23
+ - parser spent avoidable time rebuilding marker regexes and checking Markdown extension per line
24
+
25
+ ## Optimizations
26
+
27
+ - compile path filters once per runtime
28
+ - split simple `dir/**` excludes into `Set` root-dir checks / prefix checks before regex fallback
29
+ - add `shouldIncludeCompiledPath()` for hot scan paths
30
+ - make `toSlash()` allocation-free when path is already slash-normalized
31
+ - cache marker regex by normalized marker list
32
+ - compute Markdown-file status once per parse
33
+
34
+ ## Benchstat
35
+
36
+ ```text
37
+ │ __bench__/baseline.txt │ __bench__/optimized.txt │
38
+ │ sec/op │ sec/op vs base │
39
+ ParseNoMarkers 73.71µ ± ∞ ¹ 57.10µ ± ∞ ¹ ~ (p=0.333 n=1+5)
40
+ ParseSomeMarkers 82.60µ ± ∞ ¹ 70.85µ ± ∞ ¹ ~ (p=0.333 n=1+5)
41
+ ParseMarkdownFences 110.88µ ± ∞ ¹ 80.38µ ± ∞ ¹ ~ (p=0.333 n=1+5)
42
+ PathFilterCompiled 3227.5µ ± ∞ ¹ 763.7µ ± ∞ ¹ ~ (p=0.333 n=1+5)
43
+ geomean 216.1µ 125.5µ -41.90%
44
+ ¹ need >= 6 samples for confidence interval at level 0.95
45
+ ```
46
+
47
+ Note: baseline was captured before optimization; optimized has 5 samples. Confidence is limited but direction/magnitude is clear.
@@ -0,0 +1,4 @@
1
+ BenchmarkParseNoMarkers 4000 73707 ns/op
2
+ BenchmarkParseSomeMarkers 4000 82603 ns/op
3
+ BenchmarkParseMarkdownFences 2000 110883 ns/op
4
+ BenchmarkPathFilterCompiled 2000 3227542 ns/op
@@ -0,0 +1,66 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { compilePathFilter, defaultConfig, parseMarkerPrompts, shouldIncludeCompiledPath } from "../core";
3
+
4
+ const ITERATIONS = Number(process.env.ITERATIONS ?? 2000);
5
+ const MIN_TIME_MS = Number(process.env.MIN_TIME_MS ?? 200);
6
+
7
+ const sourceNoMarkers = makeSource(600, 0);
8
+ const sourceSomeMarkers = makeSource(600, 20);
9
+ const markdown = makeMarkdown(900, 30);
10
+ const paths = makePaths(5000);
11
+ const filter = compilePathFilter(defaultConfig());
12
+
13
+ bench("ParseNoMarkers", () => parseMarkerPrompts("src/main.ts", sourceNoMarkers));
14
+ bench("ParseSomeMarkers", () => parseMarkerPrompts("src/main.ts", sourceSomeMarkers));
15
+ bench("ParseMarkdownFences", () => parseMarkerPrompts("docs/notes.md", markdown));
16
+ bench("PathFilterCompiled", () => {
17
+ let matched = 0;
18
+ for (const p of paths) if (shouldIncludeCompiledPath(p, filter)) matched++;
19
+ if (matched === 0) throw new Error("impossible");
20
+ return matched;
21
+ });
22
+
23
+ function bench(name: string, fn: () => unknown): void {
24
+ for (let i = 0; i < 50; i++) fn();
25
+
26
+ let iterations = ITERATIONS;
27
+ let elapsed = 0;
28
+ let result: unknown;
29
+
30
+ do {
31
+ const start = performance.now();
32
+ for (let i = 0; i < iterations; i++) result = fn();
33
+ elapsed = performance.now() - start;
34
+ if (elapsed < MIN_TIME_MS) iterations *= 2;
35
+ } while (elapsed < MIN_TIME_MS);
36
+
37
+ if (result === undefined) throw new Error("bench result vanished");
38
+ const nsPerOp = (elapsed * 1_000_000) / iterations;
39
+ console.log(`Benchmark${name} ${iterations} ${nsPerOp.toFixed(0)} ns/op`);
40
+ }
41
+
42
+ function makeSource(lines: number, markerEvery: number): string {
43
+ const out: string[] = [];
44
+ for (let i = 0; i < lines; i++) {
45
+ if (markerEvery > 0 && i % markerEvery === 0) out.push(` LLM implement generated case ${i}`);
46
+ else out.push(` const value${i} = callThing(${i}, "${i}");`);
47
+ }
48
+ return out.join("\n");
49
+ }
50
+
51
+ function makeMarkdown(lines: number, markerEvery: number): string {
52
+ const out: string[] = ["# Notes"];
53
+ for (let i = 0; i < lines; i++) {
54
+ if (i % 80 === 0) out.push("```ts");
55
+ if (i % 80 === 40) out.push("```");
56
+ if (markerEvery > 0 && i % markerEvery === 0) out.push(`LLM summarize section ${i}`);
57
+ else out.push(`Regular prose line ${i} with enough text to be realistic.`);
58
+ }
59
+ return out.join("\n");
60
+ }
61
+
62
+ function makePaths(count: number): string[] {
63
+ const exts = ["ts", "tsx", "rs", "java", "md", "png", "lock"];
64
+ const dirs = ["src", "test", "node_modules/pkg", "target/debug", "research/pages", "docs"];
65
+ return Array.from({ length: count }, (_, i) => `${dirs[i % dirs.length]}/file-${i}.${exts[i % exts.length]}`);
66
+ }
@@ -0,0 +1,8 @@
1
+ │ __bench__/baseline.txt │ __bench__/optimized.txt │
2
+ │ sec/op │ sec/op vs base │
3
+ ParseNoMarkers 73.71µ ± ∞ ¹ 57.10µ ± ∞ ¹ ~ (p=0.333 n=1+5)
4
+ ParseSomeMarkers 82.60µ ± ∞ ¹ 70.85µ ± ∞ ¹ ~ (p=0.333 n=1+5)
5
+ ParseMarkdownFences 110.88µ ± ∞ ¹ 80.38µ ± ∞ ¹ ~ (p=0.333 n=1+5)
6
+ PathFilterCompiled 3227.5µ ± ∞ ¹ 763.7µ ± ∞ ¹ ~ (p=0.333 n=1+5)
7
+ geomean 216.1µ 125.5µ -41.90%
8
+ ¹ need >= 6 samples for confidence interval at level 0.95
@@ -0,0 +1,20 @@
1
+ BenchmarkParseNoMarkers 4000 57699 ns/op
2
+ BenchmarkParseSomeMarkers 4000 69163 ns/op
3
+ BenchmarkParseMarkdownFences 4000 80377 ns/op
4
+ BenchmarkPathFilterCompiled 2000 797186 ns/op
5
+ BenchmarkParseNoMarkers 4000 59416 ns/op
6
+ BenchmarkParseSomeMarkers 4000 70846 ns/op
7
+ BenchmarkParseMarkdownFences 4000 83867 ns/op
8
+ BenchmarkPathFilterCompiled 2000 763720 ns/op
9
+ BenchmarkParseNoMarkers 4000 56519 ns/op
10
+ BenchmarkParseSomeMarkers 4000 67986 ns/op
11
+ BenchmarkParseMarkdownFences 4000 78726 ns/op
12
+ BenchmarkPathFilterCompiled 2000 750411 ns/op
13
+ BenchmarkParseNoMarkers 4000 55438 ns/op
14
+ BenchmarkParseSomeMarkers 4000 76997 ns/op
15
+ BenchmarkParseMarkdownFences 4000 90038 ns/op
16
+ BenchmarkPathFilterCompiled 2000 761932 ns/op
17
+ BenchmarkParseNoMarkers 4000 57098 ns/op
18
+ BenchmarkParseSomeMarkers 4000 71594 ns/op
19
+ BenchmarkParseMarkdownFences 4000 79348 ns/op
20
+ BenchmarkPathFilterCompiled 2000 763887 ns/op
@@ -0,0 +1,17 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`llmiterate output snapshots agent prompt snapshot 1`] = `
4
+ "llmiterate request from @a.rs:1
5
+
6
+ Prompt marker contents:
7
+ \`\`\`text
8
+ implement foo
9
+ \`\`\`
10
+
11
+ Instructions:
12
+ - Work in/around @a.rs.
13
+ - Marker span to replace: a.rs:1-1.
14
+ - Treat the marker line(s) as an inline prompt/TODO, not program code.
15
+ - Replace the whole marker span with the requested implementation or code change.
16
+ - Do not leave the marker line(s) behind unless impossible; explain if impossible."
17
+ `;
@@ -0,0 +1,15 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`llmiterate UI widget empty snapshot 1`] = `
4
+ "╭ llmiterate ──────────────────────────────────────────────╮
5
+ │no prompts queued yet │
6
+ ╰──────────────────────────────────────────────────────────╯"
7
+ `;
8
+
9
+ exports[`llmiterate UI widget run snapshot 1`] = `
10
+ "╭ llmiterate ──────────────────────────────────────────────────────────────────╮
11
+ │● src/main.ts:1 LLM implement foo and bar │
12
+ │ running edit │
13
+ │ I am editing the file now and will run tests afterwards. │
14
+ ╰──────────────────────────────────────────────────────────────────────────────╯"
15
+ `;
@@ -0,0 +1,268 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import {
6
+ buildAgentPrompt,
7
+ compilePathFilter,
8
+ defaultConfig,
9
+ emptyState,
10
+ globToRegExp,
11
+ isExcludedPath,
12
+ hashProjectRoot,
13
+ loadState,
14
+ normalizeConfig,
15
+ parseMarkerPrompts,
16
+ platformStateDir,
17
+ projectStoreDir,
18
+ previewText,
19
+ saveState,
20
+ STATE_FILE,
21
+ shouldIncludeCompiledPath,
22
+ shouldIncludePath,
23
+ shouldWatchDirectory,
24
+ toSlash,
25
+ } from "../core";
26
+
27
+ const tempDirs: string[] = [];
28
+
29
+ afterEach(() => {
30
+ while (tempDirs.length) rmSync(tempDirs.pop()!, { recursive: true, force: true });
31
+ });
32
+
33
+ function tempDir(): string {
34
+ const dir = mkdtempSync(join(tmpdir(), "llmiterate-core-"));
35
+ tempDirs.push(dir);
36
+ return dir;
37
+ }
38
+
39
+ describe("llmiterate parser", () => {
40
+ test("parses marker as first word on line", () => {
41
+ const blocks = parseMarkerPrompts("Main.java", [
42
+ "void main(final String[] a) {",
43
+ " LLM some prompt text",
44
+ " if (a.length == 0)",
45
+ " LLM deal with this",
46
+ "}",
47
+ ].join("\n"));
48
+
49
+ expect(blocks).toHaveLength(2);
50
+ expect(blocks[0]).toMatchObject({ marker: "LLM", startLine: 2, endLine: 2, prompt: "some prompt text" });
51
+ expect(blocks[1]).toMatchObject({ marker: "LLM", startLine: 4, endLine: 4, prompt: "deal with this" });
52
+ });
53
+
54
+ test("groups consecutive marker lines with same marker", () => {
55
+ const blocks = parseMarkerPrompts("Main.java", [
56
+ " LLM open ui",
57
+ " LLM and then fix perf",
58
+ " run();",
59
+ ].join("\n"));
60
+
61
+ expect(blocks).toHaveLength(1);
62
+ expect(blocks[0]).toMatchObject({ startLine: 1, endLine: 2, prompt: "open ui\nand then fix perf" });
63
+ });
64
+
65
+ test("does not group different markers", () => {
66
+ const blocks = parseMarkerPrompts("a.ts", "LLM first\nPROMPT second\nLLM third");
67
+ expect(blocks.map((b) => b.prompt)).toEqual(["first", "second", "third"]);
68
+ expect(blocks.map((b) => b.startLine)).toEqual([1, 2, 3]);
69
+ });
70
+
71
+ test("does not strip comments unless configured marker includes comment", () => {
72
+ expect(parseMarkerPrompts("src/lib.rs", " // LLM implement knapsack algo")).toHaveLength(0);
73
+
74
+ const blocks = parseMarkerPrompts("src/lib.rs", " //LLM implement knapsack algo", ["//LLM"]);
75
+ expect(blocks).toHaveLength(1);
76
+ expect(blocks[0]).toMatchObject({ marker: "//LLM", prompt: "implement knapsack algo" });
77
+ });
78
+
79
+ test("supports markers containing spaces", () => {
80
+ const blocks = parseMarkerPrompts("a.rs", " /// LLM add tests", ["/// LLM"]);
81
+ expect(blocks).toHaveLength(1);
82
+ expect(blocks[0]).toMatchObject({ marker: "/// LLM", prompt: "add tests" });
83
+ });
84
+
85
+ test("sorts overlapping markers longest-first", () => {
86
+ const [block] = parseMarkerPrompts("a.rs", "/// LLM add tests", ["LLM", "/// LLM"]);
87
+ expect(block).toMatchObject({ marker: "/// LLM", prompt: "add tests" });
88
+ });
89
+
90
+ test("supports PROMPT marker too", () => {
91
+ const blocks = parseMarkerPrompts("a.ts", "PROMPT add tests");
92
+ expect(blocks).toHaveLength(1);
93
+ expect(blocks[0]).toMatchObject({ marker: "PROMPT", prompt: "add tests" });
94
+ });
95
+
96
+ test("marker must be first word", () => {
97
+ expect(parseMarkerPrompts("a.md", "some LLM receives message history")).toHaveLength(0);
98
+ expect(parseMarkerPrompts("a.ts", "xLLM implement foo")).toHaveLength(0);
99
+ expect(parseMarkerPrompts("a.ts", "LLMish implement foo")).toHaveLength(0);
100
+ });
101
+
102
+ test("ignores empty marker lines", () => {
103
+ expect(parseMarkerPrompts("a.ts", "LLM\nPROMPT ")).toHaveLength(0);
104
+ });
105
+
106
+ test("ignores enum/string assignment false positives", () => {
107
+ expect(parseMarkerPrompts("node.d.ts", 'PROMPT = "PROMPT",')).toHaveLength(0);
108
+ });
109
+
110
+ test("does not parse old colon or brace syntax", () => {
111
+ expect(parseMarkerPrompts("a.ts", "// PROMPT: do thing")).toHaveLength(0);
112
+ expect(parseMarkerPrompts("a.ts", "// PROMPT{{\n// do thing\n// }}")).toHaveLength(0);
113
+ });
114
+
115
+ test("ignores Markdown fenced code blocks", () => {
116
+ const blocks = parseMarkerPrompts("notes.md", [
117
+ "# docs",
118
+ "```ts",
119
+ "LLM fake inside fence",
120
+ "```",
121
+ "LLM real outside fence",
122
+ "~~~",
123
+ "PROMPT fake tilde fence",
124
+ "~~~",
125
+ ].join("\n"));
126
+
127
+ expect(blocks).toHaveLength(1);
128
+ expect(blocks[0]).toMatchObject({ startLine: 5, prompt: "real outside fence" });
129
+ });
130
+
131
+ test("normalizes CRLF and CR line endings", () => {
132
+ expect(parseMarkerPrompts("a.ts", "LLM one\r\nLLM two")[0]!.prompt).toBe("one\ntwo");
133
+ expect(parseMarkerPrompts("a.ts", "LLM one\rLLM two")[0]!.prompt).toBe("one\ntwo");
134
+ });
135
+ });
136
+
137
+ describe("llmiterate config/state/path helpers", () => {
138
+ test("defaults to watching nothing", () => {
139
+ const config = defaultConfig();
140
+ const filter = compilePathFilter(config);
141
+ expect(config.include).toEqual([]);
142
+ expect(shouldWatchDirectory("", filter)).toBe(false);
143
+ expect(shouldIncludeCompiledPath("src/a.ts", filter)).toBe(false);
144
+ });
145
+
146
+ test("normalizes valid partial config and rejects invalid values", () => {
147
+ const config = normalizeConfig({
148
+ enabled: false,
149
+ include: ["src/**/*.ts"],
150
+ exclude: ["tmp/**"],
151
+ debounceMs: 12.8,
152
+ maxFileBytes: 42,
153
+ markers: ["//LLM", "bad\nmarker", " PROMPT "],
154
+ });
155
+
156
+ expect(config).toMatchObject({
157
+ enabled: false,
158
+ include: ["src/**/*.ts"],
159
+ exclude: ["tmp/**"],
160
+ debounceMs: 12,
161
+ maxFileBytes: 42,
162
+ markers: ["//LLM", "PROMPT"],
163
+ });
164
+
165
+ const fallback = defaultConfig();
166
+ expect(normalizeConfig({ include: [""] })).toMatchObject({
167
+ include: fallback.include,
168
+ });
169
+ });
170
+
171
+ test("uses platform state dir for project stores", () => {
172
+ const oldXdg = process.env.XDG_STATE_HOME;
173
+ const stateRoot = tempDir();
174
+ process.env.XDG_STATE_HOME = stateRoot;
175
+ try {
176
+ const root = "/tmp/project";
177
+ expect(platformStateDir()).toBe(join(stateRoot, "pi"));
178
+ expect(projectStoreDir(root)).toBe(join(stateRoot, "pi", "llmiterate", hashProjectRoot(root)));
179
+ } finally {
180
+ if (oldXdg === undefined) delete process.env.XDG_STATE_HOME;
181
+ else process.env.XDG_STATE_HOME = oldXdg;
182
+ }
183
+ });
184
+
185
+ test("platformStateDir uses native conventions", () => {
186
+ expect(platformStateDir("linux", {}, "/home/me")).toBe("/home/me/.local/state/pi");
187
+ expect(platformStateDir("linux", { XDG_STATE_HOME: "/state" }, "/home/me")).toBe("/state/pi");
188
+ expect(platformStateDir("darwin", {}, "/Users/me")).toBe("/Users/me/Library/Application Support/pi");
189
+ expect(platformStateDir("win32", { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, "C:\\Users\\me"))
190
+ .toBe("C:\\Users\\me\\AppData\\Local\\pi");
191
+ expect(platformStateDir("win32", {}, "C:\\Users\\me"))
192
+ .toBe("C:\\Users\\me\\AppData\\Local\\pi");
193
+ });
194
+
195
+ test("saves and loads state, invalid state falls back empty", () => {
196
+ const storeDir = tempDir();
197
+ const state = emptyState();
198
+ state.processed["a.ts:1:hash"] = {
199
+ file: "a.ts",
200
+ startLine: 1,
201
+ endLine: 1,
202
+ promptHash: "hash",
203
+ promptPreview: "preview",
204
+ queuedAt: 123,
205
+ };
206
+
207
+ saveState(storeDir, state);
208
+ expect(loadState(storeDir)).toEqual(state);
209
+ expect(readFileSync(join(storeDir, STATE_FILE), "utf-8")).toEndWith("\n");
210
+
211
+ writeFileSync(join(storeDir, STATE_FILE), JSON.stringify({
212
+ version: 1,
213
+ processed: {
214
+ good: state.processed["a.ts:1:hash"],
215
+ bad: { file: "missing required fields" },
216
+ },
217
+ }));
218
+ expect(loadState(storeDir).processed).toEqual({ good: state.processed["a.ts:1:hash"] });
219
+ rmSync(join(storeDir, STATE_FILE));
220
+ expect(loadState(storeDir)).toEqual(emptyState());
221
+ });
222
+
223
+ test("glob supports globstar, brace alternatives, and excludes", () => {
224
+ const re = globToRegExp("**/*.{ts,rs}");
225
+ expect(re.test("index.ts")).toBe(true);
226
+ expect(re.test("src/lib.rs")).toBe(true);
227
+ expect(re.test("src/lib.java")).toBe(false);
228
+
229
+ const filter = compilePathFilter({ include: ["**/*.{ts,rs}"], exclude: ["node_modules/**", "packages/pkg/dist/**"] });
230
+ expect(shouldIncludeCompiledPath("src/lib.rs", filter)).toBe(true);
231
+ expect(shouldIncludePath("node_modules/x.ts", filter)).toBe(false);
232
+ expect(isExcludedPath("node_modules/x.ts", filter)).toBe(true);
233
+ expect(isExcludedPath("packages/pkg/dist/x.ts", filter)).toBe(true);
234
+
235
+ const defaults = compilePathFilter(defaultConfig());
236
+ expect(shouldIncludeCompiledPath("extensions/devil/node_modules/pkg/index.ts", defaults)).toBe(false);
237
+ });
238
+
239
+ test("directory watch traversal is include-whitelisted with exclude veto", () => {
240
+ const filter = compilePathFilter({ include: ["src/**/*.ts", "docs/*.md"], exclude: ["src/generated/**"] });
241
+ expect(shouldWatchDirectory("", filter)).toBe(true);
242
+ expect(shouldWatchDirectory("src", filter)).toBe(true);
243
+ expect(shouldWatchDirectory("src/feature", filter)).toBe(true);
244
+ expect(shouldWatchDirectory("docs", filter)).toBe(true);
245
+ expect(shouldWatchDirectory("research", filter)).toBe(false);
246
+ expect(shouldWatchDirectory("docs/deep", filter)).toBe(false);
247
+ expect(shouldWatchDirectory("src/generated", filter)).toBe(false);
248
+ });
249
+
250
+ test("globstar include watches all directories unless excluded", () => {
251
+ const filter = compilePathFilter({ include: ["**/*.ts"], exclude: ["node_modules/**"] });
252
+ expect(shouldWatchDirectory("any/depth", filter)).toBe(true);
253
+ expect(shouldWatchDirectory("node_modules", filter)).toBe(false);
254
+ });
255
+
256
+ test("toSlash and previewText are allocation-aware utility behavior", () => {
257
+ expect(toSlash("a/b/c")).toBe("a/b/c");
258
+ expect(toSlash("a\\b\\c")).toBe("a/b/c");
259
+ expect(previewText(" one\n two\tthree ", 9)).toBe("one two …");
260
+ });
261
+ });
262
+
263
+ describe("llmiterate output snapshots", () => {
264
+ test("agent prompt snapshot", () => {
265
+ const [block] = parseMarkerPrompts("a.rs", "LLM implement foo");
266
+ expect(buildAgentPrompt(block!)).toMatchSnapshot();
267
+ });
268
+ });