@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
|
@@ -123,7 +123,9 @@ function pickFields(value, allowed) {
|
|
|
123
123
|
function legacyRunId(task, sourceName = "task") {
|
|
124
124
|
const taskId = safeTaskId(task?.taskId ?? sourceName);
|
|
125
125
|
const identity = `${taskId}\u0000${task?.createdAt ?? "legacy"}\u0000${sourceName}`;
|
|
126
|
-
|
|
126
|
+
const suffix = `legacy-${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
|
|
127
|
+
const taskKey = taskId.slice(0, 80 - suffix.length - 1).replace(/-+$/g, "") || "task";
|
|
128
|
+
return `${taskKey}-${suffix}`;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
export function taskContractValidationErrors(input) {
|
|
@@ -620,10 +622,27 @@ export function migrateTaskState(cwd, options = {}) {
|
|
|
620
622
|
sessionName: belongsToCurrentSession ? options.sessionName : undefined
|
|
621
623
|
});
|
|
622
624
|
if (!task) continue;
|
|
623
|
-
writeTaskContract(cwd, task);
|
|
624
625
|
const safeArchiveRoot = ensurePrivateStateDirectory(cwd, archiveRoot, "Task state archive");
|
|
625
626
|
const archiveTarget = resolveLocalStatePath(cwd, path.join(safeArchiveRoot, entry.name), { label: "Task state archive file" });
|
|
626
|
-
fs.
|
|
627
|
+
if (fs.existsSync(archiveTarget)) {
|
|
628
|
+
const archived = fs.readFileSync(archiveTarget);
|
|
629
|
+
const legacy = fs.readFileSync(source);
|
|
630
|
+
if (!archived.equals(legacy)) {
|
|
631
|
+
warnings.push(`legacy task archive conflict: ${entry.name}`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
fs.copyFileSync(source, archiveTarget, fs.constants.COPYFILE_EXCL);
|
|
636
|
+
fs.chmodSync(archiveTarget, 0o600);
|
|
637
|
+
}
|
|
638
|
+
const written = writeTaskContract(cwd, task);
|
|
639
|
+
const currentTarget = taskRunPath(cwd, written.taskRunId);
|
|
640
|
+
const sourceStat = fs.statSync(source);
|
|
641
|
+
const targetStat = fs.statSync(currentTarget);
|
|
642
|
+
const sameInode = sourceStat.ino > 0 && targetStat.ino > 0
|
|
643
|
+
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
|
|
644
|
+
const sameCanonicalPath = fs.realpathSync(source) === fs.realpathSync(currentTarget);
|
|
645
|
+
if (!sameInode && !sameCanonicalPath) fs.rmSync(source);
|
|
627
646
|
migrated += 1;
|
|
628
647
|
}
|
|
629
648
|
return { migrated, current, warnings };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildContextPack,
|
|
6
|
+
classifyContextTask,
|
|
7
|
+
ensureContextIndexV2,
|
|
8
|
+
estimateContextTokens
|
|
9
|
+
} from "../../extensions/context-engine.js";
|
|
10
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
11
|
+
import type { TaskContract } from "../../extensions/guard-types.js";
|
|
12
|
+
import { RuntimeSessionState } from "../session/runtime-state.ts";
|
|
13
|
+
import {
|
|
14
|
+
compactManagedProjectInstructions,
|
|
15
|
+
rewriteLegacyProjectInstructions
|
|
16
|
+
} from "../session/system-prompt.ts";
|
|
17
|
+
import { PIAGENT_TOOL_NAMES } from "../tools/tool-groups.ts";
|
|
18
|
+
import { extractTaskRequest, looksLikeGovernedBoilerplate } from "../workflows/input-routing.ts";
|
|
19
|
+
import {
|
|
20
|
+
AUTO_INTAKE_SNAPSHOT_PATTERNS,
|
|
21
|
+
automaticTaskIntakeEligible
|
|
22
|
+
} from "../workflows/task-intake.ts";
|
|
23
|
+
|
|
24
|
+
type RuntimeIntakeResult = {
|
|
25
|
+
started: boolean;
|
|
26
|
+
text: string;
|
|
27
|
+
task?: TaskContract;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type AgentStartHookDependencies = {
|
|
31
|
+
state: RuntimeSessionState;
|
|
32
|
+
autoContextEnabled: boolean;
|
|
33
|
+
activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
|
|
34
|
+
readProtectedPaths: (ctx: ExtensionContext) => string[];
|
|
35
|
+
contextExcludePatterns: (ctx: ExtensionContext) => string[];
|
|
36
|
+
promptPackKey: (ctx: ExtensionContext, promptHash: string) => string;
|
|
37
|
+
retrievalKey: (ctx: ExtensionContext, query: string) => string;
|
|
38
|
+
startAutomaticTask: (query: string, ctx: ExtensionContext) => Promise<RuntimeIntakeResult | undefined>;
|
|
39
|
+
telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function registerAgentStartHook(pi: ExtensionAPI, dependencies: AgentStartHookDependencies): void {
|
|
43
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
44
|
+
const projectInstructions = rewriteLegacyProjectInstructions(event.systemPrompt);
|
|
45
|
+
const query = looksLikeGovernedBoilerplate(event.prompt) ? extractTaskRequest(event.prompt) : event.prompt.trim();
|
|
46
|
+
const signal = classifyContextTask(query);
|
|
47
|
+
const readProtectedPaths = dependencies.readProtectedPaths(ctx);
|
|
48
|
+
const protectedOnlyTarget = signal.paths.length > 0
|
|
49
|
+
&& signal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
50
|
+
const activeTask = dependencies.activeTask(ctx);
|
|
51
|
+
const runtimeIntake = !activeTask && automaticTaskIntakeEligible(query, readProtectedPaths);
|
|
52
|
+
const compactMode = protectedOnlyTarget
|
|
53
|
+
? "protected"
|
|
54
|
+
: runtimeIntake || activeTask?.intakeMode === "runtime"
|
|
55
|
+
? "automatic"
|
|
56
|
+
: undefined;
|
|
57
|
+
const compactedInstructions = compactMode
|
|
58
|
+
? compactManagedProjectInstructions(projectInstructions.systemPrompt, compactMode)
|
|
59
|
+
: { systemPrompt: projectInstructions.systemPrompt, compacted: false };
|
|
60
|
+
const effectiveSystemPrompt = compactedInstructions.systemPrompt;
|
|
61
|
+
const systemPromptUpdate = effectiveSystemPrompt !== event.systemPrompt
|
|
62
|
+
? { systemPrompt: effectiveSystemPrompt }
|
|
63
|
+
: undefined;
|
|
64
|
+
const active = new Set<string>(pi.getActiveTools() as string[]);
|
|
65
|
+
const toolMetadata = pi.getAllTools()
|
|
66
|
+
.filter((tool) => active.has(tool.name))
|
|
67
|
+
.map((tool) => ({
|
|
68
|
+
name: tool.name,
|
|
69
|
+
description: tool.description,
|
|
70
|
+
parameters: tool.parameters,
|
|
71
|
+
promptGuidelines: tool.promptGuidelines
|
|
72
|
+
}));
|
|
73
|
+
const toolSchemaTokens = estimateContextTokens(JSON.stringify(toolMetadata));
|
|
74
|
+
const systemPromptTokens = estimateContextTokens(effectiveSystemPrompt);
|
|
75
|
+
const autoPackUseful = activeTask?.trace.outcome !== "pending"
|
|
76
|
+
&& (runtimeIntake || signal.paths.length === 0);
|
|
77
|
+
dependencies.telemetry(ctx, {
|
|
78
|
+
event: "agent_prompt",
|
|
79
|
+
promptHash: signal.promptHash,
|
|
80
|
+
promptChars: signal.promptChars,
|
|
81
|
+
workflow: signal.workflow,
|
|
82
|
+
riskLane: signal.lane,
|
|
83
|
+
activeTools: active.size,
|
|
84
|
+
activePiagentTools: [...active].filter((toolName) => PIAGENT_TOOL_NAMES.has(toolName)).length,
|
|
85
|
+
toolSchemaTokens,
|
|
86
|
+
systemPromptTokens,
|
|
87
|
+
systemPromptHash: crypto.createHash("sha256").update(effectiveSystemPrompt).digest("hex"),
|
|
88
|
+
legacyProjectInstructionsRewritten: projectInstructions.rewritten,
|
|
89
|
+
managedInstructionsCompacted: compactedInstructions.compacted,
|
|
90
|
+
contextUsage: ctx.getContextUsage()
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const finishAgentStart = async (contextMessage?: {
|
|
94
|
+
customType: string;
|
|
95
|
+
content: string;
|
|
96
|
+
details: Record<string, unknown>;
|
|
97
|
+
}) => {
|
|
98
|
+
const intake = await dependencies.startAutomaticTask(query, ctx);
|
|
99
|
+
if (!contextMessage && !intake) return systemPromptUpdate;
|
|
100
|
+
const content = [contextMessage?.content, intake?.text].filter(Boolean).join("\n\n");
|
|
101
|
+
return {
|
|
102
|
+
...(systemPromptUpdate ?? {}),
|
|
103
|
+
message: {
|
|
104
|
+
customType: contextMessage?.customType ?? "piagent-runtime-task-intake",
|
|
105
|
+
content,
|
|
106
|
+
display: false,
|
|
107
|
+
details: {
|
|
108
|
+
...(contextMessage?.details ?? {}),
|
|
109
|
+
runtimeTask: intake?.task
|
|
110
|
+
? {
|
|
111
|
+
taskId: intake.task.taskId,
|
|
112
|
+
taskRunId: intake.task.taskRunId,
|
|
113
|
+
scope: intake.task.scope,
|
|
114
|
+
verifyCommands: intake.task.verifyCommands,
|
|
115
|
+
intakeMode: intake.task.intakeMode
|
|
116
|
+
}
|
|
117
|
+
: undefined,
|
|
118
|
+
runtimeIntakeStarted: intake?.started ?? false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const packKey = dependencies.promptPackKey(ctx, signal.promptHash);
|
|
125
|
+
if (
|
|
126
|
+
query.length < 20
|
|
127
|
+
|| signal.workflow === "usage"
|
|
128
|
+
|| !dependencies.autoContextEnabled
|
|
129
|
+
|| !autoPackUseful
|
|
130
|
+
|| dependencies.state.hasAutoPackedPrompt(packKey)
|
|
131
|
+
) {
|
|
132
|
+
return finishAgentStart();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dependencies.state.rememberAutoPackedPrompt(packKey);
|
|
136
|
+
try {
|
|
137
|
+
const excludePatterns = dependencies.contextExcludePatterns(ctx);
|
|
138
|
+
const ensured = await ensureContextIndexV2(ctx.cwd, {
|
|
139
|
+
excludePatterns,
|
|
140
|
+
rebuildMissing: false
|
|
141
|
+
});
|
|
142
|
+
const status = ensured.status;
|
|
143
|
+
if (!status.exists || status.stale) {
|
|
144
|
+
dependencies.telemetry(ctx, {
|
|
145
|
+
event: "context_pack",
|
|
146
|
+
queryHash: signal.promptHash,
|
|
147
|
+
confidence: "none",
|
|
148
|
+
candidates: 0,
|
|
149
|
+
selected: 0,
|
|
150
|
+
skipped: status.exists ? "stale-index" : "missing-index"
|
|
151
|
+
});
|
|
152
|
+
return finishAgentStart();
|
|
153
|
+
}
|
|
154
|
+
const budgetTokens = runtimeIntake
|
|
155
|
+
? 900
|
|
156
|
+
: signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
|
|
157
|
+
const pack = await buildContextPack(ctx.cwd, query, {
|
|
158
|
+
budgetTokens,
|
|
159
|
+
includeCode: runtimeIntake,
|
|
160
|
+
includePatterns: runtimeIntake ? AUTO_INTAKE_SNAPSHOT_PATTERNS : undefined,
|
|
161
|
+
currentSnapshot: runtimeIntake,
|
|
162
|
+
limit: runtimeIntake ? 8 : 12,
|
|
163
|
+
excludePatterns
|
|
164
|
+
});
|
|
165
|
+
dependencies.telemetry(ctx, {
|
|
166
|
+
event: "context_pack",
|
|
167
|
+
queryHash: pack.queryHash,
|
|
168
|
+
confidence: pack.confidence,
|
|
169
|
+
candidates: pack.candidates,
|
|
170
|
+
selected: pack.selected.length,
|
|
171
|
+
estimatedTokens: pack.estimatedTokens,
|
|
172
|
+
selectedPaths: pack.selected.map((item) => item.path),
|
|
173
|
+
finderRecommended: pack.finderRecommended,
|
|
174
|
+
currentSnapshot: runtimeIntake
|
|
175
|
+
});
|
|
176
|
+
if (!["medium", "high"].includes(pack.confidence) || pack.selected.length === 0) return finishAgentStart();
|
|
177
|
+
dependencies.state.rememberInjectedContextPack(ctx, dependencies.retrievalKey(ctx, query), {
|
|
178
|
+
queryHash: pack.queryHash,
|
|
179
|
+
confidence: pack.confidence,
|
|
180
|
+
estimatedTokens: pack.estimatedTokens,
|
|
181
|
+
paths: pack.selected.map((item) => item.path)
|
|
182
|
+
});
|
|
183
|
+
for (const selected of pack.selected) {
|
|
184
|
+
dependencies.state.rememberObservedContext(ctx, {
|
|
185
|
+
path: selected.path,
|
|
186
|
+
reason: "Runtime injected a bounded Context Engine navigation pack."
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return finishAgentStart({
|
|
190
|
+
customType: "piagent-context-pack-v2",
|
|
191
|
+
content: pack.text,
|
|
192
|
+
details: {
|
|
193
|
+
schemaVersion: 2,
|
|
194
|
+
queryHash: pack.queryHash,
|
|
195
|
+
confidence: pack.confidence,
|
|
196
|
+
estimatedTokens: pack.estimatedTokens,
|
|
197
|
+
paths: pack.selected.map((item) => item.path),
|
|
198
|
+
currentSnapshot: runtimeIntake
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
dependencies.telemetry(ctx, {
|
|
203
|
+
event: "context_pack",
|
|
204
|
+
queryHash: signal.promptHash,
|
|
205
|
+
confidence: "none",
|
|
206
|
+
candidates: 0,
|
|
207
|
+
selected: 0,
|
|
208
|
+
error: error instanceof Error ? error.message : String(error)
|
|
209
|
+
});
|
|
210
|
+
return finishAgentStart();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
import type { TaskContract } from "../../extensions/guard-types.js";
|
|
5
|
+
import { runtimeLifecycleMode, workingTreeEvidenceDigest } from "../../extensions/task-lifecycle.js";
|
|
6
|
+
import { workingTreeSnapshot } from "../../extensions/task-state.js";
|
|
7
|
+
import {
|
|
8
|
+
assistantMessageHasToolCall,
|
|
9
|
+
assistantMessageText,
|
|
10
|
+
looksLikeCompletionClaim,
|
|
11
|
+
looksLikeIncompleteHandoff
|
|
12
|
+
} from "../session/message-signals.ts";
|
|
13
|
+
import { RuntimeSessionState } from "../session/runtime-state.ts";
|
|
14
|
+
import type { ObservedTaskContext } from "../session/runtime-state.ts";
|
|
15
|
+
|
|
16
|
+
type CompletionGate = {
|
|
17
|
+
decision: "pass" | "fail";
|
|
18
|
+
missing: string[];
|
|
19
|
+
missingVerifyCommands: string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type CompletionHookDependencies = {
|
|
23
|
+
state: RuntimeSessionState;
|
|
24
|
+
maxManifestFiles: number;
|
|
25
|
+
autoRecoveryEnabled: boolean;
|
|
26
|
+
activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
|
|
27
|
+
flushObservedTaskContext: (
|
|
28
|
+
pi: ExtensionAPI,
|
|
29
|
+
ctx: ExtensionContext,
|
|
30
|
+
pendingContext: ObservedTaskContext[],
|
|
31
|
+
maxManifestFiles: number,
|
|
32
|
+
event: string
|
|
33
|
+
) => TaskContract | undefined;
|
|
34
|
+
completionProjection: (
|
|
35
|
+
cwd: string,
|
|
36
|
+
task: TaskContract,
|
|
37
|
+
currentDigests: Record<string, string>
|
|
38
|
+
) => TaskContract;
|
|
39
|
+
evaluateGate: (
|
|
40
|
+
cwd: string,
|
|
41
|
+
task: TaskContract,
|
|
42
|
+
currentDigests: Record<string, string>,
|
|
43
|
+
currentDigest: string
|
|
44
|
+
) => CompletionGate;
|
|
45
|
+
writeTask: (cwd: string, task: TaskContract) => TaskContract;
|
|
46
|
+
activateBaseTools: (ctx: ExtensionContext) => unknown;
|
|
47
|
+
appendTrace: (cwd: string, payload: Record<string, unknown>) => void;
|
|
48
|
+
appendSessionTrace: (pi: ExtensionAPI, payload: Record<string, unknown>) => void;
|
|
49
|
+
telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
|
|
50
|
+
finalGateMode: (ctx: ExtensionContext) => string;
|
|
51
|
+
verifierInstructions: (commands: string[]) => string[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function registerCompletionHook(pi: ExtensionAPI, dependencies: CompletionHookDependencies): void {
|
|
55
|
+
const {
|
|
56
|
+
state,
|
|
57
|
+
maxManifestFiles,
|
|
58
|
+
autoRecoveryEnabled,
|
|
59
|
+
activeTask,
|
|
60
|
+
flushObservedTaskContext,
|
|
61
|
+
completionProjection,
|
|
62
|
+
evaluateGate,
|
|
63
|
+
writeTask,
|
|
64
|
+
activateBaseTools,
|
|
65
|
+
appendTrace,
|
|
66
|
+
appendSessionTrace,
|
|
67
|
+
telemetry,
|
|
68
|
+
finalGateMode,
|
|
69
|
+
verifierInstructions
|
|
70
|
+
} = dependencies;
|
|
71
|
+
|
|
72
|
+
pi.on("message_end", async (event, ctx) => {
|
|
73
|
+
if (!event.message || event.message.role !== "assistant" || assistantMessageHasToolCall(event.message)) return;
|
|
74
|
+
let task = flushObservedTaskContext(
|
|
75
|
+
pi,
|
|
76
|
+
ctx,
|
|
77
|
+
state.observedContext(ctx),
|
|
78
|
+
maxManifestFiles,
|
|
79
|
+
"context_observed_before_handoff"
|
|
80
|
+
) ?? activeTask(ctx);
|
|
81
|
+
if (!task) return;
|
|
82
|
+
const text = assistantMessageText(event.message);
|
|
83
|
+
const completionClaim = looksLikeCompletionClaim(text);
|
|
84
|
+
const incompleteHandoff = looksLikeIncompleteHandoff(text);
|
|
85
|
+
if (task.trace.outcome !== "pending") return;
|
|
86
|
+
const readOnlyEvidenceObserved = task.changeMode === "read-only" && task.contextManifest.length > 0;
|
|
87
|
+
const potentiallyFinalEvidence = task.observedChangedFiles.length > 0
|
|
88
|
+
|| task.verifyEvidence.some((evidence) => evidence.exitCode === 0 && evidence.observed === true && evidence.matchedProfileCommand === true)
|
|
89
|
+
|| readOnlyEvidenceObserved;
|
|
90
|
+
if (!completionClaim && (incompleteHandoff || !potentiallyFinalEvidence)) return;
|
|
91
|
+
|
|
92
|
+
const currentDigests = workingTreeSnapshot(ctx.cwd) as Record<string, string>;
|
|
93
|
+
const currentDigest = workingTreeEvidenceDigest(currentDigests);
|
|
94
|
+
const currentPassingVerifierObserved = task.verifyEvidence.some((evidence) => (
|
|
95
|
+
evidence.exitCode === 0
|
|
96
|
+
&& evidence.observed === true
|
|
97
|
+
&& evidence.matchedProfileCommand === true
|
|
98
|
+
&& evidence.workingTreeDigest === currentDigest
|
|
99
|
+
));
|
|
100
|
+
const handoffAttempt = completionClaim || (
|
|
101
|
+
!incompleteHandoff
|
|
102
|
+
&& (task.observedChangedFiles.length > 0 || currentPassingVerifierObserved || readOnlyEvidenceObserved)
|
|
103
|
+
);
|
|
104
|
+
let completionGate: CompletionGate | undefined;
|
|
105
|
+
if (handoffAttempt) {
|
|
106
|
+
const projected = completionProjection(ctx.cwd, task, currentDigests);
|
|
107
|
+
const projectedGate = evaluateGate(ctx.cwd, projected, currentDigests, currentDigest);
|
|
108
|
+
completionGate = projectedGate;
|
|
109
|
+
if (projectedGate.decision === "pass") {
|
|
110
|
+
task = writeTask(ctx.cwd, projected);
|
|
111
|
+
state.cacheTaskIdentity(ctx, task);
|
|
112
|
+
state.clearCompletionRecoveryAttempt(task.taskRunId);
|
|
113
|
+
state.clearObservedContext(ctx);
|
|
114
|
+
activateBaseTools(ctx);
|
|
115
|
+
const trace = {
|
|
116
|
+
event: "task_auto_completed",
|
|
117
|
+
taskId: task.taskId,
|
|
118
|
+
taskRunId: task.taskRunId,
|
|
119
|
+
sessionId: task.sessionId,
|
|
120
|
+
changedFiles: task.changedFiles,
|
|
121
|
+
lifecycleMode: runtimeLifecycleMode(task)
|
|
122
|
+
};
|
|
123
|
+
appendTrace(ctx.cwd, trace);
|
|
124
|
+
appendSessionTrace(pi, trace);
|
|
125
|
+
telemetry(ctx, trace);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!handoffAttempt || finalGateMode(ctx) !== "enforce") return;
|
|
130
|
+
const gate = completionGate ?? evaluateGate(ctx.cwd, task, currentDigests, currentDigest);
|
|
131
|
+
if (gate.decision === "pass") return;
|
|
132
|
+
|
|
133
|
+
const recoveryAttempt = state.completionRecoveryAttempt(task.taskRunId);
|
|
134
|
+
const lifecycleMode = runtimeLifecycleMode(task);
|
|
135
|
+
if (autoRecoveryEnabled && lifecycleMode !== "manual" && recoveryAttempt < 1) {
|
|
136
|
+
state.rememberCompletionRecoveryAttempt(task.taskRunId, recoveryAttempt + 1);
|
|
137
|
+
const recoveryGuidance = task.changeMode === "read-only"
|
|
138
|
+
? [
|
|
139
|
+
"Continue the same read-only task. Review the cited evidence against the requested scope, state concrete unknowns, and complete the returned review step when required.",
|
|
140
|
+
"Do not mutate project source or repeat diagnostic Piagent tools; use ordinary targeted reads and the active progress tool only."
|
|
141
|
+
]
|
|
142
|
+
: [
|
|
143
|
+
"Continue the same bounded task. Re-check the requested behavior against current source, make a relevant in-scope change when required, and run every exact configured verifier.",
|
|
144
|
+
...verifierInstructions(gate.missingVerifyCommands),
|
|
145
|
+
"A pre-existing passing test without a relevant source diff is not completion. Do not repeat diagnostic Piagent tools; use ordinary read/edit/bash work."
|
|
146
|
+
];
|
|
147
|
+
const recovery = [
|
|
148
|
+
"[Piagent continuation required]",
|
|
149
|
+
`Task ${task.taskId} cannot finish yet. Missing: ${gate.missing.join(", ")}.`,
|
|
150
|
+
...recoveryGuidance
|
|
151
|
+
].join("\n");
|
|
152
|
+
pi.sendMessage(
|
|
153
|
+
{ customType: "piagent-completion-recovery", content: recovery, display: false, details: { taskId: task.taskId, missing: gate.missing } },
|
|
154
|
+
{ deliverAs: "followUp", triggerTurn: true }
|
|
155
|
+
);
|
|
156
|
+
const recoveryTrace = {
|
|
157
|
+
event: "completion_recovery_scheduled",
|
|
158
|
+
taskId: task.taskId,
|
|
159
|
+
taskRunId: task.taskRunId,
|
|
160
|
+
sessionId: task.sessionId,
|
|
161
|
+
attempt: recoveryAttempt + 1,
|
|
162
|
+
missing: gate.missing
|
|
163
|
+
};
|
|
164
|
+
appendTrace(ctx.cwd, recoveryTrace);
|
|
165
|
+
appendSessionTrace(pi, recoveryTrace);
|
|
166
|
+
telemetry(ctx, recoveryTrace);
|
|
167
|
+
const continuingNotice = [
|
|
168
|
+
"[Piagent completion gate: CONTINUING]",
|
|
169
|
+
`Task ${task.taskId} needs one bounded correction pass before handoff.`,
|
|
170
|
+
""
|
|
171
|
+
].join("\n");
|
|
172
|
+
const content = Array.isArray(event.message.content)
|
|
173
|
+
? [{ type: "text" as const, text: continuingNotice }, ...event.message.content]
|
|
174
|
+
: [{ type: "text" as const, text: `${continuingNotice}${text}` }];
|
|
175
|
+
return { message: { ...event.message, content } };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const notice = [
|
|
179
|
+
"[Piagent completion gate: NOT APPROVED]",
|
|
180
|
+
`Task ${task.taskId} (${task.taskRunId}) is still open.`,
|
|
181
|
+
`Missing: ${gate.missing.join(", ") || "a completed task trace"}.`,
|
|
182
|
+
...verifierInstructions(gate.missingVerifyCommands),
|
|
183
|
+
"The response below is preserved as work in progress and must not be treated as a completion report.",
|
|
184
|
+
""
|
|
185
|
+
].join("\n");
|
|
186
|
+
const content = Array.isArray(event.message.content)
|
|
187
|
+
? [{ type: "text" as const, text: notice }, ...event.message.content]
|
|
188
|
+
: [{ type: "text" as const, text: `${notice}${text}` }];
|
|
189
|
+
const trace = {
|
|
190
|
+
event: "completion_claim_blocked",
|
|
191
|
+
taskId: task.taskId,
|
|
192
|
+
taskRunId: task.taskRunId,
|
|
193
|
+
sessionId: task.sessionId,
|
|
194
|
+
missing: gate.missing,
|
|
195
|
+
responseHash: crypto.createHash("sha256").update(text).digest("hex"),
|
|
196
|
+
responseChars: text.length
|
|
197
|
+
};
|
|
198
|
+
appendTrace(ctx.cwd, trace);
|
|
199
|
+
appendSessionTrace(pi, trace);
|
|
200
|
+
telemetry(ctx, trace);
|
|
201
|
+
return { message: { ...event.message, content } };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { classifyContextTask } from "../../extensions/context-engine.js";
|
|
4
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
5
|
+
import type { TaskContract } from "../../extensions/guard-types.js";
|
|
6
|
+
import { attachLocalImagesFromText } from "../input/chat-images.ts";
|
|
7
|
+
import type { ChatImageAccessPolicy } from "../input/chat-images.ts";
|
|
8
|
+
import { LONG_INPUT_CHARS } from "../runtime-limits.ts";
|
|
9
|
+
import { buildContextPreflight, buildUsageSnapshot } from "../session/usage.ts";
|
|
10
|
+
import { activeTaskToolGroups, toolGroupsForPrompt } from "../tools/tool-groups.ts";
|
|
11
|
+
import type { PiagentToolGroup } from "../tools/tool-groups.ts";
|
|
12
|
+
import {
|
|
13
|
+
buildFreshCommand,
|
|
14
|
+
chooseFreshWorkflow,
|
|
15
|
+
extractTaskRequest,
|
|
16
|
+
isFreshOrUtilityInput,
|
|
17
|
+
isPiagentWorkflowInput,
|
|
18
|
+
looksLikeGovernedBoilerplate,
|
|
19
|
+
trimTaskForInline
|
|
20
|
+
} from "../workflows/input-routing.ts";
|
|
21
|
+
import {
|
|
22
|
+
automaticTaskIntakeEligible,
|
|
23
|
+
manualTaskIntakeEligible
|
|
24
|
+
} from "../workflows/task-intake.ts";
|
|
25
|
+
|
|
26
|
+
type InputHookDependencies = {
|
|
27
|
+
boilerplateCollapseChars: number;
|
|
28
|
+
activeTask: (ctx: ExtensionContext) => TaskContract | undefined;
|
|
29
|
+
readProtectedPaths: (ctx: ExtensionContext) => string[];
|
|
30
|
+
imageAccess: (ctx: ExtensionContext) => ChatImageAccessPolicy;
|
|
31
|
+
activateToolGroups: (ctx: ExtensionContext, groups: PiagentToolGroup[]) => unknown;
|
|
32
|
+
telemetry: (ctx: ExtensionContext, payload: Record<string, unknown>) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function registerInputHook(pi: ExtensionAPI, dependencies: InputHookDependencies): void {
|
|
36
|
+
pi.on("input", async (event, ctx) => {
|
|
37
|
+
const text = event.text.trim();
|
|
38
|
+
if (!text || isFreshOrUtilityInput(text)) return { action: "continue" };
|
|
39
|
+
if (/^\/piagent-workflow\b/i.test(text)) {
|
|
40
|
+
return { action: "transform", text: text.replace(/^\/piagent-workflow\b/i, "/workflow") };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const taskSignal = classifyContextTask(text);
|
|
44
|
+
const activeTask = dependencies.activeTask(ctx);
|
|
45
|
+
const readProtectedPaths = dependencies.readProtectedPaths(ctx);
|
|
46
|
+
const protectedTarget = taskSignal.paths.some((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
47
|
+
const protectedOnlyTarget = taskSignal.paths.length > 0
|
|
48
|
+
&& taskSignal.paths.every((candidate) => matchesProtectedPath(candidate, readProtectedPaths));
|
|
49
|
+
const runtimeIntake = !activeTask && automaticTaskIntakeEligible(text, readProtectedPaths);
|
|
50
|
+
const manualIntake = !activeTask && !runtimeIntake && manualTaskIntakeEligible(text, readProtectedPaths);
|
|
51
|
+
const promptGroups = protectedOnlyTarget
|
|
52
|
+
? []
|
|
53
|
+
: toolGroupsForPrompt(text).filter((group) => (
|
|
54
|
+
runtimeIntake ? group !== "intake" && group !== "task" : true
|
|
55
|
+
));
|
|
56
|
+
if (manualIntake && !promptGroups.includes("intake")) promptGroups.push("intake");
|
|
57
|
+
dependencies.activateToolGroups(ctx, activeTask?.trace.outcome === "pending"
|
|
58
|
+
? [...promptGroups.filter((group) => group !== "intake"), ...activeTaskToolGroups(activeTask)]
|
|
59
|
+
: promptGroups);
|
|
60
|
+
dependencies.telemetry(ctx, {
|
|
61
|
+
event: "user_input",
|
|
62
|
+
source: event.source,
|
|
63
|
+
promptHash: taskSignal.promptHash,
|
|
64
|
+
promptChars: taskSignal.promptChars,
|
|
65
|
+
workflow: taskSignal.workflow,
|
|
66
|
+
riskLane: taskSignal.lane,
|
|
67
|
+
intakeMode: runtimeIntake ? "runtime" : "model",
|
|
68
|
+
protectedTarget,
|
|
69
|
+
protectedOnlyTarget,
|
|
70
|
+
manualIntake,
|
|
71
|
+
explicitPathCount: taskSignal.paths.length,
|
|
72
|
+
termCount: taskSignal.terms.length
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const imageAttachment = attachLocalImagesFromText(
|
|
76
|
+
text,
|
|
77
|
+
event.images,
|
|
78
|
+
ctx.cwd,
|
|
79
|
+
() => dependencies.imageAccess(ctx)
|
|
80
|
+
);
|
|
81
|
+
if (imageAttachment?.attached.length) {
|
|
82
|
+
ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
|
|
83
|
+
} else if (imageAttachment?.skipped.length) {
|
|
84
|
+
ctx.ui.notify(`Piagent image input: skipped ${imageAttachment.skipped.length} local image path(s)`, "warning");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inputText = imageAttachment?.text ?? text;
|
|
88
|
+
const canRewriteWorkflow = event.source !== "extension";
|
|
89
|
+
const snapshot = buildUsageSnapshot(ctx, String(pi.getThinkingLevel()));
|
|
90
|
+
const preflight = buildContextPreflight(snapshot, chooseFreshWorkflow(inputText, inputText), inputText.length);
|
|
91
|
+
const hasBoilerplate = looksLikeGovernedBoilerplate(inputText);
|
|
92
|
+
const shouldFreshen = canRewriteWorkflow
|
|
93
|
+
&& preflight.recommendation === "fresh-session"
|
|
94
|
+
&& (hasBoilerplate || isPiagentWorkflowInput(inputText) || inputText.length >= LONG_INPUT_CHARS);
|
|
95
|
+
const shouldCollapseBoilerplate = canRewriteWorkflow
|
|
96
|
+
&& hasBoilerplate
|
|
97
|
+
&& inputText.length >= dependencies.boilerplateCollapseChars;
|
|
98
|
+
const outgoingImages = [
|
|
99
|
+
...(Array.isArray(event.images) ? event.images : []),
|
|
100
|
+
...(imageAttachment?.images ?? [])
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
if (!shouldFreshen && !shouldCollapseBoilerplate) {
|
|
104
|
+
if (imageAttachment?.attached.length) return { action: "transform", text: inputText, images: outgoingImages };
|
|
105
|
+
return { action: "continue" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const task = extractTaskRequest(inputText);
|
|
109
|
+
const workflow = chooseFreshWorkflow(inputText, task);
|
|
110
|
+
const reason = shouldFreshen
|
|
111
|
+
? "Current session is near context limits; use a fresh governed session."
|
|
112
|
+
: "Mandatory flow boilerplate is already part of the platform; collapse it to the task request.";
|
|
113
|
+
const command = shouldFreshen
|
|
114
|
+
? buildFreshCommand(ctx.cwd, workflow, inputText, reason)
|
|
115
|
+
: `/${workflow} ${trimTaskForInline(task)}`;
|
|
116
|
+
|
|
117
|
+
ctx.ui.notify(`Piagent preflight: ${reason}`, "warning");
|
|
118
|
+
return outgoingImages.length > 0
|
|
119
|
+
? { action: "transform", text: command, images: outgoingImages }
|
|
120
|
+
: { action: "transform", text: command };
|
|
121
|
+
});
|
|
122
|
+
}
|