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 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 are bounded, expire after seven days, and live in
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ };
@@ -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 { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
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, CONFIG_DIR_NAME, "fovea.json");
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],
@@ -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 persistRecord = async (target: string, journal: MutationJournal, record: MutationRecord): Promise<void> => {
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(record);
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 finishMutation = async (
90
- capture: MutationCapture,
99
+ export const recordMutationTransitions = async (
100
+ root: string,
101
+ transitions: readonly MutationTransition[],
91
102
  sessionId: string,
92
103
  toolCallId: string,
93
- ): Promise<boolean> => {
94
- const afterSha = await hashFile(capture.absolutePath);
95
- if (capture.beforeSha === afterSha) return false;
104
+ ): Promise<number> => {
105
+ const resolvedRoot = resolve(root);
96
106
  const owner = ownerFor(sessionId);
97
- const target = provenancePathFor(capture.root, sessionId);
98
- const record: MutationRecord = {
99
- file: capture.file,
100
- beforeSha: capture.beforeSha,
101
- afterSha,
102
- owner,
103
- toolCallId,
104
- at: Date.now(),
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(() => persistRecord(target, {
108
- version: JOURNAL_VERSION,
109
- root: capture.root,
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 true;
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(matching, change.beforeSha, change.afterSha),
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 { createGrepTool, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
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 ?? getAgentDir(), projectTrusted: trusted });
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 = createGrepTool(root);
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 = createGrepTool(root);
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 = createGrepTool(root);
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");
@@ -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 (${CONFIG_DIR_NAME}/fovea.json)`
220
- : `Global defaults (~/${CONFIG_DIR_NAME}/agent/fovea.json)`;
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 = getAgentDir();
330
+ const agentDir = resolveAgentDir();
332
331
  const scopes = {
333
332
  cwd: context.cwd,
334
333
  agentDir,