@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 +8 -0
- package/README.md +160 -0
- package/__bench__/PERF.md +47 -0
- package/__bench__/baseline.txt +4 -0
- package/__bench__/bench.ts +66 -0
- package/__bench__/benchstat.txt +8 -0
- package/__bench__/optimized.txt +20 -0
- package/__tests__/__snapshots__/core.test.ts.snap +17 -0
- package/__tests__/__snapshots__/ui.test.ts.snap +15 -0
- package/__tests__/core.test.ts +268 -0
- package/__tests__/lock.test.ts +80 -0
- package/__tests__/rpc.test.ts +124 -0
- package/__tests__/ui.test.ts +80 -0
- package/__tests__/watcher.test.ts +133 -0
- package/assets/workflow_suite.gif +0 -0
- package/bun.lock +336 -0
- package/core.ts +489 -0
- package/index.ts +434 -0
- package/lock.ts +139 -0
- package/package.json +19 -0
- package/rpc.ts +228 -0
- package/types.ts +45 -0
- package/ui.ts +103 -0
- package/watcher.ts +191 -0
package/CHANGELOG.md
ADDED
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
|
+

|
|
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,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
|
+
});
|