@piagent/platform 1.2.12 → 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 (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +17 -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/quality-benchmark.md +93 -0
  16. package/docs/release-install-policy.md +6 -6
  17. package/docs/security-threat-model.md +1 -1
  18. package/docs/team-onboarding.md +2 -2
  19. package/docs/vi/README.md +13 -0
  20. package/docs/vi/architecture.md +81 -0
  21. package/docs/vi/maintainer-guide.md +121 -0
  22. package/docs-site/architecture.html +39 -14
  23. package/docs-site/assets/docs.css +407 -0
  24. package/docs-site/benchmark.html +341 -0
  25. package/docs-site/commands.html +4 -1
  26. package/docs-site/content/architecture.html +35 -13
  27. package/docs-site/content/benchmark.html +214 -0
  28. package/docs-site/content/index.html +5 -0
  29. package/docs-site/context-engine.html +6 -3
  30. package/docs-site/index.html +9 -1
  31. package/docs-site/mcp-approval.html +4 -1
  32. package/docs-site/mcp-auth.html +4 -1
  33. package/docs-site/mcp-servers.html +4 -1
  34. package/docs-site/mcp.html +4 -1
  35. package/docs-site/permissions.html +4 -1
  36. package/docs-site/profiles.html +4 -1
  37. package/docs-site/quickstart.html +4 -1
  38. package/docs-site/runtime.html +6 -3
  39. package/docs-site/scope.html +4 -1
  40. package/docs-site/security.html +4 -1
  41. package/docs-site/team.html +4 -1
  42. package/docs-site/workflows.html +4 -1
  43. package/package.json +5 -1
  44. package/packages/piagent-core/README.md +6 -2
  45. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  46. package/packages/piagent-core/capabilities/runtime-integrity.js +18 -0
  47. package/packages/piagent-core/extensions/guard-types.ts +0 -37
  48. package/packages/piagent-core/extensions/piagent-guard.ts +170 -2099
  49. package/packages/piagent-core/extensions/task-state.js +22 -3
  50. package/packages/piagent-core/package.json +1 -1
  51. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +213 -0
  52. package/packages/piagent-core/runtime/hooks/completion-hook.ts +203 -0
  53. package/packages/piagent-core/runtime/hooks/input-hook.ts +122 -0
  54. package/packages/piagent-core/runtime/hooks/session-hooks.ts +104 -0
  55. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +140 -0
  56. package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +316 -0
  57. package/packages/piagent-core/runtime/input/chat-images.ts +299 -0
  58. package/packages/piagent-core/runtime/runtime-limits.ts +21 -0
  59. package/packages/piagent-core/runtime/session/message-signals.ts +64 -0
  60. package/packages/piagent-core/runtime/session/runtime-state.ts +175 -0
  61. package/packages/piagent-core/runtime/session/system-prompt.ts +52 -0
  62. package/packages/piagent-core/runtime/session/tool-result-compaction.ts +352 -0
  63. package/packages/piagent-core/runtime/session/usage.ts +178 -0
  64. package/packages/piagent-core/runtime/tools/tool-groups.ts +86 -0
  65. package/packages/piagent-core/runtime/workflows/input-routing.ts +113 -0
  66. package/packages/piagent-core/runtime/workflows/task-intake.ts +73 -0
  67. package/scripts/build-docs-site.mjs +10 -1
  68. package/scripts/check-architecture.mjs +132 -0
  69. package/scripts/check-doc-languages.mjs +67 -0
  70. package/scripts/verify-local.sh +31 -0
@@ -0,0 +1,299 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { matchesAnyPath, matchesProtectedPath } from "../../extensions/policy-core.js";
6
+ import { formatCount } from "../session/usage.ts";
7
+
8
+ const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"] as const;
9
+ const MAX_CHAT_IMAGE_ATTACHMENTS = 4;
10
+ const MAX_CHAT_IMAGE_BYTES = 8 * 1024 * 1024;
11
+
12
+ export type ChatImageAttachmentResult = {
13
+ text: string;
14
+ images: Array<{ type: "image"; data: string; mimeType: string }>;
15
+ attached: Array<{ marker: string; path: string; mimeType: string; bytes: number }>;
16
+ skipped: Array<{ path: string; reason: string }>;
17
+ };
18
+
19
+ export type ChatImageAccessPolicy = {
20
+ roots: Array<{ path: string; source: string }>;
21
+ readProtectedPaths: string[];
22
+ filesystemRead?: string[];
23
+ enforceFilesystemRead: boolean;
24
+ onImageInspected?: (path: string) => void;
25
+ };
26
+
27
+ type ResolvedChatImage =
28
+ | { status: "ok"; absolutePath: string; bytes: Buffer; mimeType: string }
29
+ | { status: "error"; reason: string };
30
+
31
+ function escapeRegExp(input: string): string {
32
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ function supportedImageMimeType(bytes: Buffer): string | undefined {
36
+ if (
37
+ bytes.length >= 24
38
+ && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
39
+ && bytes.readUInt32BE(8) === 13
40
+ && bytes.subarray(12, 16).toString("ascii") === "IHDR"
41
+ && bytes.readUInt32BE(16) > 0
42
+ && bytes.readUInt32BE(20) > 0
43
+ ) {
44
+ return "image/png";
45
+ }
46
+ if (
47
+ bytes.length >= 4
48
+ && bytes[0] === 0xff
49
+ && bytes[1] === 0xd8
50
+ && bytes[2] === 0xff
51
+ && bytes[bytes.length - 2] === 0xff
52
+ && bytes[bytes.length - 1] === 0xd9
53
+ ) {
54
+ return "image/jpeg";
55
+ }
56
+ if (bytes.length >= 10) {
57
+ const head = bytes.subarray(0, 6).toString("ascii");
58
+ if (
59
+ (head === "GIF87a" || head === "GIF89a")
60
+ && bytes.readUInt16LE(6) > 0
61
+ && bytes.readUInt16LE(8) > 0
62
+ ) {
63
+ return "image/gif";
64
+ }
65
+ }
66
+ if (
67
+ bytes.length >= 16
68
+ && bytes.subarray(0, 4).toString("ascii") === "RIFF"
69
+ && bytes.subarray(8, 12).toString("ascii") === "WEBP"
70
+ && ["VP8 ", "VP8L", "VP8X"].includes(bytes.subarray(12, 16).toString("ascii"))
71
+ && bytes.readUInt32LE(4) + 8 <= bytes.length
72
+ ) {
73
+ return "image/webp";
74
+ }
75
+ if (
76
+ bytes.length >= 26
77
+ && bytes[0] === 0x42
78
+ && bytes[1] === 0x4d
79
+ && bytes.readUInt32LE(2) <= bytes.length
80
+ && bytes.readUInt32LE(14) >= 12
81
+ ) {
82
+ return "image/bmp";
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ function normalizeImagePathCandidate(candidate: string, cwd: string, options: { allowBareRelative?: boolean } = {}): string | undefined {
88
+ let raw = candidate.trim().replace(/^['"`<]+|['"`>,.;:!?]+$/g, "");
89
+ if (!raw) return undefined;
90
+ const allowBareRelative = options.allowBareRelative !== false;
91
+ const hasExplicitPathPrefix = raw.startsWith("file://") || raw.startsWith("~/") || path.isAbsolute(raw) || raw.startsWith("./") || raw.startsWith("../");
92
+ if (!allowBareRelative && !hasExplicitPathPrefix) return undefined;
93
+ try {
94
+ if (raw.startsWith("file://")) raw = fileURLToPath(raw);
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ if (raw.startsWith("~/")) {
99
+ const home = process.env.HOME;
100
+ if (!home) return undefined;
101
+ raw = path.join(home, raw.slice(2));
102
+ }
103
+ const ext = path.extname(raw).toLowerCase().replace(/^\./, "");
104
+ if (!IMAGE_EXTENSIONS.includes(ext as typeof IMAGE_EXTENSIONS[number])) return undefined;
105
+ return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
106
+ }
107
+
108
+ function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
109
+ const candidates = new Set<string>();
110
+ const imageExt = "(?:png|jpe?g|gif|webp|bmp)";
111
+ const wholeTextPath = normalizeImagePathCandidate(text, cwd, { allowBareRelative: false });
112
+ if (wholeTextPath) candidates.add(wholeTextPath);
113
+
114
+ const quoted = new RegExp(`(?:path=)?["']([^"']+\\.${imageExt})["']`, "gi");
115
+ for (const match of text.matchAll(quoted)) {
116
+ const normalized = normalizeImagePathCandidate(match[1], cwd);
117
+ if (normalized) candidates.add(normalized);
118
+ }
119
+
120
+ const fileUrl = new RegExp(`file://[^\\s"'<>]+\\.${imageExt}`, "gi");
121
+ for (const match of text.matchAll(fileUrl)) {
122
+ const normalized = normalizeImagePathCandidate(match[0], cwd);
123
+ if (normalized) candidates.add(normalized);
124
+ }
125
+
126
+ const linePathPattern = '\\s((?:/|~\\/|\\.\\.?/)[^\\n\\r"\\\'<>]*?\\.' + imageExt + ')(?=$|\\s|["\\\'`)>])';
127
+ const linePath = new RegExp(linePathPattern, "gi");
128
+ for (const match of text.matchAll(linePath)) {
129
+ const normalized = normalizeImagePathCandidate(match[1], cwd);
130
+ if (normalized) candidates.add(normalized);
131
+ }
132
+
133
+ return [...candidates];
134
+ }
135
+
136
+ function pathContainedBy(root: string, candidate: string): boolean {
137
+ if (candidate === root) return true;
138
+ const relative = path.relative(root, candidate);
139
+ return relative.length > 0
140
+ && relative !== ".."
141
+ && !relative.startsWith(`..${path.sep}`)
142
+ && !path.isAbsolute(relative);
143
+ }
144
+
145
+ export function readChatImage(
146
+ requestedPath: string,
147
+ cwd: string,
148
+ access: ChatImageAccessPolicy
149
+ ): ResolvedChatImage {
150
+ const lexicalRequestedPath = path.isAbsolute(requestedPath)
151
+ ? path.normalize(requestedPath)
152
+ : path.resolve(cwd, requestedPath);
153
+ const lexicalProjectRoot = path.resolve(cwd);
154
+ const requestedProjectLexicalRelative = pathContainedBy(lexicalProjectRoot, lexicalRequestedPath)
155
+ ? path.relative(lexicalProjectRoot, lexicalRequestedPath).split(path.sep).join("/") || "."
156
+ : undefined;
157
+ let canonicalPath: string;
158
+ let inspected: fs.Stats;
159
+ try {
160
+ canonicalPath = fs.realpathSync.native(requestedPath);
161
+ inspected = fs.statSync(canonicalPath);
162
+ } catch {
163
+ return { status: "error", reason: "file does not exist or cannot be inspected" };
164
+ }
165
+ if (!inspected.isFile()) return { status: "error", reason: "not a regular file" };
166
+ if (inspected.size <= 0) return { status: "error", reason: "empty file" };
167
+ if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
168
+ return {
169
+ status: "error",
170
+ reason: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
171
+ };
172
+ }
173
+
174
+ const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath));
175
+ if (!root) return { status: "error", reason: "outside the project and every granted additionalReadRoots directory" };
176
+
177
+ const rootRelative = path.relative(root.path, canonicalPath).split(path.sep).join("/") || ".";
178
+ const projectRoot = access.roots.find((candidate) => candidate.source === "project");
179
+ let canonicalRequestedLocation = requestedPath;
180
+ try {
181
+ canonicalRequestedLocation = path.join(
182
+ fs.realpathSync.native(path.dirname(requestedPath)),
183
+ path.basename(requestedPath)
184
+ );
185
+ } catch {
186
+ // The target already resolved. This form only preserves the final symlink's policy position.
187
+ }
188
+ const requestedProjectRelative = projectRoot && pathContainedBy(projectRoot.path, canonicalRequestedLocation)
189
+ ? path.relative(projectRoot.path, canonicalRequestedLocation).split(path.sep).join("/") || "."
190
+ : undefined;
191
+ const protectedMatch = matchesProtectedPath(canonicalPath, access.readProtectedPaths)
192
+ ?? matchesProtectedPath(rootRelative, access.readProtectedPaths)
193
+ ?? matchesProtectedPath(canonicalRequestedLocation, access.readProtectedPaths)
194
+ ?? matchesProtectedPath(requestedPath, access.readProtectedPaths)
195
+ ?? (requestedProjectLexicalRelative
196
+ ? matchesProtectedPath(requestedProjectLexicalRelative, access.readProtectedPaths)
197
+ : undefined)
198
+ ?? (requestedProjectRelative ? matchesProtectedPath(requestedProjectRelative, access.readProtectedPaths) : undefined);
199
+ if (protectedMatch) return { status: "error", reason: `matches protected path ${protectedMatch}` };
200
+
201
+ if (
202
+ root.source === "project"
203
+ && access.enforceFilesystemRead
204
+ && access.filesystemRead
205
+ && !matchesAnyPath(rootRelative, access.filesystemRead)
206
+ ) {
207
+ return {
208
+ status: "error",
209
+ reason: `outside the resolved filesystem read scope (${access.filesystemRead.join(", ")})`
210
+ };
211
+ }
212
+
213
+ access.onImageInspected?.(canonicalPath);
214
+ const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW;
215
+ let fd: number;
216
+ try {
217
+ fd = fs.openSync(canonicalPath, openFlags);
218
+ } catch {
219
+ return { status: "error", reason: "file changed before it could be opened" };
220
+ }
221
+ try {
222
+ const opened = fs.fstatSync(fd);
223
+ if (
224
+ !opened.isFile()
225
+ || opened.dev !== inspected.dev
226
+ || opened.ino !== inspected.ino
227
+ || opened.size !== inspected.size
228
+ ) {
229
+ return { status: "error", reason: "file changed between the safety checks and the read" };
230
+ }
231
+ const bytes = Buffer.alloc(opened.size);
232
+ let offset = 0;
233
+ while (offset < bytes.length) {
234
+ const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
235
+ if (read <= 0) break;
236
+ offset += read;
237
+ }
238
+ if (offset !== bytes.length) return { status: "error", reason: "file changed while it was being read" };
239
+ const mimeType = supportedImageMimeType(bytes);
240
+ if (!mimeType) return { status: "error", reason: "file bytes are not a supported image" };
241
+ return { status: "ok", absolutePath: canonicalPath, bytes, mimeType };
242
+ } finally {
243
+ fs.closeSync(fd);
244
+ }
245
+ }
246
+
247
+ export function attachLocalImagesFromText(
248
+ text: string,
249
+ existingImages: unknown[] | undefined,
250
+ cwd: string,
251
+ resolveAccess: () => ChatImageAccessPolicy
252
+ ): ChatImageAttachmentResult | undefined {
253
+ const imagePaths = extractLocalImagePathCandidates(text, cwd);
254
+ if (imagePaths.length === 0) return undefined;
255
+ const access = resolveAccess();
256
+
257
+ const existing = Array.isArray(existingImages) ? existingImages : [];
258
+ const images: ChatImageAttachmentResult["images"] = [];
259
+ const attached: ChatImageAttachmentResult["attached"] = [];
260
+ const skipped: ChatImageAttachmentResult["skipped"] = [];
261
+ let nextText = text;
262
+
263
+ for (const imagePath of imagePaths) {
264
+ if (images.length + existing.length >= MAX_CHAT_IMAGE_ATTACHMENTS) {
265
+ skipped.push({ path: imagePath, reason: `attachment limit ${MAX_CHAT_IMAGE_ATTACHMENTS} reached` });
266
+ continue;
267
+ }
268
+ try {
269
+ const resolved = readChatImage(imagePath, cwd, access);
270
+ if (resolved.status === "error") {
271
+ skipped.push({ path: imagePath, reason: resolved.reason });
272
+ continue;
273
+ }
274
+ const marker = `[image${existing.length + images.length + 1}]`;
275
+ images.push({ type: "image", mimeType: resolved.mimeType, data: resolved.bytes.toString("base64") });
276
+ attached.push({ marker, path: imagePath, mimeType: resolved.mimeType, bytes: resolved.bytes.length });
277
+ nextText = nextText.replace(new RegExp(escapeRegExp(imagePath), "g"), marker);
278
+ nextText = nextText.replace(new RegExp(escapeRegExp(`file://${imagePath}`), "g"), marker);
279
+ } catch (error) {
280
+ skipped.push({ path: imagePath, reason: error instanceof Error ? error.message : String(error) });
281
+ }
282
+ }
283
+
284
+ if (attached.length === 0) {
285
+ return skipped.length > 0 ? { text, images: [], attached, skipped } : undefined;
286
+ }
287
+
288
+ const attachmentLines = attached.map((item) => `- ${item.marker}: ${path.basename(item.path)} (${item.mimeType}, ${formatCount(item.bytes)} bytes)`);
289
+ const skippedLines = skipped.map((item) => `- skipped ${path.basename(item.path)}: ${item.reason}`);
290
+ nextText = [
291
+ nextText.trim(),
292
+ "",
293
+ "Attached local image(s):",
294
+ ...attachmentLines,
295
+ ...(skippedLines.length > 0 ? ["", "Skipped local image path(s):", ...skippedLines] : [])
296
+ ].join("\n").trim();
297
+
298
+ return { text: nextText, images, attached, skipped };
299
+ }
@@ -0,0 +1,21 @@
1
+ // Runtime limits live outside the Pi extension entrypoint so every feature
2
+ // reads the same operational policy instead of carrying a private copy.
3
+
4
+ export const CONTEXT_WATCH_PERCENT = 50;
5
+ export const CONTEXT_COMPACT_PERCENT = 70;
6
+ export const CONTEXT_FRESH_PERCENT = 82;
7
+ export const LONG_INPUT_CHARS = 8000;
8
+ export const MAX_INLINE_COLLAPSED_TASK_CHARS = 2200;
9
+
10
+ export const TOOL_RESULT_COMPACT_CHAR_THRESHOLD = 12_000;
11
+ export const TOOL_RESULT_COMPACT_LINE_THRESHOLD = 180;
12
+ export const TOOL_RESULT_PREVIEW_HEAD_LINES = 24;
13
+ export const TOOL_RESULT_PREVIEW_TAIL_LINES = 24;
14
+ export const TOOL_RESULT_PREVIEW_INTERESTING_LINES = 24;
15
+ export const TOOL_RESULT_PREVIEW_MAX_CHARS = 6_000;
16
+ export const TOOL_RESULT_CAPTURE_MAX_CHARS = 500_000;
17
+
18
+ export const CAPTURE_INDEX_MAX_BYTES = 4 * 1024 * 1024;
19
+ export const CAPTURE_RETENTION_MAX_BYTES = 128 * 1024 * 1024;
20
+ export const CAPTURE_RETENTION_MAX_FILES = 500;
21
+ export const CAPTURE_RETENTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -0,0 +1,64 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export function assistantMessageText(message: unknown): string {
4
+ if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return "";
5
+ const content = (message as { content?: unknown }).content;
6
+ if (!Array.isArray(content)) return "";
7
+ return content
8
+ .filter((item): item is { type: "text"; text: string } => Boolean(item && typeof item === "object" && (item as { type?: unknown }).type === "text" && typeof (item as { text?: unknown }).text === "string"))
9
+ .map((item) => item.text)
10
+ .join("\n");
11
+ }
12
+
13
+ export function assistantMessageHasToolCall(message: unknown): boolean {
14
+ const content = message && typeof message === "object" ? (message as { content?: unknown }).content : undefined;
15
+ return Array.isArray(content) && content.some((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "toolCall");
16
+ }
17
+
18
+ function normalizeLanguageSignal(text: string): string {
19
+ return text
20
+ .normalize("NFKD")
21
+ .replace(/\p{M}/gu, "")
22
+ .toLowerCase()
23
+ .replaceAll("đ", "d");
24
+ }
25
+
26
+ export function looksLikeIncompleteHandoff(text: string): boolean {
27
+ const normalized = normalizeLanguageSignal(text);
28
+ return /\b(?:not done|not complete|not finished|still working|blocked|cannot complete|unable to complete|need clarification|tests? (?:fail|failed|failing)|verification (?:fail|failed|failing)|chua xong|chua hoan tat|chua hoan thanh|dang lam|bi chan|can lam them|test (?:loi|fail))\b/.test(normalized);
29
+ }
30
+
31
+ export function looksLikeCompletionClaim(text: string): boolean {
32
+ const normalized = normalizeLanguageSignal(text);
33
+ if (!normalized.trim() || normalized.includes("[piagent completion gate:")) return false;
34
+ if (looksLikeIncompleteHandoff(text)) return false;
35
+ return /\b(?:done|completed|complete|finished|fixed|implemented|resolved|shipped|all tests pass(?:ed)?|tests? pass(?:ed)?|da xong|da sua|da fix|da hoan tat|da hoan thanh|da trien khai|test da pass|kiem tra da pass)\b/.test(normalized);
36
+ }
37
+
38
+ export function modelLabel(ctx: ExtensionContext): string {
39
+ const model = ctx.model as { provider?: string; id?: string; name?: string } | undefined;
40
+ if (!model) return "none";
41
+ if (model.provider && model.id) return `${model.provider}/${model.id}`;
42
+ return model.name ?? model.id ?? "unknown";
43
+ }
44
+
45
+ export function currentSessionName(ctx: ExtensionContext): string {
46
+ try {
47
+ return String(ctx.sessionManager.getSessionName() ?? "").trim();
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+
53
+ export function hasOperatorSessionName(name: string): boolean {
54
+ const normalized = name.trim().toLowerCase();
55
+ return Boolean(normalized && normalized !== "session");
56
+ }
57
+
58
+ export function cleanSessionNameInput(input: string): string {
59
+ let name = input.trim();
60
+ if ((name.startsWith("\"") && name.endsWith("\"")) || (name.startsWith("'") && name.endsWith("'"))) {
61
+ name = name.slice(1, -1).trim();
62
+ }
63
+ return name;
64
+ }
@@ -0,0 +1,175 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { toolResultFingerprint } from "../../extensions/context-engine.js";
4
+ import { workingTreeSnapshot } from "../../extensions/task-state.js";
5
+ import type { TaskContract } from "../../extensions/guard-types.ts";
6
+
7
+ export type ObservedTaskContext = { path: string; reason: string };
8
+
9
+ export type InjectedContextPack = {
10
+ queryHash: string;
11
+ confidence: string;
12
+ estimatedTokens: number;
13
+ paths: string[];
14
+ };
15
+
16
+ function evictOldest<K, V>(map: Map<K, V>, maximum: number): void {
17
+ while (map.size > maximum) map.delete(map.keys().next().value as K);
18
+ }
19
+
20
+ export class RuntimeSessionState {
21
+ readonly #maxObservedContext: number;
22
+ readonly #advisedTools = new Set<string>();
23
+ readonly #seenToolResults = new Map<string, { outputHash: string; recordedAt: string }>();
24
+ readonly #autoPackedPrompts = new Set<string>();
25
+ readonly #injectedContextPacks = new Map<string, InjectedContextPack>();
26
+ readonly #completionRecoveryAttempts = new Map<string, number>();
27
+ readonly #taskIdentityBySession = new Map<string, { taskId: string; taskRunId: string }>();
28
+ readonly #observedContextBySession = new Map<string, Map<string, ObservedTaskContext>>();
29
+ readonly #shellMutationSnapshots = new Map<string, Record<string, string>>();
30
+
31
+ constructor(options: { maxObservedContext: number }) {
32
+ this.#maxObservedContext = options.maxObservedContext;
33
+ }
34
+
35
+ sessionKey(ctx: ExtensionContext): string {
36
+ return `${ctx.cwd}\u0000${ctx.sessionManager.getSessionId()}`;
37
+ }
38
+
39
+ cacheTaskIdentity(ctx: ExtensionContext, task: TaskContract | undefined): void {
40
+ const key = this.sessionKey(ctx);
41
+ if (task) this.#taskIdentityBySession.set(key, { taskId: task.taskId, taskRunId: task.taskRunId });
42
+ else this.#taskIdentityBySession.delete(key);
43
+ evictOldest(this.#taskIdentityBySession, 100);
44
+ }
45
+
46
+ taskIdentity(ctx: ExtensionContext): { taskId: string; taskRunId: string } | undefined {
47
+ return this.#taskIdentityBySession.get(this.sessionKey(ctx));
48
+ }
49
+
50
+ hasAdvisedTool(ctx: ExtensionContext, toolName: string): boolean {
51
+ return this.#advisedTools.has(`${this.sessionKey(ctx)}\u0000${toolName}`);
52
+ }
53
+
54
+ rememberAdvisedTool(ctx: ExtensionContext, toolName: string): void {
55
+ this.#advisedTools.add(`${this.sessionKey(ctx)}\u0000${toolName}`);
56
+ while (this.#advisedTools.size > 500) {
57
+ this.#advisedTools.delete(this.#advisedTools.values().next().value as string);
58
+ }
59
+ }
60
+
61
+ rememberObservedContext(ctx: ExtensionContext, entry: ObservedTaskContext): void {
62
+ const key = this.sessionKey(ctx);
63
+ let observed = this.#observedContextBySession.get(key);
64
+ if (!observed) {
65
+ observed = new Map();
66
+ this.#observedContextBySession.set(key, observed);
67
+ }
68
+ if (!observed.has(entry.path)) observed.set(entry.path, entry);
69
+ evictOldest(observed, this.#maxObservedContext);
70
+ evictOldest(this.#observedContextBySession, 100);
71
+ }
72
+
73
+ observedContext(ctx: ExtensionContext): ObservedTaskContext[] {
74
+ return [...(this.#observedContextBySession.get(this.sessionKey(ctx))?.values() ?? [])];
75
+ }
76
+
77
+ clearObservedContext(ctx: ExtensionContext): void {
78
+ this.#observedContextBySession.delete(this.sessionKey(ctx));
79
+ }
80
+
81
+ #shellMutationSnapshotKey(ctx: ExtensionContext, toolName: string, input: unknown): string {
82
+ return `${this.sessionKey(ctx)}\u0000${toolResultFingerprint(toolName, input, []).key}`;
83
+ }
84
+
85
+ rememberShellMutationSnapshot(ctx: ExtensionContext, toolName: string, input: unknown): void {
86
+ this.#shellMutationSnapshots.set(
87
+ this.#shellMutationSnapshotKey(ctx, toolName, input),
88
+ workingTreeSnapshot(ctx.cwd) as Record<string, string>
89
+ );
90
+ evictOldest(this.#shellMutationSnapshots, 100);
91
+ }
92
+
93
+ consumeShellMutationSnapshot(ctx: ExtensionContext, toolName: string, input: unknown): Record<string, string> | undefined {
94
+ const key = this.#shellMutationSnapshotKey(ctx, toolName, input);
95
+ const snapshot = this.#shellMutationSnapshots.get(key);
96
+ this.#shellMutationSnapshots.delete(key);
97
+ return snapshot;
98
+ }
99
+
100
+ clearShellMutationSnapshots(ctx: ExtensionContext): void {
101
+ const prefix = `${this.sessionKey(ctx)}\u0000`;
102
+ for (const key of this.#shellMutationSnapshots.keys()) {
103
+ if (key.startsWith(prefix)) this.#shellMutationSnapshots.delete(key);
104
+ }
105
+ }
106
+
107
+ hasAutoPackedPrompt(key: string): boolean {
108
+ return this.#autoPackedPrompts.has(key);
109
+ }
110
+
111
+ rememberAutoPackedPrompt(key: string): void {
112
+ this.#autoPackedPrompts.add(key);
113
+ while (this.#autoPackedPrompts.size > 50) {
114
+ this.#autoPackedPrompts.delete(this.#autoPackedPrompts.values().next().value as string);
115
+ }
116
+ }
117
+
118
+ rememberInjectedContextPack(ctx: ExtensionContext, key: string, pack: InjectedContextPack): void {
119
+ this.#injectedContextPacks.set(`${this.sessionKey(ctx)}\u0000${key}`, pack);
120
+ evictOldest(this.#injectedContextPacks, 50);
121
+ }
122
+
123
+ injectedContextPack(ctx: ExtensionContext, key: string): InjectedContextPack | undefined {
124
+ return this.#injectedContextPacks.get(`${this.sessionKey(ctx)}\u0000${key}`);
125
+ }
126
+
127
+ completionRecoveryAttempt(taskRunId: string): number {
128
+ return this.#completionRecoveryAttempts.get(taskRunId) ?? 0;
129
+ }
130
+
131
+ rememberCompletionRecoveryAttempt(taskRunId: string, attempt: number): void {
132
+ this.#completionRecoveryAttempts.set(taskRunId, attempt);
133
+ evictOldest(this.#completionRecoveryAttempts, 100);
134
+ }
135
+
136
+ clearCompletionRecoveryAttempt(taskRunId: string): void {
137
+ this.#completionRecoveryAttempts.delete(taskRunId);
138
+ }
139
+
140
+ previousToolResult(
141
+ ctx: ExtensionContext,
142
+ fingerprintKey: string
143
+ ): { outputHash: string; recordedAt: string } | undefined {
144
+ return this.#seenToolResults.get(`${this.sessionKey(ctx)}\u0000${fingerprintKey}`);
145
+ }
146
+
147
+ rememberToolResult(
148
+ ctx: ExtensionContext,
149
+ fingerprintKey: string,
150
+ value: { outputHash: string; recordedAt: string }
151
+ ): void {
152
+ this.#seenToolResults.set(`${this.sessionKey(ctx)}\u0000${fingerprintKey}`, value);
153
+ evictOldest(this.#seenToolResults, 500);
154
+ }
155
+
156
+ clearSession(ctx: ExtensionContext): void {
157
+ const sessionKey = this.sessionKey(ctx);
158
+ const prefix = `${sessionKey}\u0000`;
159
+ const taskIdentity = this.#taskIdentityBySession.get(sessionKey);
160
+ this.#taskIdentityBySession.delete(sessionKey);
161
+ this.#observedContextBySession.delete(sessionKey);
162
+ this.clearShellMutationSnapshots(ctx);
163
+ if (taskIdentity) this.#completionRecoveryAttempts.delete(taskIdentity.taskRunId);
164
+ for (const values of [this.#advisedTools, this.#autoPackedPrompts]) {
165
+ for (const key of values) {
166
+ if (key.startsWith(prefix)) values.delete(key);
167
+ }
168
+ }
169
+ for (const values of [this.#seenToolResults, this.#injectedContextPacks]) {
170
+ for (const key of values.keys()) {
171
+ if (key.startsWith(prefix)) values.delete(key);
172
+ }
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,52 @@
1
+ const LEGACY_PROJECT_INSTRUCTIONS_START = "Before implementation:\n\n1. Load `.pi/piagent-profile.json` with `piagent_context`.";
2
+ const LEGACY_PROJECT_INSTRUCTIONS_END = "18. If the bundled `pi-subagents` parent skill is available, use it for delegation patterns, review loops, native supervisor coordination, and safety boundaries.";
3
+ const RUNTIME_MANAGED_PROJECT_INSTRUCTIONS = [
4
+ "Piagent runtime-managed task flow:",
5
+ "1. Runtime creates the task contract automatically for a bounded source-changing request before the model starts.",
6
+ "2. Only when runtime intake pauses for broad, high-risk, or ambiguous scope, call `piagent_task_start` exactly once with project-relative path/glob scope; reuse an active contract.",
7
+ "3. Read the narrow target and nearest relevant test, then use ordinary read/edit/bash tools.",
8
+ "4. Run every exact runtime-provided verifier after the latest mutation. Automatic tasks need no management calls.",
9
+ "5. Runtime hooks enforce policy and record context, changes, current-tree verification, trace and final gate. Diagnostic groups appear only for explicit requests or manual high-risk checkpoints."
10
+ ].join("\n");
11
+
12
+ export function rewriteLegacyProjectInstructions(systemPrompt: string): {
13
+ systemPrompt: string;
14
+ rewritten: boolean;
15
+ } {
16
+ const start = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_START);
17
+ if (start < 0) return { systemPrompt, rewritten: false };
18
+ const endStart = systemPrompt.indexOf(LEGACY_PROJECT_INSTRUCTIONS_END, start);
19
+ if (endStart < 0) return { systemPrompt, rewritten: false };
20
+ const end = endStart + LEGACY_PROJECT_INSTRUCTIONS_END.length;
21
+ return {
22
+ systemPrompt: `${systemPrompt.slice(0, start)}${RUNTIME_MANAGED_PROJECT_INSTRUCTIONS}${systemPrompt.slice(end)}`,
23
+ rewritten: true
24
+ };
25
+ }
26
+
27
+ export function compactManagedProjectInstructions(
28
+ systemPrompt: string,
29
+ mode: "automatic" | "protected"
30
+ ): { systemPrompt: string; compacted: boolean } {
31
+ const concise = mode === "protected"
32
+ ? "Piagent protected-path policy: do not read, disclose, or mutate protected content. Refuse without tool calls."
33
+ : "Piagent runtime task is injected below. Root project instructions are already loaded; do not re-read root AGENTS.md. Use task-relevant source/tests and ordinary tools, run the exact verifier after final edits, and make no task-management calls.";
34
+ const markerStart = "<!-- piagent-managed:start -->";
35
+ const markerEnd = "<!-- piagent-managed:end -->";
36
+ const start = systemPrompt.indexOf(markerStart);
37
+ const endStart = start >= 0 ? systemPrompt.indexOf(markerEnd, start) : -1;
38
+ if (start >= 0 && endStart >= 0) {
39
+ const end = endStart + markerEnd.length;
40
+ return {
41
+ systemPrompt: `${systemPrompt.slice(0, start)}${markerStart}\n${concise}\n${markerEnd}${systemPrompt.slice(end)}`,
42
+ compacted: true
43
+ };
44
+ }
45
+ if (systemPrompt.includes(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS)) {
46
+ return {
47
+ systemPrompt: systemPrompt.replace(RUNTIME_MANAGED_PROJECT_INSTRUCTIONS, concise),
48
+ compacted: true
49
+ };
50
+ }
51
+ return { systemPrompt, compacted: false };
52
+ }