pi-orchestrator-config 4.3.14 → 4.4.1
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/README.md +48 -4
- package/agents/test-runner.md +1 -1
- package/extensions/openai-compatible-discovery/index.ts +29 -1
- package/extensions/orchestrator/async-agents.ts +4 -31
- package/extensions/orchestrator/enforcement-helpers.ts +9 -4
- package/extensions/orchestrator/graft.ts +306 -0
- package/extensions/orchestrator/index.ts +2 -0
- package/extensions/orchestrator/memory-tools.ts +1 -1
- package/extensions/orchestrator/pi-config-review-state.ts +1 -1
- package/extensions/orchestrator/project-settings.ts +7 -1
- package/extensions/orchestrator/settings-tui-helpers.ts +2 -3
- package/extensions/orchestrator/settings-tui.ts +1 -3
- package/extensions/orchestrator/status-bar.ts +1 -0
- package/extensions/orchestrator/subagent-tool.ts +3 -2
- package/extensions/orchestrator/task-lifecycle.ts +10 -2
- package/extensions/pidash/model-info.ts +7 -0
- package/extensions/pidash/pidash-ui/dist/assets/{index-Ehfwjr9d.js → index-C1VH6cPk.js} +6 -6
- package/extensions/pidash/pidash-ui/dist/assets/index-hM6gC5Yg.css +2 -0
- package/extensions/pidash/pidash-ui/dist/index.html +2 -2
- package/extensions/pidash/pidash-ui/src/App.tsx +2 -1
- package/extensions/pidash/pidash-ui/src/components/InfoBar.tsx +79 -15
- package/extensions/pidash/pidash-ui/src/lib/format-total.ts +16 -0
- package/extensions/pidash/pidash-ui/src/lib/model-capabilities.ts +11 -0
- package/extensions/pidash/pidash.ts +24 -6
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/index.ts +70 -9
- package/extensions/pitasks/task-store.ts +44 -5
- package/extensions/pitasks/task-tools.ts +8 -0
- package/extensions/pitasks/task-widget.ts +57 -27
- package/extensions/pitasks/types.ts +6 -0
- package/extensions/providers/index.ts +16 -4
- package/extensions/providers/last-thinking-level.ts +174 -0
- package/extensions/shared/file-logger.ts +5 -2
- package/extensions/shared/format-total.ts +11 -0
- package/extensions/shared/install-logger.mjs +18 -0
- package/extensions/shared/logger-core.mjs +35 -0
- package/extensions/shared/logger.ts +6 -27
- package/extensions/shared/openai-compatible-discovery.ts +28 -0
- package/extensions/shared/types.ts +4 -0
- package/package.json +3 -1
- package/rules/10-agent-routing.md +6 -3
- package/rules/20-code-review-loop.md +9 -9
- package/scripts/graft-allow-scripts.mjs +47 -0
- package/scripts/pidash-server.ts +7 -3
- package/scripts/pidash-session-state.ts +116 -0
- package/settings-keys.json +21 -0
- package/extensions/pidash/pidash-ui/dist/assets/index-j_XBAdtq.css +0 -2
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Single extension that provides:
|
|
|
28
28
|
| **Git status** | Live git status in status line with colored icons — updates after every tool call. Shows clickable `#N` when the branch has an open PR. Last-activity clock `⏱ HH:MM (Xm/Xh ago)` shows time since last response |
|
|
29
29
|
| **Desktop notifications** | Notifies via `notify-send` on task completion, waiting for input, and action required |
|
|
30
30
|
| **File preview** | Serves generated HTML/frontend files via HTTP for browser preview from container |
|
|
31
|
+
| **Graft integration** | When `graft_enable=true`, main agents and subagents query the local Graft graph before raw project navigation on substantive prompts. Children only consume graphs. Stale graphs remain usable, retrieval failures fall back to raw tools, and main processes coordinate rebuilds with a cross-process lock. |
|
|
31
32
|
| **Pidash dashboard** | Live web dashboard — multi-session monitoring, browser messaging, model switching, live session name updates, reasoning token display |
|
|
32
33
|
| **Pidiff viewer** | Per-project diff viewer with review comments — branch diffs, file tree, inline comments, git-based ignore rules |
|
|
33
34
|
| **Dreaming** | Background memory consolidation — extracts memories from sessions, deduplicates, maintains topic-based memory |
|
|
@@ -157,7 +158,7 @@ The installer covers:
|
|
|
157
158
|
|
|
158
159
|
- **Pi Packages** — pi-config, pi-vertex-claude, pi-web-access, myk-pi-tools, bun
|
|
159
160
|
- **Python Tools** — prek
|
|
160
|
-
- **npm Packages** — mcpc, acpx, agent-browser
|
|
161
|
+
- **npm Packages** — mcpc, acpx, agent-browser, graft
|
|
161
162
|
- **Browser Automation** — playwright + chromium
|
|
162
163
|
- **Environment Setup** — gitignore configuration
|
|
163
164
|
|
|
@@ -263,14 +264,16 @@ model contract. It does not filter discovery results or claim that the endpoint
|
|
|
263
264
|
|
|
264
265
|
`review_loop_enforcement` controls automatic review-agent dispatch and commit enforcement.
|
|
265
266
|
|
|
266
|
-
- `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-
|
|
267
|
+
- `true` automatically dispatches 6 agents in parallel (5 reviewers + `test-runner`) after code changes and requires a clean review before commits.
|
|
267
268
|
- `false` disables automatic review-agent and test-agent dispatch. Manual reviews and test runs are optional. No review state, cycle, or result is required before commit.
|
|
268
269
|
|
|
269
270
|
When `review_loop_enforcement` is enabled, the loop stops once all reviewers approve with 0 findings and tests pass
|
|
270
|
-
(`tests_passed: true` in `pi-config-review-state.jsonl`)
|
|
271
|
+
(`tests_passed: true` in `pi-config-review-state.jsonl`). `tests_passed` is set only by a detected
|
|
272
|
+
successful test command; completion of async `test-runner` or `test-automator` agents does not set it.
|
|
273
|
+
OR after `review_loop_max_cycles` total cycles (default `3`,
|
|
271
274
|
valid integers `1`-`10`; env: digit string `"1"`-`"10"` only (after trim)) — whichever comes first. Each cycle
|
|
272
275
|
always completes fix/explain (5a) before the cap check; the cap only blocks re-dispatch
|
|
273
|
-
(step 2 / all 6 agents, including test-
|
|
276
|
+
(step 2 / all 6 agents, including test-runner), not responding to findings.
|
|
274
277
|
At cap, report **Not fixed** (explained why not → outstanding) vs **Fixed**
|
|
275
278
|
(verification blocked by the cap — cannot re-dispatch to confirm clean). Invalid values (including non-digit
|
|
276
279
|
forms like `"10.0"` / `"1e1"`) fall through to the next resolution layer / default `3`. Disable the review loop via
|
|
@@ -402,6 +405,29 @@ Set `image_model` in `pi-config-settings.json` or use `PI_IMAGE_MODEL` env var.
|
|
|
402
405
|
|
|
403
406
|
In containers, images are auto-served via HTTP for browser preview.
|
|
404
407
|
|
|
408
|
+
### Graft repository graph (optional)
|
|
409
|
+
|
|
410
|
+
Set `graft_enable` to `true` in `.pi/pi-config-settings.json` (or set
|
|
411
|
+
`PI_GRAFT_ENABLE=true`) to enable local Graft repository graph context:
|
|
412
|
+
|
|
413
|
+
```json
|
|
414
|
+
{
|
|
415
|
+
"graft_enable": true
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Graft requires Node.js 22+, npm 12+, node-gyp prerequisites (Python 3, `make`, and
|
|
420
|
+
G++), and the `graft` CLI. The container includes them; native installs can use
|
|
421
|
+
`uv run scripts/install.py`. The installer downloads `@nanonets/graft@latest` with
|
|
422
|
+
scripts disabled into a dedicated npm prefix, audits that isolated dependency tree
|
|
423
|
+
for lifecycle scripts, and strictly rebuilds it with only those packages approved.
|
|
424
|
+
The completed prefix is then published under the global npm prefix and its `graft`
|
|
425
|
+
binary linked globally, so unrelated global packages are never evaluated. The
|
|
426
|
+
installer verifies `graft --version`; `DO_NOT_TRACK=1` disables Graft telemetry.
|
|
427
|
+
When enabled, Graft reads the trusted project and writes a regenerable `./graft/`
|
|
428
|
+
cache. Delete that directory or set `graft_enable` to `false` to disable it. If the
|
|
429
|
+
native CLI is unavailable or its installation fails, pi continues without Graft.
|
|
430
|
+
|
|
405
431
|
### Cache Miss Notices
|
|
406
432
|
|
|
407
433
|
Enable `showCacheMissNotices` in pi settings to see transcript notices on significant prompt-cache misses — useful for investigating unexpected token costs:
|
|
@@ -423,6 +449,23 @@ Run pi inside a disposable container for **filesystem isolation** — the agent
|
|
|
423
449
|
- **Consistent tooling** — All required tools pre-installed in a single image
|
|
424
450
|
- **Disposable** — Container is destroyed after each session (`--rm`)
|
|
425
451
|
|
|
452
|
+
**Graft (optional `graft_enable`):** The image installs the latest
|
|
453
|
+
`@nanonets/graft` with `DO_NOT_TRACK=1`. It downloads with scripts disabled, derives
|
|
454
|
+
an exact script allowlist by auditing the installed package's reachable dependency
|
|
455
|
+
metadata in a dedicated npm prefix, then strictly rebuilds that isolated tree with
|
|
456
|
+
those approvals. Only after the rebuild succeeds is the completed prefix moved under
|
|
457
|
+
the global npm prefix and its `graft` binary linked globally. This prevents unrelated
|
|
458
|
+
global packages from entering npm's strict preflight. The allowlist is command-scoped
|
|
459
|
+
and changes with `@latest`; other npm commands keep npm 12's install-script
|
|
460
|
+
protections. The image includes Python 3,
|
|
461
|
+
`make`, and the GCC/G++ toolchain required by node-gyp for Graft and other native
|
|
462
|
+
npm packages; these remain available for reliable package rebuilds. The build then
|
|
463
|
+
verifies `graft --version`. Enable Graft in trusted project or global pi-config
|
|
464
|
+
settings; it reads the project locally and stores its regenerable graph in `./graft/`. Set
|
|
465
|
+
`graft_enable` to `false` and delete that directory to disable and remove the cache.
|
|
466
|
+
Native installations require Node.js 22+, npm 12+, Python 3, `make`, and G++; the
|
|
467
|
+
installer uses the same audited strict installation and failure remains non-fatal.
|
|
468
|
+
|
|
426
469
|
**CLI provider binaries (optional `cli_agents`):** The image installs the CLIs used by
|
|
427
470
|
`cli-*` providers — `claude` (Claude Code), `gemini` (`@google/gemini-cli`), and
|
|
428
471
|
`agent` (Cursor Agent CLI). Enable with `cli_agents` in settings (e.g.
|
|
@@ -689,6 +732,7 @@ PI_PIDIFF_ENABLE=false pi
|
|
|
689
732
|
| `acpx` | Agent proxy for remote models |
|
|
690
733
|
| `kubectl` / `oc` | Kubernetes and OpenShift CLI |
|
|
691
734
|
| `agent-browser` | Browser automation CLI (navigate, click, screenshot, forms) |
|
|
735
|
+
| `graft` | Local repository code graph for opt-in `graft_enable`; graph cache is stored in the mounted project’s `./graft/` directory |
|
|
692
736
|
| `procps` | Process utilities (ps, top, pgrep, pkill) |
|
|
693
737
|
| `passwd` | `usermod`/`groupmod` — init remaps user `node` to `PI_HOST_UID`/`PI_HOST_GID` |
|
|
694
738
|
| `docker` / `podman` | Container CLIs (used via `docker-safe` read-only wrapper) |
|
package/agents/test-runner.md
CHANGED
|
@@ -32,7 +32,7 @@ You are a specialized test execution agent. Run tests and provide concise failur
|
|
|
32
32
|
Failed Test 1: test_name (file:line)
|
|
33
33
|
Expected: [brief description]
|
|
34
34
|
Actual: [brief description]
|
|
35
|
-
Fix location: path/to/file
|
|
35
|
+
Fix location: path/to/file:line
|
|
36
36
|
Suggested approach: [one line]
|
|
37
37
|
|
|
38
38
|
Returning control for fixes.
|
|
@@ -4,13 +4,16 @@ import { createRuntimeProvider } from "../shared/create-runtime-provider.js";
|
|
|
4
4
|
import { createLogger } from "../shared/logger.js";
|
|
5
5
|
import {
|
|
6
6
|
OpenAiCompatibleDiscoveryCache,
|
|
7
|
+
buildLiteLlmCapabilitiesUrl,
|
|
7
8
|
buildOpenAiCompatibleModelsRequest,
|
|
9
|
+
enrichLiteLlmReasoning,
|
|
8
10
|
findEligibleOpenAiCompatibleProviderConfigsResult,
|
|
9
11
|
formatOpenAiCompatibleDiscoverySummary,
|
|
10
12
|
materializeOpenAiCompatibleModels,
|
|
11
13
|
openAiCompatibleConnectionFingerprint,
|
|
12
14
|
redactOpenAiCompatibleDiagnostic,
|
|
13
15
|
resolveStaticOpenAiCompatibleHeaders,
|
|
16
|
+
type LiteLlmCapabilityRecord,
|
|
14
17
|
type OpenAiCompatibleModelRecord,
|
|
15
18
|
type ResolvedOpenAiCompatibleConnection,
|
|
16
19
|
} from "../shared/openai-compatible-discovery.js";
|
|
@@ -28,6 +31,18 @@ function responseRecords(payload: unknown): OpenAiCompatibleModelRecord[] {
|
|
|
28
31
|
});
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function capabilityRecords(payload: unknown): LiteLlmCapabilityRecord[] {
|
|
35
|
+
if (!payload || typeof payload !== "object" || !Array.isArray((payload as { data?: unknown }).data))
|
|
36
|
+
throw new Error("LiteLLM capability response must contain a data array");
|
|
37
|
+
return (payload as { data: unknown[] }).data.filter(
|
|
38
|
+
(record): record is LiteLlmCapabilityRecord => Boolean(record) && typeof record === "object" && !Array.isArray(record),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLiteLlmProvider(sourceProviderId: string, source: Provider): boolean {
|
|
43
|
+
return `${sourceProviderId} ${source.name ?? ""}`.toLowerCase().includes("litellm");
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
function resolvedConnection(source: Provider, auth?: AuthResult, staticHeaders?: Record<string, string>): ResolvedOpenAiCompatibleConnection {
|
|
32
47
|
return {
|
|
33
48
|
baseUrl: auth?.auth.baseUrl ?? source.baseUrl,
|
|
@@ -84,7 +99,20 @@ async function discoverProvider(
|
|
|
84
99
|
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
85
100
|
});
|
|
86
101
|
if (!response.ok) throw new Error(`OpenAI-compatible /v1/models returned HTTP ${response.status}`);
|
|
87
|
-
|
|
102
|
+
const records = responseRecords(await response.json());
|
|
103
|
+
if (!isLiteLlmProvider(sourceProviderId, source)) return records;
|
|
104
|
+
try {
|
|
105
|
+
const capabilityResponse = await fetch(buildLiteLlmCapabilitiesUrl(request.url), {
|
|
106
|
+
headers: request.headers,
|
|
107
|
+
redirect: "error",
|
|
108
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
109
|
+
});
|
|
110
|
+
if (!capabilityResponse.ok) throw new Error(`LiteLLM capability endpoint returned HTTP ${capabilityResponse.status}`);
|
|
111
|
+
return enrichLiteLlmReasoning(records, capabilityRecords(await capabilityResponse.json()));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log.warn(`${sourceProviderId}: reasoning capability enrichment unavailable`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
|
|
114
|
+
return records;
|
|
115
|
+
}
|
|
88
116
|
} catch (error) {
|
|
89
117
|
log.warn(`${sourceProviderId}: discovery refresh failed`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
|
|
90
118
|
throw error;
|
|
@@ -15,8 +15,9 @@ const require = createRequire(import.meta.url);
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
|
|
17
17
|
import { resolveAgentModelProvider } from "./resolve-agent-model.js";
|
|
18
|
+
import { withGraftTools } from "./graft.js";
|
|
18
19
|
import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
|
|
19
|
-
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState
|
|
20
|
+
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState } from "./pi-config-review-state.js";
|
|
20
21
|
import {
|
|
21
22
|
getMainBranch,
|
|
22
23
|
} from "./git-helpers.js";
|
|
@@ -337,9 +338,6 @@ export function registerAsyncAgents(
|
|
|
337
338
|
const findings = countFindings(typeof data.output === "string" ? data.output : "");
|
|
338
339
|
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { zombieSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
339
340
|
}
|
|
340
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
341
|
-
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { zombieSideEffectsOk = false; log.error(`markTests failed for ${job.agent}: ${e?.message}`); }
|
|
342
|
-
}
|
|
343
341
|
if (zombieSideEffectsOk) job.sideEffectsApplied = true;
|
|
344
342
|
try { fs.unlinkSync(resultFilePath); } catch {}
|
|
345
343
|
} catch {
|
|
@@ -405,9 +403,6 @@ export function registerAsyncAgents(
|
|
|
405
403
|
const findings = countFindings(typeof job.output === "string" ? job.output : "");
|
|
406
404
|
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
407
405
|
}
|
|
408
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
409
|
-
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: markTests failed for ${job.agent}: ${e?.message}`); }
|
|
410
|
-
}
|
|
411
406
|
if (sideEffectsOk) job.sideEffectsApplied = true;
|
|
412
407
|
}
|
|
413
408
|
// Delivery: retry for groups where all members are done
|
|
@@ -548,18 +543,6 @@ export function registerAsyncAgents(
|
|
|
548
543
|
} catch (e: any) { j.sideEffectsApplied = false; log.error(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); continue; }
|
|
549
544
|
}
|
|
550
545
|
|
|
551
|
-
// Track test agent completions for grouped test-automator/test-runner
|
|
552
|
-
for (const j of groupJobs) {
|
|
553
|
-
if (j.agent !== "test-automator" && j.agent !== "test-runner") continue;
|
|
554
|
-
try {
|
|
555
|
-
if (j.status === "complete") {
|
|
556
|
-
markTestsPassed(jobCwd(j));
|
|
557
|
-
} else if (j.status === "failed") {
|
|
558
|
-
markTestsFailed(jobCwd(j));
|
|
559
|
-
}
|
|
560
|
-
} catch (e: any) { j.sideEffectsApplied = false; log.error(`markTests failed for ${j.agent}: ${e?.message}`); continue; }
|
|
561
|
-
}
|
|
562
|
-
|
|
563
546
|
// Mark side-effects applied per-job (only if not already marked false by a failed side-effect above)
|
|
564
547
|
for (const j of groupJobs) {
|
|
565
548
|
if (j.sideEffectsApplied !== false) j.sideEffectsApplied = true;
|
|
@@ -738,17 +721,6 @@ export function registerAsyncAgents(
|
|
|
738
721
|
}
|
|
739
722
|
}
|
|
740
723
|
|
|
741
|
-
// Track test agent completion for review state test tracking
|
|
742
|
-
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
743
|
-
try {
|
|
744
|
-
if (data.success) {
|
|
745
|
-
markTestsPassed(jobCwd(job));
|
|
746
|
-
} else {
|
|
747
|
-
markTestsFailed(jobCwd(job));
|
|
748
|
-
}
|
|
749
|
-
} catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
|
|
750
|
-
}
|
|
751
|
-
|
|
752
724
|
if (processResultSideEffectsOk) job.sideEffectsApplied = true;
|
|
753
725
|
|
|
754
726
|
// Always delete result file after ingestion — data is in memory (job.output).
|
|
@@ -953,7 +925,8 @@ export function registerAsyncAgents(
|
|
|
953
925
|
piArgs.push("--session-id", deterministicSessionId);
|
|
954
926
|
if (effectiveModel) piArgs.push("--model", effectiveModel);
|
|
955
927
|
if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
|
|
956
|
-
|
|
928
|
+
const tools = withGraftTools(agent.tools, getSetting(cwd, "graft_enable"));
|
|
929
|
+
if (tools?.length) piArgs.push("--tools", tools.join(","));
|
|
957
930
|
|
|
958
931
|
if (agent.systemPrompt?.trim()) {
|
|
959
932
|
const promptPath = path.join(workerDir, "system-prompt.md");
|
|
@@ -812,7 +812,7 @@ function bodyAlreadySigned(text: string): boolean {
|
|
|
812
812
|
|
|
813
813
|
/** Unresolved model placeholders are stale template footers, not signatures. */
|
|
814
814
|
function replaceUnresolvedModelFooters(payload: string): string {
|
|
815
|
-
const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL
|
|
815
|
+
const unresolvedFooter = /(?:\n|\\n){2}---(?:\n|\\n)\*Assisted-by: PI \((?:\$PI_MODEL|\$\{PI_MODEL(?::-unknown)?\})\)\*/g;
|
|
816
816
|
const replaced = payload.replace(unresolvedFooter, "");
|
|
817
817
|
if (replaced !== payload)
|
|
818
818
|
enfLog.debug("injectGhBodySignature replaced unresolved PI_MODEL footer");
|
|
@@ -939,7 +939,9 @@ export function injectGhBodySignature(command: string, signature: string): strin
|
|
|
939
939
|
|
|
940
940
|
/**
|
|
941
941
|
* Detect common test runner commands — require command-start position
|
|
942
|
-
* (after &&, |, ;, or line start) to avoid false positives from install/grep/cat commands.
|
|
942
|
+
* (after &&, |, ;, a newline, or line start) to avoid false positives from install/grep/cat commands.
|
|
943
|
+
* `pre-commit` counts only when its `run` subcommand is executed; setup and information
|
|
944
|
+
* commands must not mark tests as passed.
|
|
943
945
|
* NOTE: For compound commands (e.g., pytest && other_cmd), if the non-test part fails,
|
|
944
946
|
* isError=true marks tests as failed even though pytest passed. This is the conservative/safe
|
|
945
947
|
* direction — re-run the test command standalone to mark tests_passed.
|
|
@@ -947,11 +949,14 @@ export function injectGhBodySignature(command: string, signature: string): strin
|
|
|
947
949
|
* match — we exclude tox with explicit -e to avoid marking lint/docs runs as test passes.
|
|
948
950
|
*/
|
|
949
951
|
export function isTestRunnerCommand(command: string): boolean {
|
|
950
|
-
|
|
952
|
+
const result = /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?(?:pytest|vitest|jest|mocha)\b/.test(command)
|
|
951
953
|
|| /(?:^|[;&|]\s*)(?:uv\s+run\s+(?:--\S+(?:\s+\S+)?\s+)*)?tox\b(?!\s*-e)(?!\s+--(?:help|version|list))/.test(command)
|
|
952
954
|
|| /(?:^|[;&|]\s*)go\s+test\b/.test(command)
|
|
953
955
|
|| /(?:^|[;&|]\s*)npm\s+test\b/.test(command)
|
|
954
|
-
|| /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command)
|
|
956
|
+
|| /(?:^|[;&|]\s*)npx\s+tsx\s+--test\b/.test(command)
|
|
957
|
+
|| /(?:^|[;&|\n]\s*)pre-commit\s+run\b/.test(command);
|
|
958
|
+
enfLog.debug("isTestRunnerCommand", result);
|
|
959
|
+
return result;
|
|
955
960
|
}
|
|
956
961
|
|
|
957
962
|
/** True for release bump branches: chore/bump-version-<digit>... */
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/** Opt-in, local Graft graph integration. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmodSync, closeSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { createLogger } from "../shared/logger.js";
|
|
10
|
+
import { isPiOneshotInvocation } from "../shared/oneshot.js";
|
|
11
|
+
import { getSetting } from "./project-settings.js";
|
|
12
|
+
import { clearSlot, setSlot } from "./status-bar.js";
|
|
13
|
+
import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
|
|
14
|
+
import { formatCompactTotal } from "../shared/format-total.js";
|
|
15
|
+
|
|
16
|
+
const exec = promisify(execFile);
|
|
17
|
+
const log = createLogger("graft");
|
|
18
|
+
const TIMEOUT_MS = 45_000;
|
|
19
|
+
const MAX_BUFFER = 64 * 1024;
|
|
20
|
+
const MIN_PROMPT_CHARS = 12;
|
|
21
|
+
const INTERACTIVE_TIMEOUT_MS = 2_000;
|
|
22
|
+
export const GRAFT_QUERY_TOOLS = ["graft_find_code", "graft_find_all", "graft_file_api", "graft_trace_calls", "graft_repo_map"] as const;
|
|
23
|
+
export function withGraftTools(tools: readonly string[] | undefined, enabled: boolean): string[] | undefined {
|
|
24
|
+
if (!tools?.length || !enabled) return tools ? [...tools] : undefined;
|
|
25
|
+
return [...new Set([...tools, ...GRAFT_QUERY_TOOLS])];
|
|
26
|
+
}
|
|
27
|
+
export type GraftState = "syncing" | "ready" | "stale" | "failed";
|
|
28
|
+
export type GraftRun = (args: readonly string[], options: { cwd: string; timeoutMs?: number }) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
29
|
+
type GraphStatus = "fresh" | "stale" | "absent";
|
|
30
|
+
type Options = { enabled: boolean; setting?: (cwd: string) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus>; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; run?: GraftRun; interactiveTimeoutMs?: number };
|
|
31
|
+
type StatusTheme = { fg: (color: string, value: string) => string };
|
|
32
|
+
type Hit = { title?: string; pointer?: string; snippet?: string };
|
|
33
|
+
type Ask = { coverage?: number; coverageStrong?: number; hits?: Hit[] };
|
|
34
|
+
|
|
35
|
+
/** Graft's statusline shape, adapted to Pi's theme API. */
|
|
36
|
+
export function formatGraftFooter({ state, nodeCount, tokenSavings }: { state: GraftState; nodeCount: number; tokenSavings: number }, theme?: StatusTheme): string {
|
|
37
|
+
const fg = (color: string, value: string) => theme?.fg(color, value) ?? value;
|
|
38
|
+
const freshness = state === "syncing" ? fg("warning", "syncing…") : state === "stale" ? fg("warning", "⚠ stale") : state === "failed" ? fg("error", "failed") : fg("success", "✓ synced");
|
|
39
|
+
const parts = [fg("dim", "◤ ") + fg("accent", "graft"), fg("dim", `${nodeCount} nodes`), freshness];
|
|
40
|
+
if (tokenSavings > 0) parts.push(fg("success", `~${formatCompactTotal(tokenSavings)} tok saved`));
|
|
41
|
+
return parts.join(fg("dim", " · "));
|
|
42
|
+
}
|
|
43
|
+
function text(value: unknown, max = 500): string | null { return typeof value === "string" && value.trim() && value.length <= max && !/[\0\r\n]/.test(value) ? value.trim() : null; }
|
|
44
|
+
function projectPath(root: string, value: unknown): string | null {
|
|
45
|
+
const file = text(value); if (!file || isAbsolute(file)) return null;
|
|
46
|
+
const absolute = resolve(root, file); if (relative(root, absolute).startsWith("..")) return null;
|
|
47
|
+
try { const realRoot = realpathSync(root); const realFile = realpathSync(absolute); return realFile === realRoot || realFile.startsWith(realRoot + sep) ? file : null; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
function wiring(root: string): any | null { try { return JSON.parse(readFileSync(resolve(root, "graft/.graph/wiring.json"), "utf8")); } catch { return null; } }
|
|
50
|
+
function nodes(root: string): number { const graph = wiring(root); return Number.isSafeInteger(graph?.meta?.nodeCount) ? graph.meta.nodeCount : graph?.nodes?.length ?? 0; }
|
|
51
|
+
function savedTokens(output: string): number { return [...output.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)].reduce((total, match) => total + (Number(match[1].replaceAll(",", "")) || 0), 0); }
|
|
52
|
+
function savingsStore(ctx: any, root: string): string {
|
|
53
|
+
let id: unknown = process.env.PI_SUBAGENT_CHILD === "1" ? process.env.__PI_PARENT_SESSION_ID : undefined;
|
|
54
|
+
const source = id ? "parent" : "session";
|
|
55
|
+
try { id ||= ctx.sessionManager?.getSessionId?.(); } catch { log.debug("savings_store_resolved", { source, available: false }); return ""; }
|
|
56
|
+
if (typeof id !== "string" || !id) { log.debug("savings_store_resolved", { source, available: false }); return ""; }
|
|
57
|
+
const dir = getProjectTmpDir(root); chmodSync(dir, 0o700);
|
|
58
|
+
log.debug("savings_store_resolved", { source, available: true });
|
|
59
|
+
return join(dir, `graft-savings-${createHash("sha256").update(id).digest("hex")}.json`);
|
|
60
|
+
}
|
|
61
|
+
function validSavings(value: unknown): number { return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : 0; }
|
|
62
|
+
function readSavings(file: string): number {
|
|
63
|
+
if (!file) return 0;
|
|
64
|
+
try { return validSavings(JSON.parse(readFileSync(file, "utf8"))?.tokenSavings); } catch { return 0; }
|
|
65
|
+
}
|
|
66
|
+
function writeSavings(file: string, delta: number): number {
|
|
67
|
+
if (!file || !delta) return readSavings(file);
|
|
68
|
+
const lock = `${file}.lock`; const owner = { pid: process.pid, token: randomUUID() }; const ownerTmp = `${lock}.${owner.token}.tmp`; const valueTmp = `${file}.${owner.token}.tmp`;
|
|
69
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
70
|
+
let acquired = false;
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(ownerTmp, JSON.stringify(owner), { mode: 0o600, flag: "wx" }); linkSync(ownerTmp, lock); acquired = true; unlinkSync(ownerTmp);
|
|
73
|
+
const total = readSavings(file) + delta;
|
|
74
|
+
writeFileSync(valueTmp, JSON.stringify({ tokenSavings: total }), { mode: 0o600 }); renameSync(valueTmp, file);
|
|
75
|
+
return total;
|
|
76
|
+
} catch (error: any) {
|
|
77
|
+
if (error?.code !== "EEXIST") { log.warn("savings_persist_failed", { code: error?.code }); return readSavings(file); }
|
|
78
|
+
const existing = lockOwner(lock);
|
|
79
|
+
if (existing && !ownerAlive(existing)) {
|
|
80
|
+
const claim = `${lock}.claim-${owner.token}`;
|
|
81
|
+
try { renameSync(lock, claim); if (lockOwner(claim)?.token === existing.token && !ownerAlive(existing)) unlinkSync(claim); else try { renameSync(claim, lock); } catch {} } catch {}
|
|
82
|
+
}
|
|
83
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
|
|
84
|
+
} finally {
|
|
85
|
+
try { unlinkSync(ownerTmp); } catch {}
|
|
86
|
+
try { unlinkSync(valueTmp); } catch {}
|
|
87
|
+
if (acquired) try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
log.warn("savings_persist_failed", { code: "LOCK_BUSY" }); return readSavings(file);
|
|
91
|
+
}
|
|
92
|
+
function toolText(content: unknown): string { return Array.isArray(content) ? content.map((part: any) => typeof part?.text === "string" ? part.text : "").join("\n") : ""; }
|
|
93
|
+
export function classifyToolUse(name: unknown, input: any): "graft" | "source" | null {
|
|
94
|
+
const tool = typeof name === "string" ? name.toLowerCase() : "";
|
|
95
|
+
if (tool.startsWith("graft_")) return "graft";
|
|
96
|
+
if (["read", "grep", "find", "ls"].includes(tool)) return "source";
|
|
97
|
+
if (["bash", "powershell"].includes(tool) && /(^|[|&;]\s*)(npx\s+(-y\s+)?(@nanonets\/)?\s*)?graft(-dev)?\b/i.test(String(input?.command ?? ""))) return "graft";
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function forceUseGuidance(stale = false): string { return `[graft] This trusted project has a ${stale ? "stale but usable" : "ready"} local Graft graph. Use Graft before raw grep/read: graft_find_code for where/how, graft_trace_calls for callers/callees or blast radius, graft_file_api for a file API, graft_repo_map for orientation, and graft_find_all only when every occurrence is required. Use raw tools only to inspect a Graft result or when Graft has no answer.${stale ? " The owner is rebuilding the graph; verify pointers against source." : ""}`; }
|
|
101
|
+
function substantive(query: string): boolean { return query.length >= MIN_PROMPT_CHARS && !query.startsWith("/"); }
|
|
102
|
+
function pointerPath(pointer: string): string | null { const match = /^(.+?):\d+(?::\d+)?$/.exec(pointer.trim()); return match?.[1] ?? null; }
|
|
103
|
+
function navigationTool(event: any): boolean {
|
|
104
|
+
const tool = String(event?.toolName ?? event?.name ?? "").toLowerCase();
|
|
105
|
+
if (["grep", "find", "glob", "search"].includes(tool)) return true;
|
|
106
|
+
if (tool !== "bash" && tool !== "powershell") return false;
|
|
107
|
+
return /(^|[|;&]\s*)(?:\S+=\S+\s+)*(?:rg|grep|find|ag|ack)\b/i.test(String(event?.input?.command ?? ""));
|
|
108
|
+
}
|
|
109
|
+
function graftGateReason(): string { return "[graft] Project navigation is blocked until Graft retrieval runs for this turn. Use graft_find_code (or graft_repo_map/graft_trace_calls), then inspect its returned file pointers. If Graft has no relevant result, retry the raw navigation."; }
|
|
110
|
+
function diagnostic(stderr: string): string { return text(stderr.split(/\r?\n/, 1)[0], 240) ?? "no diagnostic output"; }
|
|
111
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
112
|
+
let timer: NodeJS.Timeout | undefined; let timedOut = false;
|
|
113
|
+
log.debug("retrieval_timeout_setup", { timeoutMs });
|
|
114
|
+
try { return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => { timedOut = true; log.warn("retrieval_timeout", { timeoutMs }); reject(new Error("automatic retrieval timed out")); }, timeoutMs); })]); }
|
|
115
|
+
finally { if (timer) clearTimeout(timer); log.debug("retrieval_timeout_cleanup", { timeoutMs, timedOut }); }
|
|
116
|
+
}
|
|
117
|
+
type LockOwner = { pid: number; token: string };
|
|
118
|
+
function lockOwner(lock: string): LockOwner | null { try { const raw = readFileSync(lock, "utf8"); const legacyPid = Number(raw); if (Number.isSafeInteger(legacyPid)) return { pid: legacyPid, token: `legacy-${legacyPid}` }; const owner = JSON.parse(raw); return Number.isSafeInteger(owner?.pid) && typeof owner?.token === "string" ? owner : null; } catch { return null; } }
|
|
119
|
+
function ownerAlive(owner: LockOwner | null): boolean { if (!owner) return false; try { process.kill(owner.pid, 0); return true; } catch (error: any) { return error?.code === "EPERM"; } }
|
|
120
|
+
export function acquireBuildLock(root: string, afterClaim?: () => void): (() => void) | null {
|
|
121
|
+
const lock = resolve(root, "graft/.graph/pi-build.lock"); const claim = `${lock}.claim`; const owner = { pid: process.pid, token: randomUUID() };
|
|
122
|
+
try { mkdirSync(dirname(lock), { recursive: true }); } catch { log.warn("build_lock_unavailable", { root }); return null; }
|
|
123
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
124
|
+
const claimant = lockOwner(claim);
|
|
125
|
+
if (claimant) { if (ownerAlive(claimant)) return null; try { unlinkSync(claim); } catch { return null; } }
|
|
126
|
+
try {
|
|
127
|
+
const fd = openSync(lock, "wx", 0o600); writeFileSync(fd, JSON.stringify(owner)); closeSync(fd);
|
|
128
|
+
if (lockOwner(claim)) { try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {} return null; }
|
|
129
|
+
return () => { try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {} };
|
|
130
|
+
} catch (error: any) {
|
|
131
|
+
if (error?.code !== "EEXIST") return null;
|
|
132
|
+
try {
|
|
133
|
+
renameSync(lock, claim);
|
|
134
|
+
const claimedOwner = lockOwner(claim);
|
|
135
|
+
if (ownerAlive(claimedOwner)) { try { renameSync(claim, lock); } catch {} return null; }
|
|
136
|
+
afterClaim?.(); unlinkSync(claim);
|
|
137
|
+
} catch { return null; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
function trusted(ctx: any): boolean { return typeof ctx?.isProjectTrusted !== "function" || ctx.isProjectTrusted() === true; }
|
|
143
|
+
function relevant(ask: Ask): Hit[] { const hits = ask.hits?.filter(hit => text(hit.pointer) && text(hit.title)) ?? []; if (!hits.length) return []; if ((typeof ask.coverage === "number" || typeof ask.coverageStrong === "number") && (ask.coverageStrong ?? 0) < .1 && (ask.coverage ?? 0) < .5) return []; return hits.slice(0, 3); }
|
|
144
|
+
function pointerText(hits: Hit[]): string { return hits.map((hit, i) => { const snippet = text(hit.snippet, 140); return ` ${i + 1}. ${text(hit.title)!}: ${text(hit.pointer)!}${snippet ? `\n ${snippet.replace(/\s+/g, " ")}` : ""}`; }).join("\n"); }
|
|
145
|
+
function blastHint(root: string, path: unknown): string | null {
|
|
146
|
+
const file = typeof path === "string" ? path : null; const graph = file && wiring(root); if (!file || !graph) return null;
|
|
147
|
+
const names = new Map((graph.nodes ?? []).map((node: any) => [node.id, node]));
|
|
148
|
+
const ids = new Set((graph.nodes ?? []).filter((node: any) => node.path === file || file.endsWith(`/${node.path}`)).map((node: any) => node.id));
|
|
149
|
+
const dependents = (graph.edges ?? []).filter((edge: any) => ids.has(edge.target) && !ids.has(edge.source)).slice(0, 8).map((edge: any) => { const node = names.get(edge.source); return ` • ${edge.relation} ← ${node ? `${node.name} (${basename(node.path)})` : edge.source}`; });
|
|
150
|
+
return dependents.length ? `[graft] blast radius for ${basename(file)}, who depends on it:\n${dependents.join("\n")}` : null;
|
|
151
|
+
}
|
|
152
|
+
export function isEligibleGraftStartup(event: any, ctx: any): boolean { return event?.reason === "startup" && ctx?.mode !== "print" && ctx?.mode !== "json" && process.env.PI_SUBAGENT_CHILD !== "1" && !isPiOneshotInvocation(); }
|
|
153
|
+
|
|
154
|
+
export function createGraftIntegration(options: Options) {
|
|
155
|
+
const child = process.env.PI_SUBAGENT_CHILD === "1";
|
|
156
|
+
const state = { generation: 0, root: "", store: "", dirtySignal: "", enabled: false, events: undefined as ExtensionAPI["events"] | undefined, value: "stale" as GraftState, nodeCount: 0, tokenSavings: 0, graftReads: 0, sourceReads: 0, dirty: false, refresh: undefined as Promise<boolean> | undefined, ctx: undefined as any, gate: { active: false, retrieved: false, pointers: new Set<string>() } };
|
|
157
|
+
const publishSavings = () => {
|
|
158
|
+
if (child || !state.events) return;
|
|
159
|
+
state.events.emit("pidash:graft-savings", { tokenSavings: state.tokenSavings });
|
|
160
|
+
log.debug("savings_published", { tokenSavings: state.tokenSavings });
|
|
161
|
+
};
|
|
162
|
+
const setState = (value: GraftState) => { state.value = value; setSlot("graft", formatGraftFooter({ state: value, nodeCount: state.nodeCount, tokenSavings: state.tokenSavings }, state.ctx?.ui?.theme), state.ctx); publishSavings(); };
|
|
163
|
+
const addSavings = (amount: number) => { if (!amount) return; state.tokenSavings = state.store ? writeSavings(state.store, amount) : state.tokenSavings + amount; };
|
|
164
|
+
const rawRun: GraftRun = options.run ?? (async (args, { cwd, timeoutMs }) => { try { const result = await exec("graft", args as string[], { cwd, timeout: timeoutMs ?? TIMEOUT_MS, maxBuffer: MAX_BUFFER, env: { ...process.env, DO_NOT_TRACK: "1", npm_config_offline: "true" } }); return { stdout: String(result.stdout).slice(0, MAX_BUFFER), stderr: String(result.stderr).slice(0, MAX_BUFFER), code: 0 }; } catch (error: any) { return { stdout: String(error?.stdout ?? "").slice(0, MAX_BUFFER), stderr: String(error?.stderr || error?.message || "").slice(0, MAX_BUFFER), code: Number(error?.code) || 1 }; } });
|
|
165
|
+
const run: GraftRun = async (args, options) => {
|
|
166
|
+
const started = Date.now();
|
|
167
|
+
// Do not log ask arguments: they contain the user's prompt.
|
|
168
|
+
log.info("subprocess_start", { command: args[0], argCount: args.length, cwd: options.cwd });
|
|
169
|
+
const result = await rawRun(args, options);
|
|
170
|
+
const timing = { command: args[0], argCount: args.length, code: result.code, elapsedMs: Date.now() - started, stdoutBytes: result.stdout.length, stderrBytes: result.stderr.length };
|
|
171
|
+
if (result.code) log.warn("subprocess_end", timing); else log.info("subprocess_end", timing);
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
const available = options.executable ?? (async () => (await run(["--version"], { cwd: state.root })).code === 0);
|
|
175
|
+
const graph = options.graph ?? (async cwd => (await run(["check", "--json"], { cwd })).code === 0 ? "fresh" : nodes(cwd) ? "stale" : "absent");
|
|
176
|
+
const refresh = async (): Promise<boolean> => {
|
|
177
|
+
if (child) return false;
|
|
178
|
+
if (state.refresh) { log.info("refresh_join", { dirty: state.dirty }); return state.refresh; }
|
|
179
|
+
const generation = state.generation;
|
|
180
|
+
const root = state.root;
|
|
181
|
+
const release = acquireBuildLock(root);
|
|
182
|
+
if (!release) { log.info("refresh_locked", { root }); return false; }
|
|
183
|
+
const started = Date.now();
|
|
184
|
+
log.info("refresh_start", { root, dirty: state.dirty });
|
|
185
|
+
setState(state.value === "stale" || state.nodeCount ? "stale" : "syncing");
|
|
186
|
+
let pending!: Promise<boolean>;
|
|
187
|
+
pending = (async () => {
|
|
188
|
+
const result = await run(["build"], { cwd: root });
|
|
189
|
+
const nodeCount = nodes(root);
|
|
190
|
+
if (state.generation !== generation || state.root !== root) {
|
|
191
|
+
log.info("refresh_end_stale", { root, ok: result.code === 0, nodes: nodeCount, elapsedMs: Date.now() - started });
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
state.nodeCount = nodeCount; state.dirty = result.code !== 0;
|
|
195
|
+
if (!result.code && state.dirtySignal) try { for (const signal of readdirSync(state.dirtySignal)) try { unlinkSync(join(state.dirtySignal, signal)); } catch {} } catch {}
|
|
196
|
+
setState(result.code === 0 ? "ready" : state.nodeCount ? "stale" : "failed");
|
|
197
|
+
log.info("refresh_end", { ok: result.code === 0, nodes: state.nodeCount, elapsedMs: Date.now() - started });
|
|
198
|
+
return result.code === 0;
|
|
199
|
+
})().finally(() => { release(); if (state.refresh === pending) state.refresh = undefined; });
|
|
200
|
+
state.refresh = pending;
|
|
201
|
+
return pending;
|
|
202
|
+
};
|
|
203
|
+
const markDirty = (source: string) => { if (state.dirty) { log.debug("graph_dirty_repeat", { source }); return; } state.dirty = true; setState("stale"); log.info("graph_dirty", { source }); };
|
|
204
|
+
const consumeDirtySignals = () => {
|
|
205
|
+
if (child || !state.dirtySignal) return;
|
|
206
|
+
try { const signals = readdirSync(state.dirtySignal); if (!signals.length) return; markDirty("child"); log.info("child_dirty_consumed", { count: signals.length }); } catch {}
|
|
207
|
+
};
|
|
208
|
+
const invalidate = (ctx: any, reason: string) => {
|
|
209
|
+
state.generation++; state.ctx = ctx; state.enabled = false; state.root = ""; state.store = ""; state.dirtySignal = ""; state.refresh = undefined; state.dirty = false; state.nodeCount = 0; state.tokenSavings = 0; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
|
|
210
|
+
clearSlot("graft", ctx); publishSavings(); log.info("session_invalidated", { reason });
|
|
211
|
+
};
|
|
212
|
+
const invoke = async (args: string[]) => {
|
|
213
|
+
log.info("tool_invoke", { command: args[0], enabled: state.enabled, dirty: state.dirty }); if (!state.enabled || !state.root || !trusted(state.ctx) || resolveWorktreeRoot(state.ctx.cwd) !== state.root) return "Graft is disabled or untrusted for this project.";
|
|
214
|
+
if (args[0] === "build") return await refresh() ? "Graft graph rebuilt." : "Graft rebuild was skipped or failed; the existing graph remains available when present.";
|
|
215
|
+
let result = await run(args, { cwd: state.root });
|
|
216
|
+
const scoped = args.indexOf("--in");
|
|
217
|
+
if (result.code && scoped >= 0) { const retry = [...args]; retry.splice(scoped, 2); log.info("scope_retry_root", { command: args[0] }); result = await run(retry, { cwd: state.root }); }
|
|
218
|
+
if (result.code) return `Graft ${args[0]} failed: ${diagnostic(result.stderr)}`;
|
|
219
|
+
state.nodeCount = nodes(state.root); setState(state.dirty || state.value === "stale" ? "stale" : "ready"); return result.stdout.slice(0, MAX_BUFFER);
|
|
220
|
+
};
|
|
221
|
+
const register = (pi: ExtensionAPI) => {
|
|
222
|
+
if (!options.enabled) return;
|
|
223
|
+
state.events = pi.events;
|
|
224
|
+
let toolsRegistered = false;
|
|
225
|
+
const registerTools = () => { if (toolsRegistered) return; toolsRegistered = true; const tool = (name: string, description: string, parameters: any, args: (params: any) => string[] | null) => pi.registerTool({ name, label: name.replace("graft_", "Graft ").replace(/_/g, " "), description, parameters, async execute(_id, params) { const command = args(params); return { content: [{ type: "text" as const, text: command ? await invoke(command) : "Invalid input: use an existing project-relative path." }], details: {} }; } });
|
|
226
|
+
tool("graft_find_code", "Find compact ranked code references in the local Graft graph.", Type.Object({ query: Type.String(), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })), path: Type.Optional(Type.String()) }), p => { const q = text(p.query), file = p.path === undefined ? undefined : projectPath(state.root, p.path); return q && (p.path === undefined || file) ? ["ask", q, ".", "--limit", String(p.limit ?? 8), ...(file ? ["--in", file] : [])] : null; });
|
|
227
|
+
tool("graft_find_all", "Search indexed project files.", Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), fixed: Type.Optional(Type.Boolean()) }), p => { const pattern = text(p.pattern), file = p.path === undefined ? undefined : projectPath(state.root, p.path); return pattern && (p.path === undefined || file) ? ["grep", ...(p.fixed ? ["--fixed"] : []), ...(file ? ["--in", file] : []), pattern] : null; });
|
|
228
|
+
tool("graft_file_api", "Get a signatures-only API view for a project file.", Type.Object({ path: Type.String() }), p => { const file = projectPath(state.root, p.path); return file ? ["skeleton", file] : null; });
|
|
229
|
+
tool("graft_trace_calls", "Trace callers or callees of a symbol.", Type.Object({ symbol: Type.String(), direction: Type.Optional(Type.Union([Type.Literal("in"), Type.Literal("out")])), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })) }), p => { const symbol = text(p.symbol); return symbol ? ["callers", "--direction", p.direction ?? "in", "--depth", String(p.depth ?? 1), symbol] : null; });
|
|
230
|
+
tool("graft_repo_map", "Show a compact graph-based repository map.", Type.Object({ max_dirs: Type.Optional(Type.Integer({ minimum: 1, maximum: 30 })) }), p => ["map", "--max-dirs", String(p.max_dirs ?? 16)]);
|
|
231
|
+
if (!child) { tool("graft_refresh", "Rebuild the local Graft graph.", Type.Object({}), () => ["build"]); tool("graft_blast_radius", "Find dependencies affected by the local diff.", Type.Object({ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })) }), p => ["blast", "--depth", String(p.depth ?? 2)]); }
|
|
232
|
+
};
|
|
233
|
+
pi.on("session_start", async (event: any, ctx: any) => {
|
|
234
|
+
state.ctx = ctx; log.info("session_start_enter", { reason: event?.reason, mode: ctx?.mode, trusted: trusted(ctx) });
|
|
235
|
+
if (!trusted(ctx)) { invalidate(ctx, "untrusted"); log.info("session_start_exit", { decision: "untrusted" }); return; }
|
|
236
|
+
const enabled = options.setting?.(ctx.cwd) ?? options.enabled;
|
|
237
|
+
if (!enabled) { invalidate(ctx, "disabled"); log.info("session_start_exit", { decision: "disabled" }); return; }
|
|
238
|
+
registerTools(); const generation = ++state.generation; state.enabled = true; state.refresh = undefined; state.dirty = false; state.root = resolveWorktreeRoot(ctx.cwd); const root = state.root; state.nodeCount = nodes(root); state.value = "failed"; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
|
|
239
|
+
try { state.store = savingsStore(ctx, root); state.dirtySignal = state.store ? `${state.store}.dirty` : ""; if (state.dirtySignal) { mkdirSync(state.dirtySignal, { recursive: true, mode: 0o700 }); chmodSync(state.dirtySignal, 0o700); } state.tokenSavings = readSavings(state.store); } catch (error: any) { state.store = ""; state.dirtySignal = ""; state.tokenSavings = 0; log.warn("savings_restore_failed", { code: error?.code }); }
|
|
240
|
+
setState("failed");
|
|
241
|
+
if (!child && !isEligibleGraftStartup(event, ctx) && !["new", "reload", "resume", "fork"].includes(event?.reason)) { log.info("session_start_exit", { decision: "ineligible" }); return; }
|
|
242
|
+
if (!child) { const executable = await available(); if (state.generation !== generation || state.root !== root) return; if (!executable) { setState("failed"); log.info("session_start_exit", { decision: "unavailable" }); return; } }
|
|
243
|
+
const status = await graph(root);
|
|
244
|
+
if (state.generation !== generation || state.root !== root) return;
|
|
245
|
+
if (status === "fresh") setState("ready");
|
|
246
|
+
else { setState(state.nodeCount ? "stale" : "failed"); if (!child) void refresh(); }
|
|
247
|
+
log.info("session_start_exit", { decision: status, nodes: state.nodeCount });
|
|
248
|
+
});
|
|
249
|
+
pi.on("tool_call", (event: any, ctx: any) => {
|
|
250
|
+
const tool = String(event?.toolName ?? event?.name ?? "").toLowerCase();
|
|
251
|
+
const allowedPointerRead = tool === "read" && typeof event?.input?.path === "string" && state.gate.pointers.has(event.input.path.replace(/^\.\//, ""));
|
|
252
|
+
if (!state.enabled || !["ready", "stale"].includes(state.value) || !state.gate.active || state.gate.retrieved || allowedPointerRead || !(navigationTool(event) || tool === "read")) return;
|
|
253
|
+
log.info("navigation_blocked", { tool: event.toolName });
|
|
254
|
+
return { block: true, reason: graftGateReason() };
|
|
255
|
+
});
|
|
256
|
+
pi.on("tool_result", (event: any, ctx: any) => {
|
|
257
|
+
state.ctx = ctx;
|
|
258
|
+
const attemptedGraft = String(event.toolName).toLowerCase().startsWith("graft_");
|
|
259
|
+
if (attemptedGraft) { state.gate.retrieved = true; state.gate.active = false; }
|
|
260
|
+
const kind = !event.isError ? classifyToolUse(event.toolName, event.input) : null;
|
|
261
|
+
const savings = !event.isError ? savedTokens(toolText(event.content)) : 0;
|
|
262
|
+
if (kind === "graft") state.graftReads++; else if (kind === "source") state.sourceReads++;
|
|
263
|
+
addSavings(savings);
|
|
264
|
+
if (kind || savings) { setState(state.dirty || state.value === "stale" ? "stale" : "ready"); log.info("session_metric", { tool: event.toolName, kind, savings, graftReads: state.graftReads, sourceReads: state.sourceReads, tokenSavings: state.tokenSavings }); }
|
|
265
|
+
const eligible = state.enabled && !event.isError && ["write", "edit"].includes(event.toolName);
|
|
266
|
+
log.info("tool_result", { tool: event.toolName, eligible, kind, savings }); if (!eligible) return;
|
|
267
|
+
if (child) { if (!state.dirtySignal) { log.warn("child_dirty_signal_unavailable", { tool: event.toolName }); return; } try { writeFileSync(join(state.dirtySignal, `${process.pid}-${randomUUID()}`), event.toolName, { mode: 0o600, flag: "wx" }); log.info("child_dirty_signaled", { tool: event.toolName }); } catch (error: any) { log.warn("child_dirty_signal_failed", { code: error?.code }); } return; }
|
|
268
|
+
markDirty(event.toolName); const hint = blastHint(state.root, event.input?.path);
|
|
269
|
+
return hint ? { content: [...(event.content ?? []), { type: "text", text: hint }] } : undefined;
|
|
270
|
+
});
|
|
271
|
+
pi.on("before_agent_start", async (event: any, ctx: any) => {
|
|
272
|
+
const started = Date.now(); state.ctx = ctx; const query = text(event?.prompt); const generation = state.generation; const root = state.root;
|
|
273
|
+
state.gate = { active: false, retrieved: false, pointers: new Set<string>() }; const gate = state.gate;
|
|
274
|
+
const current = () => {
|
|
275
|
+
const generationMatches = state.generation === generation; const rootMatches = state.root === root; const gateMatches = state.gate === gate;
|
|
276
|
+
log.debug("retrieval_current", { generationMatches, rootMatches, gateMatches });
|
|
277
|
+
return generationMatches && rootMatches && gateMatches;
|
|
278
|
+
};
|
|
279
|
+
const finish = (decision: string, extra = {}) => log.info("prompt_retrieval_exit", { decision, elapsedMs: Date.now() - started, ...extra });
|
|
280
|
+
log.info("prompt_retrieval_enter", { enabled: state.enabled, trusted: trusted(ctx), dirty: state.dirty, refreshing: Boolean(state.refresh), queryLength: query?.length ?? 0 });
|
|
281
|
+
if (!trusted(ctx) || !state.enabled || !state.root || resolveWorktreeRoot(ctx.cwd) !== state.root) { finish("disabled_or_untrusted"); return; }
|
|
282
|
+
consumeDirtySignals(); state.tokenSavings = Math.max(state.tokenSavings, readSavings(state.store)); setState(state.value);
|
|
283
|
+
if (!["ready", "stale"].includes(state.value)) {
|
|
284
|
+
const status = await graph(root);
|
|
285
|
+
if (!current()) return;
|
|
286
|
+
if (status === "fresh") setState("ready"); else if (status === "stale") setState("stale"); else { finish("graph_absent"); return; }
|
|
287
|
+
}
|
|
288
|
+
const guidance = forceUseGuidance(state.value === "stale");
|
|
289
|
+
if (!query || !substantive(query)) { finish("guidance_only_control_prompt"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` }; }
|
|
290
|
+
state.gate.active = true;
|
|
291
|
+
let hits: Hit[] = [];
|
|
292
|
+
try {
|
|
293
|
+
if (options.retrieve) { const result = await withTimeout(options.retrieve(query, root), options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS); if (!current()) return; hits = result.pointers.map(pointer => ({ title: pointer, pointer })); }
|
|
294
|
+
else { const result = await withTimeout(run(["ask", query, ".", "--json", "-n", "3"], { cwd: root, timeoutMs: options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS }), options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS); if (!current()) return; if (result.code) { state.gate.active = false; finish("ask_failed", { diagnostic: diagnostic(result.stderr) }); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval failed: ${diagnostic(result.stderr)}. Raw navigation is available.` }; } const savings = savedTokens(result.stdout); addSavings(savings); setState(state.value); try { hits = relevant(JSON.parse(result.stdout.replace(/^\[graft\] tokens saved ≈ [\d,]+\s*$/gm, "").trim())); } catch { state.gate.active = false; finish("invalid_ask_json"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval returned malformed output. Raw navigation is available.` }; } }
|
|
295
|
+
} catch (error: any) { if (!current()) return; state.gate.active = false; finish("ask_error"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval failed: ${text(error?.message, 240) ?? "unknown error"}. Raw navigation is available.` }; }
|
|
296
|
+
if (!hits.length) { state.gate.active = false; finish("guidance_only_no_relevant_hits"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` }; }
|
|
297
|
+
hits.forEach(hit => { const path = pointerPath(hit.pointer!); if (path) state.gate.pointers.add(path); });
|
|
298
|
+
state.gate.retrieved = true; finish("injected", { hits: hits.length });
|
|
299
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] starting points for this task:\n${pointerText(hits)}` };
|
|
300
|
+
});
|
|
301
|
+
pi.on("agent_end", (_event: any, ctx: any) => { state.ctx = ctx; consumeDirtySignals(); const shouldRefresh = !child && trusted(ctx) && state.enabled && state.dirty && !state.refresh; log.info("agent_end", { dirty: state.dirty, refreshing: Boolean(state.refresh), shouldRefresh }); if (shouldRefresh) void refresh(); });
|
|
302
|
+
pi.on("session_shutdown", () => { log.info("session_shutdown", { state: state.value, dirty: state.dirty, graftReads: state.graftReads, sourceReads: state.sourceReads, tokenSavings: state.tokenSavings }); clearSlot("graft", state.ctx); });
|
|
303
|
+
};
|
|
304
|
+
return { register };
|
|
305
|
+
}
|
|
306
|
+
export function registerGraft(pi: ExtensionAPI): void { createGraftIntegration({ enabled: true, setting: cwd => getSetting(cwd, "graft_enable") }).register(pi); }
|