pi-goal-list-loop-audit 0.35.13 → 0.35.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +977 -0
- package/INSTALL.md +13 -3
- package/LIST-PHILOSOPHY.md +90 -0
- package/PLAN.md +316 -0
- package/README.md +201 -26
- package/docs/DESIGN-subagent-visibility.md +76 -0
- package/docs/DESIGN.md +59 -8
- package/docs/GLLA-POSITIONING-AND-DECOMPOSITION-2026-08-08.md +6 -1
- package/docs/INDEX.md +4 -0
- package/docs/VISION-ASSIST.md +2 -2
- package/extensions/auditor-extensions.ts +289 -0
- package/extensions/context-hygiene.ts +129 -0
- package/extensions/faulty-objective-recovery.ts +24 -0
- package/extensions/glla-state-root.ts +99 -0
- package/extensions/goal-agents-panel.ts +211 -0
- package/extensions/goal-commands.ts +226 -10
- package/extensions/goal-continuation.ts +119 -21
- package/extensions/goal-heartbeat.ts +560 -35
- package/extensions/goal-loop-auditor-process.ts +126 -4
- package/extensions/goal-loop-auditor.ts +3 -5
- package/extensions/goal-loop-backoff.ts +79 -0
- package/extensions/goal-loop-core.ts +197 -15
- package/extensions/goal-loop-dispatch.ts +3 -2
- package/extensions/goal-loop-display.ts +141 -8
- package/extensions/goal-loop-forever.ts +52 -3
- package/extensions/goal-loop-shield.ts +132 -26
- package/extensions/goal-loop-stats.ts +3 -2
- package/extensions/goal-loop.ts +101 -4
- package/extensions/goal-recovery.ts +8 -2
- package/extensions/goal-settings.ts +61 -14
- package/extensions/goal-state.ts +20 -0
- package/extensions/loops/goal-activation.ts +426 -45
- package/extensions/loops/goal-auditor-hooks.ts +39 -3
- package/extensions/loops/goal-auditor-surface.ts +32 -0
- package/extensions/loops/goal-list-queue.ts +72 -12
- package/extensions/loops/goal-orchestrator.ts +33 -1
- package/extensions/loops/goal-runtime-globals.ts +5 -0
- package/extensions/loops/goal-session.ts +68 -8
- package/extensions/loops/goal-settings-ui.ts +116 -5
- package/extensions/loops/goal-tools.ts +104 -29
- package/extensions/loops/goal-ui.ts +106 -1
- package/extensions/loops/goal.ts +7 -7
- package/extensions/main-model-recovery.ts +10 -0
- package/extensions/multi-model-picker.ts +45 -7
- package/extensions/payload-guard.ts +172 -0
- package/extensions/reviewer.ts +3 -1
- package/extensions/settings-menu.ts +39 -4
- package/package.json +9 -3
- package/prompts/goal-loop-plan-loop.md +31 -0
- package/prompts/goal-loop-plan.md +51 -0
- package/schemas/goal.schema.json +4 -1
- package/scripts/auditor-extension-fixture.mjs +19 -0
- package/scripts/goal-auditor-launch.mjs +19 -1
- package/scripts/goal-auditor-worker.mjs +15 -0
- package/scripts/verify-auditor-extensions-offline.mjs +72 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.36.0
|
|
2
|
+
// extensions/auditor-extensions.ts
|
|
3
|
+
//
|
|
4
|
+
// Discovery + normalization for the auditor extension allowlist
|
|
5
|
+
// (`auditorAllowedExtensions`, GitHub issue: "Allow extension-based model
|
|
6
|
+
// providers in detached auditor sessions").
|
|
7
|
+
//
|
|
8
|
+
// The detached auditor worker spawns `pi --mode rpc --no-extensions …`. Pi's
|
|
9
|
+
// `--extension <spec>` flag still loads explicitly given specs even under
|
|
10
|
+
// `--no-extensions`, BUT a raw `npm:<pkg>` spec makes pi perform a fresh
|
|
11
|
+
// *temporary* npm install into `~/.pi/agent/tmp/extensions/npm/<hash>` on
|
|
12
|
+
// every spawn — network-bound, hundreds of MB, and completely dead offline
|
|
13
|
+
// (field-verified by the 2026-08-22 audit: `-e npm:pi-cursor-sdk` with
|
|
14
|
+
// PI_OFFLINE=1 registered 0 models; the resolved install path registered
|
|
15
|
+
// 209). Relative `extensions[]` entries are likewise resolved by pi against
|
|
16
|
+
// the *auditor's cwd*, not the settings base dir, so a verbatim
|
|
17
|
+
// `../../ghq/...` spec silently loads nothing.
|
|
18
|
+
//
|
|
19
|
+
// We therefore resolve every allowlist entry to a concrete, existing
|
|
20
|
+
// absolute path BEFORE it reaches the worker:
|
|
21
|
+
// • `npm:<name>[@version]` → `~/.pi/agent/npm/node_modules/<name>` (or the
|
|
22
|
+
// project `.pi/npm/node_modules/<name>` equivalent) — existing install
|
|
23
|
+
// only, never triggering a new one.
|
|
24
|
+
// • `git:<url>` → `~/.pi/agent/git/<host>/<user>/<project>` (or project
|
|
25
|
+
// `.pi/git/...` equivalent).
|
|
26
|
+
// • paths → `~` expanded; relative paths resolved against the settings
|
|
27
|
+
// base dir (user scope: `~/.pi/agent`, project scope: `<cwd>/.pi`),
|
|
28
|
+
// matching pi's own `getBaseDirForScope` resolution.
|
|
29
|
+
// Entries that do not resolve to an existing path are dropped (fail-closed)
|
|
30
|
+
// rather than emitted as unloadable specs.
|
|
31
|
+
//
|
|
32
|
+
// Discovery is read-only and best-effort: unreadable files simply contribute
|
|
33
|
+
// no entries. The picker menu (Auditor tab) is the only consumer besides the
|
|
34
|
+
// dispatch path, which re-reads the normalized settings at request time.
|
|
35
|
+
|
|
36
|
+
import * as fs from "node:fs";
|
|
37
|
+
import * as path from "node:path";
|
|
38
|
+
|
|
39
|
+
/** One extension the user could allow-list for the auditor. */
|
|
40
|
+
export interface DiscoveredPiExtension {
|
|
41
|
+
/** The spec to pass to `pi --extension` / store in settings.
|
|
42
|
+
* Settings-sourced entries are RESOLVED to concrete install paths (see
|
|
43
|
+
* module header); directory-discovered entries are already absolute. */
|
|
44
|
+
spec: string;
|
|
45
|
+
/** Short human label (package name or file/dir basename). */
|
|
46
|
+
label: string;
|
|
47
|
+
/** Where the entry came from — for the picker's descriptive text. */
|
|
48
|
+
source: "package" | "extensions-setting" | "user-dir" | "project-dir";
|
|
49
|
+
/** The raw settings entry this was resolved from (specs resolved from
|
|
50
|
+
* packages[]/extensions[] keep the original string for display). */
|
|
51
|
+
raw?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse an npm package spec into its install directory name.
|
|
55
|
+
* Mirrors pi's PackageManager.parseNpmSpec: `@scope/pkg@1.2.3` →
|
|
56
|
+
* `@scope/pkg`; `pkg@^2` → `pkg`; `pkg` → `pkg`. */
|
|
57
|
+
function npmSpecName(spec: string): string {
|
|
58
|
+
const match = spec.match(/^(@?[^@]+(?:\/?[^@]+)?)(?:@(.+))?$/);
|
|
59
|
+
return (match?.[1] ?? spec).trim() || spec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse a git source into `{ host, repoPath }` where repoPath is
|
|
63
|
+
* `user/project` (`.git` and ref stripped). Mirrors the subset of pi's
|
|
64
|
+
* parseGitUrl/splitRef that settings `packages[]` entries can contain.
|
|
65
|
+
* Returns undefined when the string is not a git source. */
|
|
66
|
+
function parseGitSpec(source: string): { host: string; repoPath: string } | undefined {
|
|
67
|
+
let url = source.trim();
|
|
68
|
+
if (url.startsWith("git:")) url = url.slice(4).trim();
|
|
69
|
+
else if (!/^(https?|ssh|git):\/\//i.test(url) && !/^git@[^:]+:/.test(url) && !/^[^.\s]+\.[^.\s]+\//.test(url)) {
|
|
70
|
+
return undefined; // not a git-shaped source
|
|
71
|
+
}
|
|
72
|
+
url = url.split("#")[0] ?? url; // strip #ref
|
|
73
|
+
let host = "";
|
|
74
|
+
let rest = "";
|
|
75
|
+
const scp = url.match(/^git@([^:]+):(.+)$/);
|
|
76
|
+
if (scp) {
|
|
77
|
+
host = scp[1] ?? "";
|
|
78
|
+
rest = scp[2] ?? "";
|
|
79
|
+
} else if (url.includes("://")) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url);
|
|
82
|
+
host = parsed.hostname;
|
|
83
|
+
rest = parsed.pathname.replace(/^\/+/, "");
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const slash = url.indexOf("/");
|
|
89
|
+
if (slash < 0) return undefined;
|
|
90
|
+
host = url.slice(0, slash);
|
|
91
|
+
rest = url.slice(slash + 1);
|
|
92
|
+
}
|
|
93
|
+
const at = rest.indexOf("@"); // strip @ref (pi's splitRef semantics)
|
|
94
|
+
if (at >= 0) rest = rest.slice(0, at);
|
|
95
|
+
rest = rest.replace(/\.git$/, "").replace(/^\/+/, "");
|
|
96
|
+
const parts = rest.split("/").filter(Boolean);
|
|
97
|
+
if (!host || parts.length < 2 || parts.some((p) => p === "..")) return undefined;
|
|
98
|
+
return { host, repoPath: parts.join("/") };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function firstExisting(candidates: string[]): string | undefined {
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
try {
|
|
104
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
105
|
+
} catch {
|
|
106
|
+
/* unreadable — treat as missing */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Expand `~` and resolve a path against a base dir (pi's
|
|
113
|
+
* resolvePathFromBase semantics for settings entries). */
|
|
114
|
+
function resolveSettingsPath(input: string, baseDir: string, home: string): string {
|
|
115
|
+
if (input.startsWith("~")) return path.join(home, input.slice(1).replace(/^\/+/, ""));
|
|
116
|
+
return path.resolve(baseDir, input);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve one allowlist spec to a concrete existing absolute path the
|
|
120
|
+
* worker can pass as `pi --extension <path>` without triggering installs.
|
|
121
|
+
* `base` is the settings base dir for relative paths (user scope:
|
|
122
|
+
* `~/.pi/agent`; project scope: `<cwd>/.pi`). Returns undefined when the
|
|
123
|
+
* entry does not resolve to something loadable — callers drop it
|
|
124
|
+
* (fail-closed) rather than emit an unloadable spec. */
|
|
125
|
+
export function resolveAuditorExtensionSpec(
|
|
126
|
+
spec: string,
|
|
127
|
+
opts: { home: string; cwd?: string; base?: string },
|
|
128
|
+
): string | undefined {
|
|
129
|
+
const agentDir = path.join(opts.home, ".pi", "agent");
|
|
130
|
+
const projectDir = opts.cwd ? path.join(opts.cwd, ".pi") : undefined;
|
|
131
|
+
const trimmed = spec.trim();
|
|
132
|
+
if (trimmed.startsWith("npm:")) {
|
|
133
|
+
const name = npmSpecName(trimmed.slice(4));
|
|
134
|
+
if (!name) return undefined;
|
|
135
|
+
return firstExisting([
|
|
136
|
+
path.join(agentDir, "npm", "node_modules", name),
|
|
137
|
+
...(projectDir ? [path.join(projectDir, "npm", "node_modules", name)] : []),
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
const git = parseGitSpec(trimmed);
|
|
141
|
+
if (git) {
|
|
142
|
+
return firstExisting([
|
|
143
|
+
path.join(agentDir, "git", git.host, ...git.repoPath.split("/")),
|
|
144
|
+
...(projectDir ? [path.join(projectDir, "git", git.host, ...git.repoPath.split("/"))] : []),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
// Local path: `~`/absolute as-is, relative against the settings base dir
|
|
148
|
+
// (NOT the auditor's future cwd — pi resolves -e against cwd, so emitting
|
|
149
|
+
// a relative spec would silently load nothing).
|
|
150
|
+
if (/^[a-z]+:/.test(trimmed)) return undefined; // unknown scheme — no safe resolution
|
|
151
|
+
const resolved = resolveSettingsPath(trimmed, opts.base ?? agentDir, opts.home);
|
|
152
|
+
return firstExisting([resolved]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve an allowlist to worker-ready install paths: every entry maps to
|
|
156
|
+
* a concrete existing path; unresolvable entries are dropped fail-closed. */
|
|
157
|
+
export function resolveAuditorAllowedExtensions(
|
|
158
|
+
specs: string[] | undefined,
|
|
159
|
+
home: string,
|
|
160
|
+
cwd?: string,
|
|
161
|
+
): string[] {
|
|
162
|
+
const out: string[] = [];
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
for (const spec of normalizeAuditorAllowedExtensions(specs)) {
|
|
165
|
+
const resolved = resolveAuditorExtensionSpec(spec, { home, cwd, base: path.join(home, ".pi", "agent") });
|
|
166
|
+
if (!resolved || seen.has(resolved)) continue; // not installed here — skip, don't emit a dead spec
|
|
167
|
+
seen.add(resolved);
|
|
168
|
+
out.push(resolved);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Bounded, deterministic normalization of the allowlist value. */
|
|
174
|
+
export function normalizeAuditorAllowedExtensions(value: unknown): string[] {
|
|
175
|
+
if (!Array.isArray(value)) return [];
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
const out: string[] = [];
|
|
178
|
+
for (const entry of value) {
|
|
179
|
+
if (typeof entry !== "string") continue;
|
|
180
|
+
const spec = entry.trim();
|
|
181
|
+
if (!spec || seen.has(spec)) continue;
|
|
182
|
+
if (out.length >= 32) break; // bounded: the request hash stays sane
|
|
183
|
+
seen.add(spec);
|
|
184
|
+
out.push(spec);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readJsonArray(file: string): string[] {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
192
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for (const key of ["packages", "extensions"]) {
|
|
195
|
+
const value = (parsed as Record<string, unknown>)[key];
|
|
196
|
+
if (!Array.isArray(value)) continue;
|
|
197
|
+
for (const entry of value) {
|
|
198
|
+
if (typeof entry === "string" && entry.trim()) out.push(entry.trim());
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Auto-discovered extension entries in a directory: *.ts / *.js files and
|
|
208
|
+
* subdirectories (loaded by pi as <dir>/index.ts). */
|
|
209
|
+
function discoverDir(dir: string, source: DiscoveredPiExtension["source"]): DiscoveredPiExtension[] {
|
|
210
|
+
let entries: fs.Dirent[];
|
|
211
|
+
try {
|
|
212
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
213
|
+
} catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const out: DiscoveredPiExtension[] = [];
|
|
217
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
218
|
+
const full = path.join(dir, entry.name);
|
|
219
|
+
if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
220
|
+
out.push({ spec: full, label: entry.name, source });
|
|
221
|
+
} else if (entry.isDirectory()) {
|
|
222
|
+
out.push({ spec: full, label: `${entry.name}/`, source });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function labelForSpec(spec: string): string {
|
|
229
|
+
// "npm:@scope/pkg@version" → "@scope/pkg"; "git:url" → url tail; paths → basename.
|
|
230
|
+
const colon = spec.indexOf(":");
|
|
231
|
+
if (colon > 0) {
|
|
232
|
+
const scheme = spec.slice(0, colon);
|
|
233
|
+
const rest = spec.slice(colon + 1);
|
|
234
|
+
if (scheme === "npm" || scheme === "git") {
|
|
235
|
+
const withoutVersion = rest.replace(/@[^/@]*$/, "");
|
|
236
|
+
return withoutVersion || rest;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return path.basename(spec) || spec;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Discover every extension a normal pi session would load in this
|
|
244
|
+
* environment: user settings packages/extensions, the user extensions dir,
|
|
245
|
+
* project settings packages/extensions, and the project extensions dir.
|
|
246
|
+
* Deduplicated by spec, stable order: settings packages first, then
|
|
247
|
+
* settings extension paths, then directory discovery (user before project).
|
|
248
|
+
*/
|
|
249
|
+
export function discoverAuditorExtensions(home: string, cwd?: string): DiscoveredPiExtension[] {
|
|
250
|
+
const agentDir = path.join(home, ".pi", "agent");
|
|
251
|
+
const out: DiscoveredPiExtension[] = [];
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
const push = (spec: string, source: DiscoveredPiExtension["source"]) => {
|
|
254
|
+
if (!spec || seen.has(spec)) return;
|
|
255
|
+
seen.add(spec);
|
|
256
|
+
out.push({ spec, label: labelForSpec(spec), source });
|
|
257
|
+
};
|
|
258
|
+
// Settings entries: packages[] specs and extensions[] paths are resolved
|
|
259
|
+
// to concrete install paths (npm: → managed node_modules, git: → managed
|
|
260
|
+
// git root, relative paths → settings base dir) so the stored allowlist
|
|
261
|
+
// entry is directly loadable by `pi --extension <path>` without a fresh
|
|
262
|
+
// temporary install. Unresolvable entries are skipped, not emitted.
|
|
263
|
+
const pushResolved = (raw: string, source: DiscoveredPiExtension["source"], base: string) => {
|
|
264
|
+
const spec = resolveAuditorExtensionSpec(raw, { home, cwd, base });
|
|
265
|
+
if (!spec || seen.has(spec)) return;
|
|
266
|
+
seen.add(spec);
|
|
267
|
+
out.push({ spec, label: labelForSpec(raw), source, raw });
|
|
268
|
+
};
|
|
269
|
+
const userSettings = readJsonArray(path.join(agentDir, "settings.json"));
|
|
270
|
+
for (const raw of userSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", agentDir);
|
|
271
|
+
for (const entry of discoverDir(path.join(agentDir, "extensions"), "user-dir")) push(entry.spec, entry.source);
|
|
272
|
+
if (cwd) {
|
|
273
|
+
const projectSettings = readJsonArray(path.join(cwd, ".pi", "settings.json"));
|
|
274
|
+
const projectBase = path.join(cwd, ".pi");
|
|
275
|
+
for (const raw of projectSettings) pushResolved(raw, /^[a-z]+:/.test(raw) ? "package" : "extensions-setting", projectBase);
|
|
276
|
+
for (const entry of discoverDir(path.join(cwd, ".pi", "extensions"), "project-dir")) push(entry.spec, entry.source);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Expand the allowlist into `pi` CLI args: ["--extension", spec, …].
|
|
282
|
+
* Empty/absent list → [] (the worker keeps plain --no-extensions). */
|
|
283
|
+
export function auditorExtensionArgs(allowed: string[] | undefined): string[] {
|
|
284
|
+
const args: string[] = [];
|
|
285
|
+
for (const spec of normalizeAuditorAllowedExtensions(allowed)) {
|
|
286
|
+
args.push("--extension", spec);
|
|
287
|
+
}
|
|
288
|
+
return args;
|
|
289
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.35.52
|
|
2
|
+
// extensions/context-hygiene.ts
|
|
3
|
+
//
|
|
4
|
+
// note.md Now: "failed requests add to the context, while clearly adding
|
|
5
|
+
// nothing of value" — field evidence (polis session, 2026-08-23): a run of
|
|
6
|
+
// failed turns (503 server_error, "Provider finish_reason: network_error",
|
|
7
|
+
// "Retry failed after 3 attempts: Retry cancelled") accumulated until the
|
|
8
|
+
// estimated context hit 122.7% of the 200k window, and auto-compaction then
|
|
9
|
+
// failed on its own bloated summarization input.
|
|
10
|
+
//
|
|
11
|
+
// Mechanism (verified in pi sources): when retries are exhausted, the failed
|
|
12
|
+
// assistant message (stopReason "error", errorMessage set, content typically
|
|
13
|
+
// empty or partial) STAYS in agent state and the session — pi strips it from
|
|
14
|
+
// live state only for mid-flight retries. Every later LLM call receives it,
|
|
15
|
+
// and compaction summarizes it. Nothing downstream filters these.
|
|
16
|
+
//
|
|
17
|
+
// The durable bounded rule: project error-only assistant turns OUT of the
|
|
18
|
+
// effective context — EXCEPT the most recent one, which stays so the model
|
|
19
|
+
// can see WHY the previous attempt failed on the immediate retry send. Only
|
|
20
|
+
// turns with NO tool-call blocks are dropped (an error turn that emitted
|
|
21
|
+
// tool calls owns paired toolResult messages and must stay intact); only
|
|
22
|
+
// stopReason "error" is projected ("aborted" turns are user-intent
|
|
23
|
+
// boundaries and are never touched). Session transcripts on disk are NOT
|
|
24
|
+
// modified — this is a per-send projection, exactly like the v0.35.51
|
|
25
|
+
// payload guard; a second application point prunes the same turns from the
|
|
26
|
+
// compaction summarization input (the preparation object is shared by
|
|
27
|
+
// reference with the compaction runner).
|
|
28
|
+
//
|
|
29
|
+
// Pure module — no pi runtime, no fs — so the rule is unit-testable in
|
|
30
|
+
// isolation; the orchestration layer (goal-activation.ts) registers the
|
|
31
|
+
// context/compaction hooks and writes the context_hygiene ledger entries.
|
|
32
|
+
|
|
33
|
+
/** How many of the most recent failed turns stay visible. One is enough for
|
|
34
|
+
* retry continuity; older failures are pure noise. */
|
|
35
|
+
export const DEFAULT_KEEP_RECENT_ERROR_TURNS = 1;
|
|
36
|
+
|
|
37
|
+
export interface FailedTurnDrop {
|
|
38
|
+
messageIndex: number;
|
|
39
|
+
errorMessage?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FailedTurnResult {
|
|
43
|
+
/** The projected message list (same identity when nothing was dropped). */
|
|
44
|
+
messages: readonly unknown[];
|
|
45
|
+
dropped: FailedTurnDrop[];
|
|
46
|
+
/** Failed turns kept (the most recent window). */
|
|
47
|
+
kept: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ContextHygieneOptions {
|
|
51
|
+
keepRecentErrorTurns?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when the block is a tool call (an error turn carrying these owns
|
|
55
|
+
* paired toolResult messages and is never droppable). */
|
|
56
|
+
function isToolCallBlock(block: unknown): boolean {
|
|
57
|
+
return typeof block === "object" && block !== null
|
|
58
|
+
&& (block as { type?: unknown }).type === "toolCall";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A failed error-only assistant turn: stopReason "error", content carrying
|
|
63
|
+
* no tool-call blocks. Partial text/thinking from a stream that died
|
|
64
|
+
* mid-flight is projected too — the transcript keeps it; the effective
|
|
65
|
+
* context does not need it.
|
|
66
|
+
*/
|
|
67
|
+
export function isFailedErrorOnlyTurn(message: unknown): boolean {
|
|
68
|
+
if (typeof message !== "object" || message === null) return false;
|
|
69
|
+
const m = message as { role?: unknown; stopReason?: unknown; content?: unknown };
|
|
70
|
+
if (m.role !== "assistant" || m.stopReason !== "error") return false;
|
|
71
|
+
if (!Array.isArray(m.content)) return false;
|
|
72
|
+
return !m.content.some(isToolCallBlock);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Project failed error-only assistant turns out of the message list. The
|
|
77
|
+
* most recent `keepRecentErrorTurns` failed turns are kept (bounded rule);
|
|
78
|
+
* older ones are dropped entirely. Returns the input array identity when
|
|
79
|
+
* nothing needs dropping.
|
|
80
|
+
*/
|
|
81
|
+
export function dropFailedErrorOnlyTurns(
|
|
82
|
+
messages: readonly unknown[],
|
|
83
|
+
opts: ContextHygieneOptions = {},
|
|
84
|
+
): FailedTurnResult {
|
|
85
|
+
const keepRecent = typeof opts.keepRecentErrorTurns === "number" && opts.keepRecentErrorTurns >= 0
|
|
86
|
+
? opts.keepRecentErrorTurns
|
|
87
|
+
: DEFAULT_KEEP_RECENT_ERROR_TURNS;
|
|
88
|
+
|
|
89
|
+
const failedIndexes: number[] = [];
|
|
90
|
+
messages.forEach((message, index) => {
|
|
91
|
+
if (isFailedErrorOnlyTurn(message)) failedIndexes.push(index);
|
|
92
|
+
});
|
|
93
|
+
if (failedIndexes.length <= keepRecent) {
|
|
94
|
+
return { messages, dropped: [], kept: failedIndexes.length };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const dropSet = new Set(failedIndexes.slice(0, failedIndexes.length - keepRecent));
|
|
98
|
+
const dropped: FailedTurnDrop[] = [...dropSet].map((messageIndex) => ({
|
|
99
|
+
messageIndex,
|
|
100
|
+
errorMessage: (messages[messageIndex] as { errorMessage?: string } | null)?.errorMessage,
|
|
101
|
+
}));
|
|
102
|
+
const projected = messages.filter((_, index) => !dropSet.has(index));
|
|
103
|
+
return { messages: projected, dropped, kept: keepRecent };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* In-place application to a CompactionPreparation-shaped object (the runner
|
|
108
|
+
* passes the same object to `compact()` after the extension event, so
|
|
109
|
+
* property reassignment propagates). Returns the number of dropped turns
|
|
110
|
+
* across both message arrays; never throws over shape surprises.
|
|
111
|
+
*/
|
|
112
|
+
export function pruneCompactionPreparation(
|
|
113
|
+
preparation: unknown,
|
|
114
|
+
opts: ContextHygieneOptions = {},
|
|
115
|
+
): number {
|
|
116
|
+
if (typeof preparation !== "object" || preparation === null) return 0;
|
|
117
|
+
const prep = preparation as { messagesToSummarize?: unknown; turnPrefixMessages?: unknown };
|
|
118
|
+
let dropped = 0;
|
|
119
|
+
for (const key of ["messagesToSummarize", "turnPrefixMessages"] as const) {
|
|
120
|
+
const list = prep[key];
|
|
121
|
+
if (!Array.isArray(list)) continue;
|
|
122
|
+
const result = dropFailedErrorOnlyTurns(list, opts);
|
|
123
|
+
if (result.dropped.length > 0) {
|
|
124
|
+
prep[key] = result.messages;
|
|
125
|
+
dropped += result.dropped.length;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return dropped;
|
|
129
|
+
}
|
|
@@ -149,6 +149,30 @@ function seedObjective(seed: unknown): string | null {
|
|
|
149
149
|
return usableCandidate(raw.split(/\b(?:done\s+when|verify)\s*:/i)[0]);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* v0.35.53 (note.md Now): heal legacy malformed queue items WITHOUT a repair
|
|
154
|
+
* card. The v0.35.53 parser tightening stops NEW items from being written
|
|
155
|
+
* with an empty objective, but items already persisted that way (field:
|
|
156
|
+
* neonbreak — objective "", the entire intent inside the verification
|
|
157
|
+
* contract, faulty_objective_list_activation_blocked ×42 over 22h) need a
|
|
158
|
+
* deterministic heal at activation: when the objective is empty but the
|
|
159
|
+
* contract carries a clean, actionable leading imperative, derive the
|
|
160
|
+
* objective from the contract's first sentence. Returns null when the
|
|
161
|
+
* contract is absent or itself suspicious — those stay on the true
|
|
162
|
+
* broken-objective repair path.
|
|
163
|
+
*/
|
|
164
|
+
export function deriveObjectiveFromContract(contract: unknown): string | null {
|
|
165
|
+
const text = normalizedText(contract);
|
|
166
|
+
if (!text) return null;
|
|
167
|
+
if (assessSuspiciousObjective(text).suspicious) return null;
|
|
168
|
+
if (!IMPERATIVE_START.test(text)) return null;
|
|
169
|
+
const firstSentence = text.split(/(?<=[.!?])\s+/)[0] ?? text;
|
|
170
|
+
// 400: long single-sentence intents are legitimate objectives (the field
|
|
171
|
+
// sentence is ~253 chars); the cap only guards against pathological dumps.
|
|
172
|
+
const bounded = firstSentence.slice(0, 400).trim();
|
|
173
|
+
return bounded.length >= 8 ? bounded : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
function repairContract(goal: Goal): { value?: string; source: string } {
|
|
153
177
|
const candidates: Array<[string, unknown]> = [
|
|
154
178
|
["current contract", goal.verificationContract],
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.35.58
|
|
2
|
+
// extensions/glla-state-root.ts
|
|
3
|
+
//
|
|
4
|
+
// The state-root boundary is deliberately dependency-free: goal-loop-core and
|
|
5
|
+
// goal-settings both need it, so putting it in either of those modules would
|
|
6
|
+
// create a circular import. The runtime session directory is registered by the
|
|
7
|
+
// lifecycle slice; until then sessionDir mode is pending and persistence
|
|
8
|
+
// callers must not create a cwd fallback tree.
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
export type GllaStateRoot = "workingDir" | "sessionDir";
|
|
15
|
+
|
|
16
|
+
export function globalSettingsPath(): string {
|
|
17
|
+
// Test/embedding override keeps the suite hermetic from the developer's
|
|
18
|
+
// real global settings file. This helper intentionally has no settings
|
|
19
|
+
// module dependency because piGlaDir must read the global root selector.
|
|
20
|
+
const override = process.env.GLLA_GLOBAL_SETTINGS_PATH;
|
|
21
|
+
if (override) return override;
|
|
22
|
+
return path.join(os.homedir(), ".pi", "agent", "pi-goal-list-loop-audit.settings.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function configuredGlobalSettingsPath(): string {
|
|
26
|
+
return globalSettingsPath();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Live session's top-level directory. The lifecycle slice registers this
|
|
30
|
+
* after host admission; tests and worker processes may use PI_SESSION_FILE. */
|
|
31
|
+
let runtimeSessionDir: string | undefined;
|
|
32
|
+
|
|
33
|
+
export function setRuntimeSessionDir(dir: string | undefined): void {
|
|
34
|
+
runtimeSessionDir = typeof dir === "string" && dir.trim() ? path.resolve(dir) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Register the host session root from pi's file-backed SessionManager. The
|
|
38
|
+
* lifecycle owns this admission point; keeping the derivation here makes the
|
|
39
|
+
* state-root boundary identical for normal starts and successor rebinds.
|
|
40
|
+
* `getSessionDir()` is authoritative: a session file may be imported from a
|
|
41
|
+
* different directory, while in-memory subagent managers return an empty dir. */
|
|
42
|
+
export function setRuntimeSessionDirFromSessionManager(sessionManager: unknown): string | undefined {
|
|
43
|
+
let sessionDir: unknown;
|
|
44
|
+
try {
|
|
45
|
+
const getter = (sessionManager as { getSessionDir?: unknown } | null | undefined)?.getSessionDir;
|
|
46
|
+
if (typeof getter !== "function") {
|
|
47
|
+
setRuntimeSessionDir(undefined);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
sessionDir = (getter as () => unknown).call(sessionManager);
|
|
51
|
+
} catch {
|
|
52
|
+
setRuntimeSessionDir(undefined);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (typeof sessionDir !== "string" || !sessionDir.trim()) {
|
|
56
|
+
setRuntimeSessionDir(undefined);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const dir = path.resolve(sessionDir);
|
|
60
|
+
setRuntimeSessionDir(dir);
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveRuntimeSessionDir(): string | undefined {
|
|
65
|
+
if (runtimeSessionDir) return runtimeSessionDir;
|
|
66
|
+
const sessionFile = process.env.PI_SESSION_FILE;
|
|
67
|
+
if (!sessionFile) return undefined;
|
|
68
|
+
const parent = path.dirname(path.resolve(sessionFile));
|
|
69
|
+
return parent && parent !== "." ? parent : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readStateRootSetting(): GllaStateRoot {
|
|
73
|
+
try {
|
|
74
|
+
const raw = JSON.parse(fs.readFileSync(configuredGlobalSettingsPath(), "utf8")) as Record<string, unknown>;
|
|
75
|
+
return raw.stateRoot === "sessionDir" ? "sessionDir" : "workingDir";
|
|
76
|
+
} catch {
|
|
77
|
+
return "workingDir";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function configuredStateRoot(): GllaStateRoot {
|
|
82
|
+
return readStateRootSetting();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when sessionDir is selected but the lifecycle has not registered a
|
|
86
|
+
* session root yet. Reads may fall back for compatibility; write callers must
|
|
87
|
+
* defer so startup cannot recreate state under an ambiguous cwd. */
|
|
88
|
+
export function stateRootPending(): boolean {
|
|
89
|
+
return readStateRootSetting() === "sessionDir" && !resolveRuntimeSessionDir();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Resolve the selected root without performing migration or filesystem I/O. */
|
|
93
|
+
export function resolveGllaStateDir(cwd: string): string {
|
|
94
|
+
if (readStateRootSetting() === "sessionDir") {
|
|
95
|
+
const sessionDir = resolveRuntimeSessionDir();
|
|
96
|
+
if (sessionDir) return path.join(sessionDir, "pi-glla");
|
|
97
|
+
}
|
|
98
|
+
return path.join(cwd, ".pi-glla");
|
|
99
|
+
}
|