@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.
- package/CHANGELOG.md +26 -0
- package/README.md +11 -3
- package/README.vi.md +90 -0
- package/architecture/layers.json +117 -0
- package/docs/README.md +23 -0
- package/docs/architecture.md +7 -61
- package/docs/command-reference-vietnamese.md +6 -6
- package/docs/distribution-standard.md +7 -7
- package/docs/en/README.md +13 -0
- package/docs/en/architecture.md +81 -0
- package/docs/en/maintainer-guide.md +121 -0
- package/docs/languages.json +40 -0
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/package-architecture-notes.md +9 -0
- package/docs/release-install-policy.md +6 -6
- package/docs/security-threat-model.md +1 -1
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/README.md +13 -0
- package/docs/vi/architecture.md +81 -0
- package/docs/vi/maintainer-guide.md +121 -0
- package/docs-site/architecture.html +38 -14
- package/docs-site/benchmark.html +3 -1
- package/docs-site/commands.html +3 -1
- package/docs-site/content/architecture.html +35 -13
- package/docs-site/context-engine.html +3 -1
- package/docs-site/index.html +3 -1
- package/docs-site/mcp-approval.html +3 -1
- package/docs-site/mcp-auth.html +3 -1
- package/docs-site/mcp-servers.html +3 -1
- package/docs-site/mcp.html +3 -1
- package/docs-site/permissions.html +3 -1
- package/docs-site/profiles.html +3 -1
- package/docs-site/quickstart.html +3 -1
- package/docs-site/runtime.html +3 -1
- package/docs-site/scope.html +3 -1
- package/docs-site/security.html +3 -1
- package/docs-site/team.html +3 -1
- package/docs-site/workflows.html +3 -1
- package/package.json +5 -1
- package/packages/piagent-core/README.md +6 -2
- package/packages/piagent-core/capabilities/capability-core.js +3 -0
- package/packages/piagent-core/capabilities/runtime-integrity.js +18 -0
- package/packages/piagent-core/extensions/guard-types.ts +0 -37
- package/packages/piagent-core/extensions/piagent-guard.ts +170 -2099
- package/packages/piagent-core/extensions/task-state.js +22 -3
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +213 -0
- package/packages/piagent-core/runtime/hooks/completion-hook.ts +203 -0
- package/packages/piagent-core/runtime/hooks/input-hook.ts +122 -0
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +104 -0
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +140 -0
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +316 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +299 -0
- package/packages/piagent-core/runtime/runtime-limits.ts +21 -0
- package/packages/piagent-core/runtime/session/message-signals.ts +64 -0
- package/packages/piagent-core/runtime/session/runtime-state.ts +175 -0
- package/packages/piagent-core/runtime/session/system-prompt.ts +52 -0
- package/packages/piagent-core/runtime/session/tool-result-compaction.ts +352 -0
- package/packages/piagent-core/runtime/session/usage.ts +178 -0
- package/packages/piagent-core/runtime/tools/tool-groups.ts +86 -0
- package/packages/piagent-core/runtime/workflows/input-routing.ts +113 -0
- package/packages/piagent-core/runtime/workflows/task-intake.ts +73 -0
- package/scripts/build-docs-site.mjs +4 -1
- package/scripts/check-architecture.mjs +132 -0
- package/scripts/check-doc-languages.mjs +67 -0
- 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
|
+
}
|