pi-fovea 0.18.0 → 0.18.2
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 +1 -1
- package/package.json +1 -1
- package/src/core/agent-dir.ts +19 -0
- package/src/core/config.ts +2 -2
- package/src/core/provenance.ts +65 -25
- package/src/index.ts +11 -6
- package/src/ui/settings.ts +4 -5
package/README.md
CHANGED
|
@@ -205,7 +205,7 @@ next user prompt instead; it never restarts an idle agent. The compact update
|
|
|
205
205
|
names changed files, route deltas, and newly relevant files. Shell commands,
|
|
206
206
|
external editors, and agents without Fovea remain `unattributed` rather than
|
|
207
207
|
being guessed, while the path scope still keeps unrelated sibling sandboxes
|
|
208
|
-
quiet. Provenance journals
|
|
208
|
+
quiet. Provenance journals accept either intercepted mutations or explicit trusted SHA-1 transitions, write one bounded replacement per event batch, expire after seven days, and live in
|
|
209
209
|
`$TMPDIR`; repository content remains the drift oracle. Updates list causal
|
|
210
210
|
channels such as calls, imports, shared literals, tests, or co-change history.
|
|
211
211
|
By default Fovea also embeds the refreshed focus context of the top drift target
|
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// pi's agent-dir resolution, in-process so pi-fovea modules never import the
|
|
2
|
+
// pi-coding-agent runtime at extension load. pi's loader re-evaluates the host
|
|
3
|
+
// module graph for every extension that imports it, costing roughly a second
|
|
4
|
+
// of startup.
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export const configDirName = ".pi";
|
|
10
|
+
|
|
11
|
+
const expandTilde = (value: string): string => {
|
|
12
|
+
if (value === "~") return homedir();
|
|
13
|
+
return value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const resolveAgentDir = (): string => {
|
|
17
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
18
|
+
return override ? expandTilde(override) : path.join(homedir(), configDirName, "agent");
|
|
19
|
+
};
|
package/src/core/config.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// defaults; the FOVEA_TURN_SYNC=off environment variable always wins, the
|
|
5
5
|
// same way PI_FABRIC_* overrides win over stored fabric config.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { configDirName } from "./agent-dir.js";
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
|
|
@@ -88,7 +88,7 @@ export interface FoveaConfigScopes {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
export const globalFoveaConfigPath = (agentDir: string): string => path.join(agentDir, "fovea.json");
|
|
91
|
-
export const projectFoveaConfigPath = (cwd: string): string => path.join(cwd,
|
|
91
|
+
export const projectFoveaConfigPath = (cwd: string): string => path.join(cwd, configDirName, "fovea.json");
|
|
92
92
|
|
|
93
93
|
const BOUNDS: Record<string, [number, number]> = {
|
|
94
94
|
"sync.budget": [128, 8192],
|
package/src/core/provenance.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface MutationCapture {
|
|
|
30
30
|
beforeSha?: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface MutationTransition {
|
|
34
|
+
path: string;
|
|
35
|
+
beforeSha?: string | undefined;
|
|
36
|
+
afterSha?: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
type ProvenanceKind = "current-session" | "other-session" | "mixed" | "unattributed";
|
|
34
40
|
|
|
35
41
|
export interface SyncProvenance {
|
|
@@ -68,7 +74,11 @@ export const captureMutation = async (root: string, path: string): Promise<Mutat
|
|
|
68
74
|
|
|
69
75
|
const writeQueues = new Map<string, Promise<void>>();
|
|
70
76
|
|
|
71
|
-
const
|
|
77
|
+
const persistRecords = async (
|
|
78
|
+
target: string,
|
|
79
|
+
journal: MutationJournal,
|
|
80
|
+
additions: readonly MutationRecord[],
|
|
81
|
+
): Promise<void> => {
|
|
72
82
|
const cutoff = Date.now() - JOURNAL_TTL_MS;
|
|
73
83
|
let records: MutationRecord[] = [];
|
|
74
84
|
try {
|
|
@@ -79,48 +89,73 @@ const persistRecord = async (target: string, journal: MutationJournal, record: M
|
|
|
79
89
|
} catch {
|
|
80
90
|
// Missing and torn journals both recover as a fresh per-session file.
|
|
81
91
|
}
|
|
82
|
-
records.push(
|
|
92
|
+
records.push(...additions);
|
|
83
93
|
journal.records = records.slice(-JOURNAL_MAX_RECORDS);
|
|
84
94
|
const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`;
|
|
85
95
|
await writeFile(temporary, JSON.stringify(journal));
|
|
86
96
|
await rename(temporary, target);
|
|
87
97
|
};
|
|
88
98
|
|
|
89
|
-
export const
|
|
90
|
-
|
|
99
|
+
export const recordMutationTransitions = async (
|
|
100
|
+
root: string,
|
|
101
|
+
transitions: readonly MutationTransition[],
|
|
91
102
|
sessionId: string,
|
|
92
103
|
toolCallId: string,
|
|
93
|
-
): Promise<
|
|
94
|
-
const
|
|
95
|
-
if (capture.beforeSha === afterSha) return false;
|
|
104
|
+
): Promise<number> => {
|
|
105
|
+
const resolvedRoot = resolve(root);
|
|
96
106
|
const owner = ownerFor(sessionId);
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
beforeSha
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
const at = Date.now();
|
|
108
|
+
const records = transitions.flatMap((transition): MutationRecord[] => {
|
|
109
|
+
const located = repoPath(root, transition.path);
|
|
110
|
+
if (!located || transition.beforeSha === transition.afterSha) return [];
|
|
111
|
+
return [{
|
|
112
|
+
file: located.file,
|
|
113
|
+
beforeSha: transition.beforeSha,
|
|
114
|
+
afterSha: transition.afterSha,
|
|
115
|
+
owner,
|
|
116
|
+
toolCallId,
|
|
117
|
+
at,
|
|
118
|
+
}];
|
|
119
|
+
});
|
|
120
|
+
if (records.length === 0) return 0;
|
|
121
|
+
const target = provenancePathFor(resolvedRoot, sessionId);
|
|
106
122
|
const previous = writeQueues.get(target) ?? Promise.resolve();
|
|
107
|
-
const queued = previous.catch(() => {}).then(() =>
|
|
108
|
-
version: JOURNAL_VERSION,
|
|
109
|
-
|
|
110
|
-
owner,
|
|
111
|
-
records: [],
|
|
112
|
-
}, record));
|
|
123
|
+
const queued = previous.catch(() => {}).then(() => persistRecords(target, {
|
|
124
|
+
version: JOURNAL_VERSION, root: resolvedRoot, owner, records: [],
|
|
125
|
+
}, records));
|
|
113
126
|
writeQueues.set(target, queued);
|
|
114
127
|
try {
|
|
115
128
|
await queued;
|
|
116
|
-
return
|
|
129
|
+
return records.length;
|
|
117
130
|
} finally {
|
|
118
131
|
if (writeQueues.get(target) === queued) writeQueues.delete(target);
|
|
119
132
|
}
|
|
120
133
|
};
|
|
121
134
|
|
|
135
|
+
export const recordMutationTransition = async (
|
|
136
|
+
root: string,
|
|
137
|
+
path: string,
|
|
138
|
+
beforeSha: string | undefined,
|
|
139
|
+
afterSha: string | undefined,
|
|
140
|
+
sessionId: string,
|
|
141
|
+
toolCallId: string,
|
|
142
|
+
): Promise<boolean> => (await recordMutationTransitions(
|
|
143
|
+
root, [{ path, beforeSha, afterSha }], sessionId, toolCallId,
|
|
144
|
+
)) === 1;
|
|
145
|
+
|
|
146
|
+
export const finishMutation = async (
|
|
147
|
+
capture: MutationCapture,
|
|
148
|
+
sessionId: string,
|
|
149
|
+
toolCallId: string,
|
|
150
|
+
): Promise<boolean> => recordMutationTransition(
|
|
151
|
+
capture.root, capture.file, capture.beforeSha, await hashFile(capture.absolutePath), sessionId, toolCallId,
|
|
152
|
+
);
|
|
153
|
+
|
|
122
154
|
const readRecords = async (root: string, since: number): Promise<MutationRecord[]> => {
|
|
123
155
|
const prefix = prefixFor(root);
|
|
156
|
+
await Promise.all([...writeQueues.entries()]
|
|
157
|
+
.filter(([path]) => path.split(/[/\\]/u).at(-1)?.startsWith(prefix))
|
|
158
|
+
.map(([, pending]) => pending.catch(() => {})));
|
|
124
159
|
const cutoff = Math.max(since, Date.now() - JOURNAL_TTL_MS);
|
|
125
160
|
let names: string[];
|
|
126
161
|
try {
|
|
@@ -180,12 +215,17 @@ export const attributeChanges = async (
|
|
|
180
215
|
changes: Array<{ file: string; beforeSha?: string; afterSha?: string }>,
|
|
181
216
|
): Promise<SyncProvenance> => {
|
|
182
217
|
const records = await readRecords(root, since);
|
|
218
|
+
const recordsByFile = new Map<string, MutationRecord[]>();
|
|
219
|
+
for (const record of records) {
|
|
220
|
+
const matching = recordsByFile.get(record.file) ?? [];
|
|
221
|
+
matching.push(record);
|
|
222
|
+
recordsByFile.set(record.file, matching);
|
|
223
|
+
}
|
|
183
224
|
const currentOwner = ownerFor(sessionId);
|
|
184
225
|
const files: Record<string, ProvenanceKind> = {};
|
|
185
226
|
for (const change of changes) {
|
|
186
|
-
const matching = records.filter((record) => record.file === change.file);
|
|
187
227
|
files[change.file] = kindForOwners(
|
|
188
|
-
ownersForTransition(
|
|
228
|
+
ownersForTransition(recordsByFile.get(change.file) ?? [], change.beforeSha, change.afterSha),
|
|
189
229
|
currentOwner,
|
|
190
230
|
);
|
|
191
231
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// incremental across sessions.
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Type } from "typebox";
|
|
9
|
+
import { resolveAgentDir } from "./core/agent-dir.js";
|
|
9
10
|
import { loadFoveaConfig, type FoveaConfig } from "./core/config.js";
|
|
10
11
|
import { hasAstGrep } from "./core/astgrep.js";
|
|
11
12
|
import { ROOT_CACHE_LIMIT } from "./core/asyncutil.js";
|
|
@@ -13,7 +14,6 @@ import { dwell, ensureStateBackground, focus, impact, sketch } from "./core/ops.
|
|
|
13
14
|
import { observeSessionPaths, resetSessions } from "./core/session.js";
|
|
14
15
|
import { captureMutation, finishMutation, type MutationCapture } from "./core/provenance.js";
|
|
15
16
|
import { resetSyncBaselines, sync, warmSync } from "./core/sync.js";
|
|
16
|
-
import { openFoveaSettings } from "./ui/settings.js";
|
|
17
17
|
import type { NodeKind } from "./core/types.js";
|
|
18
18
|
|
|
19
19
|
const PACKAGE_VERSION = (() => {
|
|
@@ -89,7 +89,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
89
89
|
configs.set(root, hit);
|
|
90
90
|
return hit;
|
|
91
91
|
}
|
|
92
|
-
const cfg = loadFoveaConfig({ cwd: root, agentDir: agentDir ??
|
|
92
|
+
const cfg = loadFoveaConfig({ cwd: root, agentDir: agentDir ?? resolveAgentDir(), projectTrusted: trusted });
|
|
93
93
|
configs.set(root, cfg);
|
|
94
94
|
while (configs.size > ROOT_CACHE_LIMIT) configs.delete(configs.keys().next().value!);
|
|
95
95
|
return cfg;
|
|
@@ -159,6 +159,10 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
159
159
|
return undefined;
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
|
+
// pi-core's createGrepTool drags the whole host module graph into this
|
|
163
|
+
// extension's loader (~1s of startup). Load it only on first native use.
|
|
164
|
+
const loadNativeGrepTool = (root: string) =>
|
|
165
|
+
import("@earendil-works/pi-coding-agent").then(({ createGrepTool }) => createGrepTool(root));
|
|
162
166
|
const registerGrepOverride = (): void => {
|
|
163
167
|
if (grepOverrideRegistered) return;
|
|
164
168
|
grepOverrideRegistered = true;
|
|
@@ -175,7 +179,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
175
179
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
176
180
|
const root = ctx.cwd;
|
|
177
181
|
if (requestsNativeGrep(params)) {
|
|
178
|
-
const native =
|
|
182
|
+
const native = await loadNativeGrepTool(root);
|
|
179
183
|
return native.execute(id, params, signal, onUpdate);
|
|
180
184
|
}
|
|
181
185
|
const budget = configFor(root, ctx.isProjectTrusted()).tools.defaultBudget;
|
|
@@ -183,7 +187,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
183
187
|
try {
|
|
184
188
|
const result = await focus(root, query, budget, { fresh: true });
|
|
185
189
|
if (Number(result.details.seeds ?? 0) === 0) {
|
|
186
|
-
const native =
|
|
190
|
+
const native = await loadNativeGrepTool(root);
|
|
187
191
|
return native.execute(id, params, signal, onUpdate);
|
|
188
192
|
}
|
|
189
193
|
return {
|
|
@@ -194,7 +198,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
194
198
|
// A broken graph backend must not break text search: degrade to
|
|
195
199
|
// native grep and mark the result, the way a graph miss does.
|
|
196
200
|
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
-
const native =
|
|
201
|
+
const native = await loadNativeGrepTool(root);
|
|
198
202
|
const fallback = await native.execute(id, params, signal, onUpdate);
|
|
199
203
|
return {
|
|
200
204
|
...fallback,
|
|
@@ -542,6 +546,7 @@ export default function fovea(pi: ExtensionAPI) {
|
|
|
542
546
|
return;
|
|
543
547
|
}
|
|
544
548
|
if (sub === "settings") {
|
|
549
|
+
const { openFoveaSettings } = await import("./ui/settings.js");
|
|
545
550
|
const result = await openFoveaSettings(ctx, { onConfigApplied: () => configs.clear() });
|
|
546
551
|
if (result.grepRegistrationChanged) {
|
|
547
552
|
ctx.ui.notify("Reloading extensions to apply the grep tool change…", "info");
|
package/src/ui/settings.ts
CHANGED
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
// external-editor keybinding switches the configuration layer being viewed and saved.
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
CONFIG_DIR_NAME,
|
|
10
9
|
DynamicBorder,
|
|
11
|
-
getAgentDir,
|
|
12
10
|
type ExtensionContext,
|
|
13
11
|
type Theme,
|
|
14
12
|
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { configDirName, resolveAgentDir } from "../core/agent-dir.js";
|
|
15
14
|
import {
|
|
16
15
|
Container,
|
|
17
16
|
type Component,
|
|
@@ -216,8 +215,8 @@ export class FoveaSettingsComponent extends Container {
|
|
|
216
215
|
|
|
217
216
|
private updateSaveScopeText(): void {
|
|
218
217
|
const destination = this.saveScope === "project"
|
|
219
|
-
? `Project overrides (${
|
|
220
|
-
: `Global defaults (~/${
|
|
218
|
+
? `Project overrides (${configDirName}/fovea.json)`
|
|
219
|
+
: `Global defaults (~/${configDirName}/agent/fovea.json)`;
|
|
221
220
|
const keys = this.keybindings?.getKeys("app.editor.external") ?? [];
|
|
222
221
|
const shortcut = keys.map(formatKey).join("/");
|
|
223
222
|
const hint = !this.projectScopeAvailable
|
|
@@ -328,7 +327,7 @@ export const openFoveaSettings = async (
|
|
|
328
327
|
context.ui.notify("Fovea settings are available in TUI mode", "warning");
|
|
329
328
|
return { grepRegistrationChanged: false };
|
|
330
329
|
}
|
|
331
|
-
const agentDir =
|
|
330
|
+
const agentDir = resolveAgentDir();
|
|
332
331
|
const scopes = {
|
|
333
332
|
cwd: context.cwd,
|
|
334
333
|
agentDir,
|