@piagent/platform 1.2.13 → 1.2.14

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +11 -3
  3. package/README.vi.md +90 -0
  4. package/architecture/layers.json +117 -0
  5. package/docs/README.md +23 -0
  6. package/docs/architecture.md +7 -61
  7. package/docs/command-reference-vietnamese.md +6 -6
  8. package/docs/distribution-standard.md +7 -7
  9. package/docs/en/README.md +13 -0
  10. package/docs/en/architecture.md +81 -0
  11. package/docs/en/maintainer-guide.md +121 -0
  12. package/docs/languages.json +40 -0
  13. package/docs/operator-manual-vietnamese.md +7 -7
  14. package/docs/package-architecture-notes.md +9 -0
  15. package/docs/release-install-policy.md +6 -6
  16. package/docs/security-threat-model.md +1 -1
  17. package/docs/team-onboarding.md +2 -2
  18. package/docs/vi/README.md +13 -0
  19. package/docs/vi/architecture.md +81 -0
  20. package/docs/vi/maintainer-guide.md +121 -0
  21. package/docs-site/architecture.html +38 -14
  22. package/docs-site/benchmark.html +3 -1
  23. package/docs-site/commands.html +3 -1
  24. package/docs-site/content/architecture.html +35 -13
  25. package/docs-site/context-engine.html +3 -1
  26. package/docs-site/index.html +3 -1
  27. package/docs-site/mcp-approval.html +3 -1
  28. package/docs-site/mcp-auth.html +3 -1
  29. package/docs-site/mcp-servers.html +3 -1
  30. package/docs-site/mcp.html +3 -1
  31. package/docs-site/permissions.html +3 -1
  32. package/docs-site/profiles.html +3 -1
  33. package/docs-site/quickstart.html +3 -1
  34. package/docs-site/runtime.html +3 -1
  35. package/docs-site/scope.html +3 -1
  36. package/docs-site/security.html +3 -1
  37. package/docs-site/team.html +3 -1
  38. package/docs-site/workflows.html +3 -1
  39. package/package.json +5 -1
  40. package/packages/piagent-core/README.md +6 -2
  41. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  42. package/packages/piagent-core/capabilities/runtime-integrity.js +18 -0
  43. package/packages/piagent-core/extensions/guard-types.ts +0 -37
  44. package/packages/piagent-core/extensions/piagent-guard.ts +170 -2099
  45. package/packages/piagent-core/extensions/task-state.js +22 -3
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +213 -0
  48. package/packages/piagent-core/runtime/hooks/completion-hook.ts +203 -0
  49. package/packages/piagent-core/runtime/hooks/input-hook.ts +122 -0
  50. package/packages/piagent-core/runtime/hooks/session-hooks.ts +104 -0
  51. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +140 -0
  52. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +316 -0
  53. package/packages/piagent-core/runtime/input/chat-images.ts +299 -0
  54. package/packages/piagent-core/runtime/runtime-limits.ts +21 -0
  55. package/packages/piagent-core/runtime/session/message-signals.ts +64 -0
  56. package/packages/piagent-core/runtime/session/runtime-state.ts +175 -0
  57. package/packages/piagent-core/runtime/session/system-prompt.ts +52 -0
  58. package/packages/piagent-core/runtime/session/tool-result-compaction.ts +352 -0
  59. package/packages/piagent-core/runtime/session/usage.ts +178 -0
  60. package/packages/piagent-core/runtime/tools/tool-groups.ts +86 -0
  61. package/packages/piagent-core/runtime/workflows/input-routing.ts +113 -0
  62. package/packages/piagent-core/runtime/workflows/task-intake.ts +73 -0
  63. package/scripts/build-docs-site.mjs +4 -1
  64. package/scripts/check-architecture.mjs +132 -0
  65. package/scripts/check-doc-languages.mjs +67 -0
  66. package/scripts/verify-local.sh +31 -0
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import type { TaskContract } from "../../extensions/guard-types.js";
5
+ import { RuntimeSessionState } from "../session/runtime-state.ts";
6
+ import type { ObservedTaskContext } from "../session/runtime-state.ts";
7
+
8
+ type TelemetryWriter = (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
9
+
10
+ type SessionHookDependencies = {
11
+ state: RuntimeSessionState;
12
+ maxManifestFiles: number;
13
+ telemetry: TelemetryWriter;
14
+ activeTask: (cwd: string, sessionId: string) => TaskContract | undefined;
15
+ writeTask: (cwd: string, task: TaskContract) => TaskContract;
16
+ bindTask: (cwd: string, sessionId: string, sessionName: string | undefined, task: TaskContract) => void;
17
+ appendTrace: (cwd: string, payload: Record<string, unknown>) => void;
18
+ flushObservedTaskContext: (
19
+ pi: ExtensionAPI,
20
+ ctx: ExtensionContext,
21
+ pendingContext: ObservedTaskContext[],
22
+ maxManifestFiles: number,
23
+ event: string
24
+ ) => TaskContract | undefined;
25
+ };
26
+
27
+ export function registerSessionHooks(pi: ExtensionAPI, dependencies: SessionHookDependencies): void {
28
+ const {
29
+ state,
30
+ maxManifestFiles,
31
+ telemetry,
32
+ activeTask,
33
+ writeTask,
34
+ bindTask,
35
+ appendTrace,
36
+ flushObservedTaskContext
37
+ } = dependencies;
38
+
39
+ pi.on("session_info_changed", async (event, ctx) => {
40
+ const sessionId = ctx.sessionManager.getSessionId();
41
+ const task = activeTask(ctx.cwd, sessionId);
42
+ if (!task) return;
43
+ const sessionName = String(event.name ?? "").trim() || undefined;
44
+ task.sessionName = sessionName;
45
+ const written = writeTask(ctx.cwd, task);
46
+ bindTask(ctx.cwd, sessionId, sessionName, written);
47
+ appendTrace(ctx.cwd, {
48
+ event: "task_session_renamed",
49
+ taskId: written.taskId,
50
+ taskRunId: written.taskRunId,
51
+ sessionId,
52
+ sessionName
53
+ });
54
+ });
55
+
56
+ pi.on("turn_end", async (event, ctx) => {
57
+ const message = event.message as unknown as { usage?: unknown; stopReason?: unknown; role?: unknown };
58
+ telemetry(ctx, {
59
+ event: "turn_end",
60
+ turnIndex: event.turnIndex,
61
+ toolResults: event.toolResults.length,
62
+ role: message.role,
63
+ stopReason: message.stopReason,
64
+ usage: message.usage,
65
+ contextUsage: ctx.getContextUsage()
66
+ });
67
+ });
68
+
69
+ pi.on("agent_settled", async (_event, ctx) => {
70
+ const task = activeTask(ctx.cwd, ctx.sessionManager.getSessionId());
71
+ telemetry(ctx, {
72
+ event: "agent_settled",
73
+ idle: ctx.isIdle(),
74
+ taskId: task?.taskId,
75
+ taskRunId: task?.taskRunId,
76
+ taskOutcome: task?.trace.outcome
77
+ });
78
+ });
79
+
80
+ pi.on("session_compact", async (event, ctx) => {
81
+ telemetry(ctx, {
82
+ event: "session_compact",
83
+ reason: event.reason,
84
+ willRetry: event.willRetry,
85
+ fromExtension: event.fromExtension
86
+ });
87
+ });
88
+
89
+ pi.on("session_shutdown", async (event, ctx) => {
90
+ flushObservedTaskContext(
91
+ pi,
92
+ ctx,
93
+ state.observedContext(ctx),
94
+ maxManifestFiles,
95
+ "context_observed_before_shutdown"
96
+ );
97
+ telemetry(ctx, {
98
+ event: "session_shutdown",
99
+ reason: event.reason,
100
+ targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
101
+ });
102
+ state.clearSession(ctx);
103
+ });
104
+ }
@@ -0,0 +1,140 @@
1
+ import path from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { contextIndexV2Status } from "../../extensions/context-engine.js";
5
+ import type { ProjectProfile, TaskContract } from "../../extensions/guard-types.js";
6
+ import { pruneCaptureFiles } from "../../extensions/state-retention.js";
7
+ import { migrateTaskState } from "../../extensions/task-state.js";
8
+ import {
9
+ CAPTURE_RETENTION_MAX_AGE_MS,
10
+ CAPTURE_RETENTION_MAX_BYTES,
11
+ CAPTURE_RETENTION_MAX_FILES
12
+ } from "../runtime-limits.ts";
13
+ import { currentSessionName, hasOperatorSessionName } from "../session/message-signals.ts";
14
+ import { RuntimeSessionState } from "../session/runtime-state.ts";
15
+ import { buildContextPreflight, buildUsageSnapshot } from "../session/usage.ts";
16
+ import { toolResultCaptureRoot } from "../session/tool-result-compaction.ts";
17
+ import { activeTaskToolGroups } from "../tools/tool-groups.ts";
18
+ import type { PiagentToolGroup } from "../tools/tool-groups.ts";
19
+
20
+ type SessionTaskReference = { taskId?: string; taskRunId?: string };
21
+
22
+ type SessionStartHookDependencies = {
23
+ state: RuntimeSessionState;
24
+ loadProfile: (ctx: ExtensionContext) => ProjectProfile;
25
+ projectProfileExists: (cwd: string) => boolean;
26
+ activateToolGroups: (ctx: ExtensionContext, groups: PiagentToolGroup[]) => unknown;
27
+ taskReference: (ctx: ExtensionContext) => SessionTaskReference | undefined;
28
+ activeTask: (cwd: string, sessionId: string) => TaskContract | undefined;
29
+ resolveTask: (cwd: string, reference: string, sessionId: string) => TaskContract | undefined;
30
+ bindTask: (cwd: string, sessionId: string, sessionName: string | undefined, task: TaskContract) => unknown;
31
+ writeTask: (cwd: string, task: TaskContract) => TaskContract;
32
+ capabilityState: (ctx: ExtensionContext) => { ok: boolean; reason?: string; repinned?: string };
33
+ permissionProfile: (ctx: ExtensionContext, profile: ProjectProfile) => { mode: string; warning?: string };
34
+ legacyProjectWarning: (cwd: string) => string | undefined;
35
+ mcpReadinessNotice: (cwd: string) => string | undefined;
36
+ updateAvailabilityNotice: () => string | undefined;
37
+ contextExcludePatterns: (profile: ProjectProfile) => string[];
38
+ telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
39
+ };
40
+
41
+ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: SessionStartHookDependencies): void {
42
+ pi.on("session_start", async (_event, ctx) => {
43
+ dependencies.activateToolGroups(ctx, []);
44
+ const projectTrusted = ctx.isProjectTrusted();
45
+ const explicitProfile = Boolean(process.env.PIAGENT_PROFILE?.trim());
46
+ const profile = dependencies.loadProfile(ctx);
47
+ const name = profile.displayName || profile.projectId || path.basename(ctx.cwd);
48
+ const operatorSessionName = currentSessionName(ctx);
49
+ if (!hasOperatorSessionName(operatorSessionName)) pi.setSessionName(`pi:${name}`);
50
+
51
+ const sessionId = ctx.sessionManager.getSessionId();
52
+ const sessionName = currentSessionName(ctx);
53
+ const taskReference = dependencies.taskReference(ctx);
54
+ let captureRetention: Record<string, unknown>;
55
+ try {
56
+ captureRetention = pruneCaptureFiles(toolResultCaptureRoot(ctx.cwd), {
57
+ maxFiles: CAPTURE_RETENTION_MAX_FILES,
58
+ maxBytes: CAPTURE_RETENTION_MAX_BYTES,
59
+ maxAgeMs: CAPTURE_RETENTION_MAX_AGE_MS,
60
+ projectRoot: ctx.cwd
61
+ });
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ captureRetention = { removed: 0, kept: 0, bytes: 0, error: message };
65
+ ctx.ui.notify(`Piagent local state needs recovery: ${message}`, "warning");
66
+ }
67
+
68
+ const migration = migrateTaskState(ctx.cwd, {
69
+ sessionId,
70
+ sessionName,
71
+ taskId: taskReference?.taskId
72
+ });
73
+ if (migration.warnings.length > 0) {
74
+ ctx.ui.notify(`Piagent task-state migration needs recovery: ${migration.warnings.join("; ")}`, "warning");
75
+ }
76
+ let resumedTask = dependencies.activeTask(ctx.cwd, sessionId);
77
+ if (!resumedTask && taskReference) {
78
+ resumedTask = dependencies.resolveTask(ctx.cwd, taskReference.taskRunId ?? taskReference.taskId ?? "", sessionId);
79
+ if (resumedTask) dependencies.bindTask(ctx.cwd, sessionId, sessionName, resumedTask);
80
+ }
81
+ if (resumedTask && resumedTask.sessionName !== sessionName) {
82
+ resumedTask.sessionName = sessionName;
83
+ resumedTask = dependencies.writeTask(ctx.cwd, resumedTask);
84
+ dependencies.bindTask(ctx.cwd, sessionId, sessionName, resumedTask);
85
+ }
86
+ dependencies.state.cacheTaskIdentity(ctx, resumedTask);
87
+ if (resumedTask?.trace.outcome === "pending") {
88
+ dependencies.activateToolGroups(ctx, activeTaskToolGroups(resumedTask));
89
+ }
90
+
91
+ const profileHint = explicitProfile || (projectTrusted && dependencies.projectProfileExists(ctx.cwd))
92
+ ? ""
93
+ : " (run /onboard to select a profile)";
94
+ const snapshot = buildUsageSnapshot(ctx, String(pi.getThinkingLevel()));
95
+ const preflight = buildContextPreflight(snapshot, "task", 0);
96
+ const capabilityState = dependencies.capabilityState(ctx);
97
+ const permissionProfile = dependencies.permissionProfile(ctx, profile);
98
+ const contextHint = preflight.recommendation === "fresh-session"
99
+ ? " Context is high; use /fresh task or /fresh scout for new work."
100
+ : preflight.recommendation === "compact"
101
+ ? " Context is warm; run /task-preflight before large work."
102
+ : "";
103
+ ctx.ui.notify(
104
+ `Piagent Pi guard loaded: ${name}${profileHint} permission=${permissionProfile.mode}${contextHint}`,
105
+ preflight.recommendation === "fresh-session" ? "warning" : "info"
106
+ );
107
+ if (!capabilityState.ok) ctx.ui.notify(capabilityState.reason ?? "Capability validation failed.", "warning");
108
+ if (capabilityState.repinned) {
109
+ ctx.ui.notify(`Capability lock re-pinned: ${capabilityState.repinned}. The capabilities this project grants are unchanged.`, "info");
110
+ }
111
+ const legacyWarning = dependencies.legacyProjectWarning(ctx.cwd);
112
+ if (legacyWarning) ctx.ui.notify(legacyWarning, "warning");
113
+ if (permissionProfile.warning) ctx.ui.notify(permissionProfile.warning, "warning");
114
+ if (permissionProfile.mode === "trusted-full-access") {
115
+ ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
116
+ }
117
+ const mcpNotice = dependencies.mcpReadinessNotice(ctx.cwd);
118
+ if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
119
+ const updateNotice = dependencies.updateAvailabilityNotice();
120
+ if (updateNotice) ctx.ui.notify(updateNotice, "info");
121
+
122
+ let engineStatus: unknown;
123
+ try {
124
+ engineStatus = await contextIndexV2Status(ctx.cwd, {
125
+ excludePatterns: dependencies.contextExcludePatterns(profile)
126
+ });
127
+ } catch (error) {
128
+ engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
129
+ }
130
+ dependencies.telemetry(ctx, {
131
+ event: "session_start",
132
+ activeTools: pi.getActiveTools().length,
133
+ index: engineStatus,
134
+ taskMigration: migration,
135
+ taskId: resumedTask?.taskId,
136
+ taskRunId: resumedTask?.taskRunId,
137
+ captureRetention
138
+ });
139
+ });
140
+ }
@@ -0,0 +1,316 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { toolResultFingerprint } from "../../extensions/context-engine.js";
6
+ import { matchesProtectedPath } from "../../extensions/policy-core.js";
7
+ import { redactForStorage, redactSensitiveText } from "../../extensions/redaction-core.js";
8
+ import {
9
+ appendObservedBashResult,
10
+ observedBashResultFromToolResultEvent
11
+ } from "../../extensions/runtime-evidence.js";
12
+ import {
13
+ attachToolResultCompactionDetails,
14
+ compactToolResultDetails,
15
+ compactToolResultTextContent
16
+ } from "../session/tool-result-compaction.ts";
17
+ import type { ToolResultCaptureSummary } from "../session/tool-result-compaction.ts";
18
+ import { RuntimeSessionState } from "../session/runtime-state.ts";
19
+ import type { ObservedTaskContext } from "../session/runtime-state.ts";
20
+
21
+ type ToolResultEvent = {
22
+ toolName: string;
23
+ input?: unknown;
24
+ content?: unknown;
25
+ details?: unknown;
26
+ isError?: boolean;
27
+ usage?: unknown;
28
+ };
29
+
30
+ type ObservedBashResult = NonNullable<ReturnType<typeof observedBashResultFromToolResultEvent>>;
31
+
32
+ type ToolResultHookDependencies = {
33
+ state: RuntimeSessionState;
34
+ maxManifestFiles: number;
35
+ readProtectedPaths: (ctx: ExtensionContext) => string[];
36
+ recordObservedBash: (observed: ObservedBashResult) => void;
37
+ observedBashLedgerPath: (cwd: string) => string;
38
+ redactText: (input: string) => string;
39
+ observedTaskContext: (
40
+ cwd: string,
41
+ event: ToolResultEvent,
42
+ readProtectedPaths: string[]
43
+ ) => ObservedTaskContext | undefined;
44
+ recordObservedTaskChanges: (
45
+ pi: ExtensionAPI,
46
+ ctx: ExtensionContext,
47
+ event: ToolResultEvent,
48
+ pendingContext: ObservedTaskContext[],
49
+ maxManifestFiles: number,
50
+ shellSnapshotBefore?: Record<string, string>
51
+ ) => unknown;
52
+ recordObservedTaskVerification: (
53
+ pi: ExtensionAPI,
54
+ ctx: ExtensionContext,
55
+ observed: ObservedBashResult,
56
+ pendingContext: ObservedTaskContext[],
57
+ maxManifestFiles: number
58
+ ) => unknown;
59
+ extractLikelyPath: (cwd: string, input: Record<string, unknown>) => string | undefined;
60
+ isShellTool: (toolName: string) => boolean;
61
+ telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
62
+ now: () => string;
63
+ };
64
+
65
+ function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
66
+ if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
67
+ let raw = candidate.trim();
68
+ try {
69
+ raw = decodeURIComponent(raw);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
74
+ if (!raw.toLowerCase().startsWith("file://")) return undefined;
75
+ try {
76
+ raw = fileURLToPath(raw);
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
82
+ return path.relative(cwd, absolute).split(path.sep).join("/");
83
+ }
84
+
85
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
87
+ const prototype = Object.getPrototypeOf(value);
88
+ return prototype === Object.prototype || prototype === null;
89
+ }
90
+
91
+ function countChangedStringLeaves(before: unknown, after: unknown): number {
92
+ if (typeof before === "string" && typeof after === "string") return before === after ? 0 : 1;
93
+ if (Array.isArray(before) && Array.isArray(after)) {
94
+ return before.reduce((total, item, index) => total + countChangedStringLeaves(item, after[index]), 0);
95
+ }
96
+ if (!before || !after || typeof before !== "object" || typeof after !== "object") return 0;
97
+ return Object.entries(before as Record<string, unknown>).reduce(
98
+ (total, [key, value]) => total + countChangedStringLeaves(value, (after as Record<string, unknown>)[key]),
99
+ 0
100
+ );
101
+ }
102
+
103
+ function redactToolResultTextContent(content: unknown): { content: unknown; redacted: number } {
104
+ if (!Array.isArray(content)) return { content, redacted: 0 };
105
+ let redacted = 0;
106
+ const safeContent = content.map((block) => {
107
+ if (!block || typeof block !== "object") return block;
108
+ const typed = block as { type?: unknown; text?: unknown };
109
+ if (typed.type !== "text" || typeof typed.text !== "string") return block;
110
+ const safeText = redactSensitiveText(typed.text);
111
+ if (!safeText.redacted) return block;
112
+ redacted += 1;
113
+ return { ...block, text: safeText.text };
114
+ });
115
+ return { content: safeContent, redacted };
116
+ }
117
+
118
+ function filterTextBlocks(
119
+ content: unknown,
120
+ blocked: (line: string) => boolean,
121
+ emptyText: string,
122
+ noticeText: (count: number) => string
123
+ ): { changed: boolean; content?: unknown; redactedLines: number } {
124
+ if (!Array.isArray(content)) return { changed: false, redactedLines: 0 };
125
+ let changed = false;
126
+ let redactedLines = 0;
127
+ const filtered = content.map((block) => {
128
+ if (!block || typeof block !== "object") return block;
129
+ const text = (block as { type?: unknown; text?: unknown }).text;
130
+ if ((block as { type?: unknown }).type !== "text" || typeof text !== "string") return block;
131
+ const kept: string[] = [];
132
+ let blockRedactedLines = 0;
133
+ for (const line of text.split(/\r?\n/)) {
134
+ if (blocked(line)) {
135
+ changed = true;
136
+ redactedLines += 1;
137
+ blockRedactedLines += 1;
138
+ } else {
139
+ kept.push(line);
140
+ }
141
+ }
142
+ if (blockRedactedLines === 0) return block;
143
+ const notice = noticeText(blockRedactedLines);
144
+ const nextText = kept.join("\n").trim().length > 0
145
+ ? `${kept.join("\n")}\n${notice}`
146
+ : `${emptyText}\n${notice}`;
147
+ return { ...block, text: nextText };
148
+ });
149
+ return { changed, content: filtered, redactedLines };
150
+ }
151
+
152
+ export function filterGrepProtectedContent(content: unknown, protectedPatterns: string[]) {
153
+ return filterTextBlocks(
154
+ content,
155
+ (line) => {
156
+ const linePath = line.match(/^(.+?)(?::\d+:|-\d+-)/)?.[1];
157
+ return Boolean(linePath && matchesProtectedPath(linePath, protectedPatterns));
158
+ },
159
+ "No matches found in non-protected paths.",
160
+ (count) => `[Piagent Pi guard redacted ${count} protected grep line${count === 1 ? "" : "s"}.]`
161
+ );
162
+ }
163
+
164
+ export function filterProtectedPathListContent(
165
+ cwd: string,
166
+ content: unknown,
167
+ protectedPatterns: string[],
168
+ basePath: string,
169
+ toolName: string
170
+ ) {
171
+ return filterTextBlocks(
172
+ content,
173
+ (line) => {
174
+ const trimmed = line.trim();
175
+ if (!trimmed || trimmed.startsWith("[") || trimmed.startsWith("No ") || trimmed === "(empty directory)") return false;
176
+ const entry = trimmed.replace(/[\\/]+$/, "");
177
+ const candidates = new Set<string>();
178
+ const direct = normalizeRelative(cwd, entry);
179
+ const underBase = normalizeRelative(cwd, path.posix.join(basePath || ".", entry));
180
+ if (direct !== undefined) candidates.add(direct);
181
+ if (underBase !== undefined) candidates.add(underBase);
182
+ return [...candidates].some((candidate) => matchesProtectedPath(candidate, protectedPatterns));
183
+ },
184
+ "No entries found in non-protected paths.",
185
+ (count) => `[Piagent Pi guard redacted ${count} protected ${toolName} line${count === 1 ? "" : "s"}.]`
186
+ );
187
+ }
188
+
189
+ export function registerToolResultHook(pi: ExtensionAPI, dependencies: ToolResultHookDependencies): void {
190
+ pi.on("tool_result", async (event, ctx) => {
191
+ const readProtectedPaths = dependencies.readProtectedPaths(ctx);
192
+ const observed = observedBashResultFromToolResultEvent(event, ctx.cwd);
193
+ if (observed) {
194
+ dependencies.recordObservedBash(observed);
195
+ try {
196
+ appendObservedBashResult(dependencies.observedBashLedgerPath(ctx.cwd), {
197
+ ...observed,
198
+ redactedCommand: dependencies.redactText(observed.command)
199
+ }, { projectRoot: ctx.cwd });
200
+ } catch (error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ ctx.ui.notify(`Piagent Pi guard could not persist bash evidence ledger: ${message}`, "warn");
203
+ }
204
+ }
205
+
206
+ const observedContextEntry = dependencies.observedTaskContext(ctx.cwd, event, readProtectedPaths);
207
+ if (observedContextEntry) dependencies.state.rememberObservedContext(ctx, observedContextEntry);
208
+ const pendingContext = dependencies.state.observedContext(ctx);
209
+ const shellSnapshotBefore = dependencies.isShellTool(event.toolName)
210
+ ? dependencies.state.consumeShellMutationSnapshot(ctx, event.toolName, event.input)
211
+ : undefined;
212
+ dependencies.recordObservedTaskChanges(pi, ctx, event, pendingContext, dependencies.maxManifestFiles, shellSnapshotBefore);
213
+ if (observed) {
214
+ dependencies.recordObservedTaskVerification(pi, ctx, observed, pendingContext, dependencies.maxManifestFiles);
215
+ }
216
+
217
+ let resultContent: unknown = event.content;
218
+ let resultDetails: unknown = event.details;
219
+ let resultChanged = false;
220
+ if (event.toolName === "grep") {
221
+ const filtered = filterGrepProtectedContent(resultContent, readProtectedPaths);
222
+ if (filtered.changed) {
223
+ resultContent = filtered.content;
224
+ resultDetails = isPlainRecord(resultDetails)
225
+ ? { ...resultDetails, protectedMatchesRedacted: filtered.redactedLines }
226
+ : { protectedMatchesRedacted: filtered.redactedLines };
227
+ resultChanged = true;
228
+ }
229
+ }
230
+ if (event.toolName === "find" || event.toolName === "ls") {
231
+ const input = isPlainRecord(event.input) ? event.input : {};
232
+ const basePath = dependencies.extractLikelyPath(ctx.cwd, input) || ".";
233
+ const filtered = filterProtectedPathListContent(ctx.cwd, resultContent, readProtectedPaths, basePath, event.toolName);
234
+ if (filtered.changed) {
235
+ resultContent = filtered.content;
236
+ resultDetails = isPlainRecord(resultDetails)
237
+ ? { ...resultDetails, protectedPathsRedacted: filtered.redactedLines }
238
+ : { protectedPathsRedacted: filtered.redactedLines };
239
+ resultChanged = true;
240
+ }
241
+ }
242
+
243
+ const safeContent = redactToolResultTextContent(resultContent);
244
+ const safeDetails = redactForStorage(resultDetails);
245
+ const sensitiveValuesRedacted = safeContent.redacted + countChangedStringLeaves(resultDetails, safeDetails);
246
+ if (sensitiveValuesRedacted > 0) {
247
+ resultContent = safeContent.content;
248
+ resultDetails = isPlainRecord(safeDetails) ? { ...safeDetails, sensitiveValuesRedacted } : safeDetails;
249
+ resultChanged = true;
250
+ }
251
+
252
+ const fingerprint = toolResultFingerprint(event.toolName, event.input, resultContent);
253
+ const previousFingerprint = dependencies.state.previousToolResult(ctx, fingerprint.key);
254
+ const repeated = previousFingerprint?.outputHash === fingerprint.outputHash;
255
+ dependencies.state.rememberToolResult(ctx, fingerprint.key, {
256
+ outputHash: fingerprint.outputHash,
257
+ recordedAt: dependencies.now()
258
+ });
259
+ if (repeated && ["read", "grep", "find", "ls"].includes(event.toolName) && fingerprint.outputChars > 0) {
260
+ resultContent = [{
261
+ type: "text",
262
+ text: `[Piagent delta: unchanged ${event.toolName} result; ${fingerprint.outputChars} chars / ${fingerprint.outputLines} lines match the previous identical call.]`
263
+ }];
264
+ const delta = {
265
+ unchanged: true,
266
+ previousRecordedAt: previousFingerprint?.recordedAt,
267
+ outputHash: fingerprint.outputHash,
268
+ originalChars: fingerprint.outputChars,
269
+ originalLines: fingerprint.outputLines
270
+ };
271
+ resultDetails = isPlainRecord(resultDetails)
272
+ ? { ...resultDetails, piagentDelta: delta }
273
+ : { value: resultDetails, piagentDelta: delta };
274
+ resultChanged = true;
275
+ }
276
+
277
+ const captureCache = new Map<string, ToolResultCaptureSummary>();
278
+ const compactedContent = compactToolResultTextContent(ctx.cwd, event, ctx, resultContent, captureCache);
279
+ const compactionCaptures = [...compactedContent.captures];
280
+ if (compactedContent.captures.length > 0) {
281
+ resultContent = compactedContent.content;
282
+ resultChanged = true;
283
+ }
284
+ if (resultDetails !== undefined) {
285
+ const compactedDetails = compactToolResultDetails(ctx.cwd, event, ctx, resultDetails, captureCache, compactionCaptures);
286
+ if (compactionCaptures.length > compactedContent.captures.length) {
287
+ resultDetails = compactedDetails;
288
+ resultChanged = true;
289
+ }
290
+ }
291
+ if (compactionCaptures.length > 0) {
292
+ resultDetails = attachToolResultCompactionDetails(resultDetails, compactionCaptures);
293
+ }
294
+
295
+ dependencies.telemetry(ctx, {
296
+ event: "tool_result",
297
+ toolName: event.toolName,
298
+ inputHash: fingerprint.inputHash,
299
+ outputHash: fingerprint.outputHash,
300
+ outputChars: fingerprint.outputChars,
301
+ outputLines: fingerprint.outputLines,
302
+ repeated,
303
+ compacted: compactionCaptures.length > 0,
304
+ compactedCaptures: compactionCaptures.length,
305
+ sensitiveValuesRedacted,
306
+ isError: event.isError,
307
+ usage: event.usage
308
+ });
309
+
310
+ if (resultChanged) {
311
+ return resultDetails === undefined
312
+ ? { content: resultContent }
313
+ : { content: resultContent, details: resultDetails };
314
+ }
315
+ });
316
+ }