pi-orchestrator-config 4.4.0 → 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 +43 -1
- package/extensions/openai-compatible-discovery/index.ts +29 -1
- package/extensions/orchestrator/async-agents.ts +3 -1
- 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/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/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/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 +2 -1
- package/scripts/graft-allow-scripts.mjs +47 -0
- package/scripts/pidash-server.ts +7 -3
- package/scripts/pidash-session-state.ts +10 -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
|
|
|
@@ -404,6 +405,29 @@ Set `image_model` in `pi-config-settings.json` or use `PI_IMAGE_MODEL` env var.
|
|
|
404
405
|
|
|
405
406
|
In containers, images are auto-served via HTTP for browser preview.
|
|
406
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
|
+
|
|
407
431
|
### Cache Miss Notices
|
|
408
432
|
|
|
409
433
|
Enable `showCacheMissNotices` in pi settings to see transcript notices on significant prompt-cache misses — useful for investigating unexpected token costs:
|
|
@@ -425,6 +449,23 @@ Run pi inside a disposable container for **filesystem isolation** — the agent
|
|
|
425
449
|
- **Consistent tooling** — All required tools pre-installed in a single image
|
|
426
450
|
- **Disposable** — Container is destroyed after each session (`--rm`)
|
|
427
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
|
+
|
|
428
469
|
**CLI provider binaries (optional `cli_agents`):** The image installs the CLIs used by
|
|
429
470
|
`cli-*` providers — `claude` (Claude Code), `gemini` (`@google/gemini-cli`), and
|
|
430
471
|
`agent` (Cursor Agent CLI). Enable with `cli_agents` in settings (e.g.
|
|
@@ -691,6 +732,7 @@ PI_PIDIFF_ENABLE=false pi
|
|
|
691
732
|
| `acpx` | Agent proxy for remote models |
|
|
692
733
|
| `kubectl` / `oc` | Kubernetes and OpenShift CLI |
|
|
693
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 |
|
|
694
736
|
| `procps` | Process utilities (ps, top, pgrep, pkill) |
|
|
695
737
|
| `passwd` | `usermod`/`groupmod` — init remaps user `node` to `PI_HOST_UID`/`PI_HOST_GID` |
|
|
696
738
|
| `docker` / `podman` | Container CLIs (used via `docker-safe` read-only wrapper) |
|
|
@@ -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,6 +15,7 @@ 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
20
|
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState } from "./pi-config-review-state.js";
|
|
20
21
|
import {
|
|
@@ -924,7 +925,8 @@ export function registerAsyncAgents(
|
|
|
924
925
|
piArgs.push("--session-id", deterministicSessionId);
|
|
925
926
|
if (effectiveModel) piArgs.push("--model", effectiveModel);
|
|
926
927
|
if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
|
|
927
|
-
|
|
928
|
+
const tools = withGraftTools(agent.tools, getSetting(cwd, "graft_enable"));
|
|
929
|
+
if (tools?.length) piArgs.push("--tools", tools.join(","));
|
|
928
930
|
|
|
929
931
|
if (agent.systemPrompt?.trim()) {
|
|
930
932
|
const promptPath = path.join(workerDir, "system-prompt.md");
|
|
@@ -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); }
|
|
@@ -36,6 +36,7 @@ import { registerReviewUI } from "./review-ui.js";
|
|
|
36
36
|
import { registerSessionSearch } from "./session-search.js";
|
|
37
37
|
import { registerSettingsTui } from "./settings-tui.js";
|
|
38
38
|
import { registerMarkdownTransformer } from "./markdown-transformer.js";
|
|
39
|
+
import { registerGraft } from "./graft.js";
|
|
39
40
|
import { ensureGitSshTimeout, isRunningInContainer, terminalNotify } from "./utils.js";
|
|
40
41
|
import { createLogger } from "../shared/logger.js";
|
|
41
42
|
import { setGlobalSessionId } from "../shared/file-logger.js";
|
|
@@ -94,6 +95,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
94
95
|
const { spawnAsyncAgent, killAsyncAgent, getAsyncJobs } = registerAsyncAgents(pi, terminalNotify);
|
|
95
96
|
registerSubagentTool(pi, spawnAsyncAgent, killAsyncAgent);
|
|
96
97
|
registerProjectSettings(pi);
|
|
98
|
+
registerGraft(pi);
|
|
97
99
|
registerEnforcement(pi, IN_CONTAINER);
|
|
98
100
|
registerReviewUI(pi);
|
|
99
101
|
registerRules(pi, getAsyncJobs);
|
|
@@ -339,7 +339,7 @@ function registerMemoryAdd(pi: ExtensionAPI): void {
|
|
|
339
339
|
return {
|
|
340
340
|
content: [{
|
|
341
341
|
type: "text",
|
|
342
|
-
text: `Invalid action "${params.action}". run_after requires a command (e.g., 'run_after
|
|
342
|
+
text: `Invalid action "${params.action}". run_after requires a command (e.g., 'run_after npm test')`,
|
|
343
343
|
}],
|
|
344
344
|
};
|
|
345
345
|
}
|
|
@@ -41,6 +41,8 @@ interface ProjectSettings {
|
|
|
41
41
|
cli_agents?: string | string[];
|
|
42
42
|
pidash_enable?: boolean;
|
|
43
43
|
pidiff_enable?: boolean;
|
|
44
|
+
/** Enable local Graft repository graph integration. Default: false. */
|
|
45
|
+
graft_enable?: boolean;
|
|
44
46
|
pidash_port?: number;
|
|
45
47
|
task_auto_clear_enabled?: boolean;
|
|
46
48
|
task_auto_clear_minutes?: number;
|
|
@@ -105,6 +107,7 @@ export interface SettingsKeyDef {
|
|
|
105
107
|
max?: number;
|
|
106
108
|
strict_digits?: boolean;
|
|
107
109
|
per_key_resolution?: boolean;
|
|
110
|
+
enum?: string[];
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
const SETTINGS_FILENAMES = ["pi-config-settings.jsonc", "pi-config-settings.json"];
|
|
@@ -136,6 +139,7 @@ const PROJECT_SETTINGS_KEYS: (keyof ProjectSettings)[] = [
|
|
|
136
139
|
"cli_agents",
|
|
137
140
|
"pidash_enable",
|
|
138
141
|
"pidiff_enable",
|
|
142
|
+
"graft_enable",
|
|
139
143
|
"pidash_port",
|
|
140
144
|
"task_auto_clear_enabled",
|
|
141
145
|
"task_auto_clear_minutes",
|
|
@@ -196,6 +200,7 @@ export function parseSettingsFile(filePath: string): ProjectSettings {
|
|
|
196
200
|
if (typeof raw.orchestrator_edit_write_block === "boolean") result.orchestrator_edit_write_block = raw.orchestrator_edit_write_block;
|
|
197
201
|
if (typeof raw.pidash_enable === "boolean") result.pidash_enable = raw.pidash_enable;
|
|
198
202
|
if (typeof raw.pidiff_enable === "boolean") result.pidiff_enable = raw.pidiff_enable;
|
|
203
|
+
if (typeof raw.graft_enable === "boolean") result.graft_enable = raw.graft_enable;
|
|
199
204
|
if (typeof raw.pidash_port === "number" && Number.isInteger(raw.pidash_port) && raw.pidash_port > 0 && raw.pidash_port <= 65535) {
|
|
200
205
|
result.pidash_port = raw.pidash_port;
|
|
201
206
|
}
|
|
@@ -248,7 +253,7 @@ export function parseSettingsFile(filePath: string): ProjectSettings {
|
|
|
248
253
|
const SPECIAL_CASE_KEYS = new Set<string>([
|
|
249
254
|
"commit_trailer", "allow_push_to_protected_branches", "use_worktrees",
|
|
250
255
|
"dream_interval_hours", "dco", "comment_signature", "review_loop_enforcement",
|
|
251
|
-
"orchestrator_edit_write_block", "pidash_enable", "pidiff_enable", "pidash_port",
|
|
256
|
+
"orchestrator_edit_write_block", "pidash_enable", "pidiff_enable", "graft_enable", "pidash_port",
|
|
252
257
|
"image_model", "internal_operations_provider", "internal_operations_model",
|
|
253
258
|
"review_loop_max_cycles", "acpx_agents", "cli_agents", "agent_provider",
|
|
254
259
|
"agent_model", "agent_overrides",
|
|
@@ -466,6 +471,7 @@ export function getSetting(cwd: string, key: "acpx_agents"): string[];
|
|
|
466
471
|
export function getSetting(cwd: string, key: "cli_agents"): string[];
|
|
467
472
|
export function getSetting(cwd: string, key: "pidash_enable"): boolean;
|
|
468
473
|
export function getSetting(cwd: string, key: "pidiff_enable"): boolean;
|
|
474
|
+
export function getSetting(cwd: string, key: "graft_enable"): boolean;
|
|
469
475
|
export function getSetting(cwd: string, key: "pidash_port"): number;
|
|
470
476
|
export function getSetting(cwd: string, key: "task_auto_clear_enabled"): boolean;
|
|
471
477
|
export function getSetting(cwd: string, key: "task_auto_clear_minutes"): number;
|
|
@@ -9,6 +9,7 @@ import { dirname, join as joinPath } from "node:path";
|
|
|
9
9
|
import stripJsonComments from "strip-json-comments";
|
|
10
10
|
import {
|
|
11
11
|
type SettingsKeyDef,
|
|
12
|
+
SETTINGS_KEYS,
|
|
12
13
|
getSettingsPath,
|
|
13
14
|
getGlobalSettingsPath,
|
|
14
15
|
} from "./project-settings.js";
|
|
@@ -40,14 +41,12 @@ export interface CategoryDef {
|
|
|
40
41
|
keys: string[];
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
import settingsSchema from "../../settings-keys.json" with { type: "json" };
|
|
44
|
-
|
|
45
44
|
// Build CATEGORIES dynamically from schema `group` field.
|
|
46
45
|
// Preserves insertion order from settings-keys.json — groups appear
|
|
47
46
|
// in the order their first key is declared.
|
|
48
47
|
export const CATEGORIES: CategoryDef[] = (() => {
|
|
49
48
|
const groupMap = new Map<string, string[]>();
|
|
50
|
-
for (const [key, def] of Object.entries(
|
|
49
|
+
for (const [key, def] of Object.entries(SETTINGS_KEYS)) {
|
|
51
50
|
const group = (def as any).group || "Other";
|
|
52
51
|
if (!groupMap.has(group)) groupMap.set(group, []);
|
|
53
52
|
groupMap.get(group)!.push(key);
|
|
@@ -55,7 +55,6 @@ import {
|
|
|
55
55
|
AgentOverridesSubmenu,
|
|
56
56
|
SelectSubmenu,
|
|
57
57
|
} from "./settings-tui-submenus.js";
|
|
58
|
-
import settingsSchema from "../../settings-keys.json" with { type: "json" };
|
|
59
58
|
|
|
60
59
|
// Re-export helpers for external consumers
|
|
61
60
|
const log = createLogger("settings_tui");
|
|
@@ -229,8 +228,7 @@ export function buildCategoryItems(
|
|
|
229
228
|
break;
|
|
230
229
|
|
|
231
230
|
case "string": {
|
|
232
|
-
const
|
|
233
|
-
const enumValues: string[] | undefined = schemaDef?.enum;
|
|
231
|
+
const enumValues = def.enum;
|
|
234
232
|
|
|
235
233
|
if (enumValues && enumValues.length > 0) {
|
|
236
234
|
item.submenu = (current: string, done: (val?: string) => void): Component => {
|