@piagent/platform 1.4.1 → 1.5.1
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 +58 -0
- package/README.md +5 -1
- package/README.vi.md +21 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +6 -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-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/context-engine.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/index.html +1 -1
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +1 -1
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
- package/package.json +1 -1
- package/packages/piagent-core/extensions/document-intake.ts +6 -3
- package/packages/piagent-core/extensions/piagent-guard.ts +2 -2
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
- package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
- package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
- package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
- package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
- package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
- package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
- package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/piagent-webui.ts +15 -2
- package/packages/piagent-webui/extension/same-session-bridge.ts +9 -9
- package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
- package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
- package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
- package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +30 -10
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +19 -10
- package/packages/piagent-webui/package.json +3 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +48 -11
- package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
- package/packages/piagent-webui/server/loopback-server.ts +63 -25
- package/packages/piagent-webui/server/read-model-provider.ts +2 -0
- package/packages/piagent-webui/server/read-only-router.ts +6 -0
- package/packages/piagent-webui/server/transcript-projection.ts +43 -2
- package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
- package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
- package/schemas/piagent-webui/catalog-v1.json +9 -0
- package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
- package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
- package/scripts/verify-local.sh +16 -1
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +0 -172
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { redactSensitiveText } from "../../extensions/redaction-core.js";
|
|
6
|
+
import {
|
|
7
|
+
DOCUMENT_EXTENSIONS,
|
|
8
|
+
extractDocument,
|
|
9
|
+
resolveDocumentPath,
|
|
10
|
+
resolveDocumentRoots,
|
|
11
|
+
type DocumentRoot
|
|
12
|
+
} from "../../extensions/document-intake.ts";
|
|
13
|
+
|
|
14
|
+
// Opening documents in the browser.
|
|
15
|
+
//
|
|
16
|
+
// The tool `piagent_document_read` already answers "read this path" for the
|
|
17
|
+
// model. This answers "what is there to read, and show me one" for the operator,
|
|
18
|
+
// and it is deliberately built on the same three functions: the roots come from
|
|
19
|
+
// `resolveDocumentRoots`, every path is decided by `resolveDocumentPath`, and the
|
|
20
|
+
// bytes are turned into text by `extractDocument`. A second implementation of
|
|
21
|
+
// containment or of the extension gate would be a second thing to keep correct,
|
|
22
|
+
// and the one that drifted would be this one, because it is the one nobody runs
|
|
23
|
+
// from a terminal.
|
|
24
|
+
//
|
|
25
|
+
// Two rules the listing adds on top:
|
|
26
|
+
//
|
|
27
|
+
// A name is not an authority. The browser never sends a path — it sends an
|
|
28
|
+
// opaque ref, and the ref is resolved by re-listing and matching. A path that
|
|
29
|
+
// stopped being listable between the listing and the read is simply not found,
|
|
30
|
+
// which is the same answer as a path that was never listed.
|
|
31
|
+
//
|
|
32
|
+
// The walk does not follow links. Containment is decided on the canonical path
|
|
33
|
+
// at read time, so a link out of a root could never be *read*, but following one
|
|
34
|
+
// during the walk would still put its target's name in front of the operator.
|
|
35
|
+
|
|
36
|
+
export const DOCUMENT_WORKSPACE_LIMITS = Object.freeze({
|
|
37
|
+
documents: 2000,
|
|
38
|
+
// A shared budget spent in root order lets a large project consume all of it
|
|
39
|
+
// and leave a granted directory — the one the operator added on purpose —
|
|
40
|
+
// showing nothing at all. Each root gets its own slice instead, with a floor
|
|
41
|
+
// so a small grant is never squeezed to nothing by a big one.
|
|
42
|
+
documentsPerRootFloor: 250,
|
|
43
|
+
depth: 8,
|
|
44
|
+
entriesPerDirectory: 4096,
|
|
45
|
+
text: 400_000
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Directories that are large, uninteresting, and present in almost every
|
|
49
|
+
// project. Walking them costs seconds and returns vendored copies of documents
|
|
50
|
+
// the operator did not write.
|
|
51
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
52
|
+
"node_modules", ".git", ".hg", ".svn", "dist", "build", "out", "coverage",
|
|
53
|
+
".next", ".nuxt", ".turbo", ".cache", ".venv", "venv", "__pycache__",
|
|
54
|
+
"target", "vendor", ".gradle", ".idea", ".tmp", ".playwright"
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const EXTENSIONS = new Set(DOCUMENT_EXTENSIONS);
|
|
58
|
+
|
|
59
|
+
export type DocumentWorkspaceEntry = {
|
|
60
|
+
documentRef: string;
|
|
61
|
+
rootRef: string;
|
|
62
|
+
name: string;
|
|
63
|
+
relativePath: string;
|
|
64
|
+
extension: string;
|
|
65
|
+
sizeBytes: number;
|
|
66
|
+
modifiedAt: string;
|
|
67
|
+
};
|
|
68
|
+
export type DocumentWorkspaceRoot = { rootRef: string; path: string; source: DocumentRoot["source"]; documentCount: number };
|
|
69
|
+
export type DocumentWorkspaceListing = {
|
|
70
|
+
schemaVersion: 1;
|
|
71
|
+
version: "piagent-webui-document-workspace-v1";
|
|
72
|
+
messageType: "listing";
|
|
73
|
+
generatedAt: string;
|
|
74
|
+
state: "ready" | "unavailable";
|
|
75
|
+
roots: DocumentWorkspaceRoot[];
|
|
76
|
+
documents: DocumentWorkspaceEntry[];
|
|
77
|
+
truncated: boolean;
|
|
78
|
+
reasonCode: string | null;
|
|
79
|
+
};
|
|
80
|
+
export type DocumentWorkspaceDocument = {
|
|
81
|
+
schemaVersion: 1;
|
|
82
|
+
version: "piagent-webui-document-workspace-v1";
|
|
83
|
+
messageType: "document";
|
|
84
|
+
generatedAt: string;
|
|
85
|
+
documentRef: string;
|
|
86
|
+
state: "ready" | "unavailable";
|
|
87
|
+
// Null wherever the document could not be identified at all. A document that
|
|
88
|
+
// was found but could not be read still names itself, so the operator sees
|
|
89
|
+
// which file the refusal is about.
|
|
90
|
+
name: string | null;
|
|
91
|
+
relativePath: string | null;
|
|
92
|
+
rootRef: string | null;
|
|
93
|
+
format: "text" | "docx" | "pdf" | null;
|
|
94
|
+
text: string | null;
|
|
95
|
+
sizeBytes: number | null;
|
|
96
|
+
truncated: boolean;
|
|
97
|
+
redacted: boolean;
|
|
98
|
+
reasonCode: string | null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type DocumentWorkspaceInput = {
|
|
102
|
+
cwd: string;
|
|
103
|
+
profileRoots?: unknown;
|
|
104
|
+
environmentRoots?: string | undefined;
|
|
105
|
+
home?: string | undefined;
|
|
106
|
+
isProtectedPath?: (candidate: string) => boolean;
|
|
107
|
+
now?: () => Date;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function ref(prefix: string, value: string): string {
|
|
111
|
+
return `${prefix}.${createHash("sha256").update(value).digest("hex")}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A control character in a name would sit inside a JSON string safely enough,
|
|
115
|
+
// but this text is also rendered, copied and logged. Stripping is cheaper than
|
|
116
|
+
// auditing every place it lands.
|
|
117
|
+
function displayName(value: string): string {
|
|
118
|
+
return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function timestamp(value: Date): string {
|
|
122
|
+
return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function extensionOf(name: string): string | undefined {
|
|
126
|
+
const extension = path.extname(name).toLowerCase().replace(/^\./, "");
|
|
127
|
+
return EXTENSIONS.has(extension) ? extension : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only regular files and real directories are considered, and both are judged by
|
|
131
|
+
// lstat so a link is seen as a link rather than as whatever it points at.
|
|
132
|
+
function walkRoot(root: DocumentRoot, budget: { remaining: number }, isProtected: (relative: string, absolute: string) => boolean,
|
|
133
|
+
paths: Map<string, string>): DocumentWorkspaceEntry[] {
|
|
134
|
+
const rootRef = ref("document-root", root.path), found: DocumentWorkspaceEntry[] = [];
|
|
135
|
+
const queue: Array<{ directory: string; depth: number }> = [{ directory: root.path, depth: 0 }];
|
|
136
|
+
while (queue.length > 0 && budget.remaining > 0) {
|
|
137
|
+
const current = queue.shift()!;
|
|
138
|
+
let entries: fs.Dirent[];
|
|
139
|
+
try { entries = fs.readdirSync(current.directory, { withFileTypes: true }); }
|
|
140
|
+
catch { continue; }
|
|
141
|
+
// A directory with more entries than this is a data directory, not a place
|
|
142
|
+
// an operator keeps documents, and reading all of it is what makes a listing
|
|
143
|
+
// take seconds.
|
|
144
|
+
if (entries.length > DOCUMENT_WORKSPACE_LIMITS.entriesPerDirectory) continue;
|
|
145
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
146
|
+
if (budget.remaining <= 0) break;
|
|
147
|
+
// A Dirent already reports a link as a link, so the isFile and isDirectory
|
|
148
|
+
// tests below reject one on their own. This says so out loud, because a
|
|
149
|
+
// later refactor to stat-based checks would silently start following them.
|
|
150
|
+
if (entry.isSymbolicLink()) continue;
|
|
151
|
+
const absolute = path.join(current.directory, entry.name);
|
|
152
|
+
const relative = path.relative(root.path, absolute).split(path.sep).join("/");
|
|
153
|
+
if (entry.isDirectory()) {
|
|
154
|
+
if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
155
|
+
if (current.depth + 1 > DOCUMENT_WORKSPACE_LIMITS.depth) continue;
|
|
156
|
+
if (isProtected(relative, absolute)) continue;
|
|
157
|
+
queue.push({ directory: absolute, depth: current.depth + 1 });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!entry.isFile()) continue;
|
|
161
|
+
const extension = extensionOf(entry.name);
|
|
162
|
+
if (!extension || isProtected(relative, absolute)) continue;
|
|
163
|
+
let stat: fs.Stats;
|
|
164
|
+
try { stat = fs.statSync(absolute); }
|
|
165
|
+
catch { continue; }
|
|
166
|
+
if (!stat.isFile() || stat.size <= 0) continue;
|
|
167
|
+
budget.remaining -= 1;
|
|
168
|
+
const documentRef = ref("document", absolute);
|
|
169
|
+
// The projected name is stripped for display. Resolution must run on the
|
|
170
|
+
// path as it exists on disk, so the exact bytes are kept beside the ref
|
|
171
|
+
// and never rebuilt from what the browser was shown.
|
|
172
|
+
paths.set(documentRef, absolute);
|
|
173
|
+
found.push({ documentRef, rootRef, name: displayName(entry.name),
|
|
174
|
+
relativePath: displayName(relative), extension, sizeBytes: stat.size, modifiedAt: timestamp(stat.mtime) });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function protectedPredicate(input: DocumentWorkspaceInput): (relative: string, absolute: string) => boolean {
|
|
181
|
+
const custom = input.isProtectedPath;
|
|
182
|
+
if (!custom) return () => false;
|
|
183
|
+
// Protected patterns are project-relative and anchored at the first segment, so
|
|
184
|
+
// an absolute candidate only ever matches a `**/`-prefixed one. Checking a
|
|
185
|
+
// single form would let every anchored entry through, the same way the document
|
|
186
|
+
// tool checks both.
|
|
187
|
+
return (relative, absolute) => custom(absolute) || custom(relative);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The listing and the raw paths behind it, produced together. Only the listing
|
|
191
|
+
// crosses to the browser; the paths stay here so a read never has to rebuild one
|
|
192
|
+
// from projected text.
|
|
193
|
+
function collectDocuments(input: DocumentWorkspaceInput): { listing: DocumentWorkspaceListing; roots: DocumentRoot[]; paths: Map<string, string> } {
|
|
194
|
+
const now = (input.now ?? (() => new Date()))();
|
|
195
|
+
const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
|
|
196
|
+
messageType: "listing" as const, generatedAt: timestamp(now) };
|
|
197
|
+
const empty = (reasonCode: string) => ({ listing: { ...base, state: "unavailable" as const, roots: [], documents: [],
|
|
198
|
+
truncated: false, reasonCode }, roots: [] as DocumentRoot[], paths: new Map<string, string>() });
|
|
199
|
+
let roots: DocumentRoot[];
|
|
200
|
+
try {
|
|
201
|
+
roots = resolveDocumentRoots({ cwd: input.cwd, profileRoots: input.profileRoots,
|
|
202
|
+
environmentRoots: input.environmentRoots, home: input.home });
|
|
203
|
+
} catch { return empty("document-roots-unavailable"); }
|
|
204
|
+
if (roots.length === 0) return empty("no-readable-root");
|
|
205
|
+
|
|
206
|
+
const isProtected = protectedPredicate(input);
|
|
207
|
+
const perRoot = Math.max(DOCUMENT_WORKSPACE_LIMITS.documentsPerRootFloor,
|
|
208
|
+
Math.floor(DOCUMENT_WORKSPACE_LIMITS.documents / roots.length));
|
|
209
|
+
const paths = new Map<string, string>();
|
|
210
|
+
const documents: DocumentWorkspaceEntry[] = [], projected: DocumentWorkspaceRoot[] = [];
|
|
211
|
+
let truncated = false;
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
const budget = { remaining: Math.min(perRoot, Math.max(0, DOCUMENT_WORKSPACE_LIMITS.documents - documents.length)) };
|
|
214
|
+
const found = walkRoot(root, budget, isProtected, paths);
|
|
215
|
+
truncated ||= budget.remaining <= 0;
|
|
216
|
+
projected.push({ rootRef: ref("document-root", root.path), path: displayName(root.path), source: root.source, documentCount: found.length });
|
|
217
|
+
documents.push(...found);
|
|
218
|
+
}
|
|
219
|
+
return { listing: { ...base, state: "ready", roots: projected, documents, truncated, reasonCode: null }, roots, paths };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function projectDocumentWorkspaceListing(input: DocumentWorkspaceInput): DocumentWorkspaceListing {
|
|
223
|
+
return collectDocuments(input).listing;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput & { documentRef: string }): DocumentWorkspaceDocument {
|
|
227
|
+
const now = (input.now ?? (() => new Date()))();
|
|
228
|
+
const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
|
|
229
|
+
messageType: "document" as const, generatedAt: timestamp(now), documentRef: input.documentRef };
|
|
230
|
+
const unavailable = (reasonCode: string): DocumentWorkspaceDocument => ({ ...base, state: "unavailable", name: null, relativePath: null,
|
|
231
|
+
rootRef: null, format: null, text: null, sizeBytes: null, truncated: false, redacted: false, reasonCode });
|
|
232
|
+
|
|
233
|
+
// The ref is matched against a listing taken now, so opening a document proves
|
|
234
|
+
// it is still listable rather than trusting a ref the browser has been holding.
|
|
235
|
+
const { listing, roots, paths } = collectDocuments(input);
|
|
236
|
+
if (listing.state !== "ready") return unavailable(listing.reasonCode ?? "document-roots-unavailable");
|
|
237
|
+
const entry = listing.documents.find((candidate) => candidate.documentRef === input.documentRef);
|
|
238
|
+
const absolute = paths.get(input.documentRef);
|
|
239
|
+
if (!entry || !absolute) return unavailable("document-not-listed");
|
|
240
|
+
|
|
241
|
+
// Resolution runs against the same roots the walk used, so the extension gate,
|
|
242
|
+
// the size cap, the symlink canonicalisation and the containment check are the
|
|
243
|
+
// ones the document tool applies — including the identity that extractDocument
|
|
244
|
+
// re-proves when it opens the file.
|
|
245
|
+
const resolved = resolveDocumentPath(absolute, roots, { cwd: input.cwd, home: input.home });
|
|
246
|
+
if (resolved.status === "error") return unavailable("document-unavailable");
|
|
247
|
+
// The walk above already dropped protected documents, so a protected ref fails
|
|
248
|
+
// to match a listing entry before it reaches here. Both of the next two checks
|
|
249
|
+
// are the invariant stated rather than assumed: they hold the guarantee in
|
|
250
|
+
// place if the listing filter is ever loosened or the ref scheme changes.
|
|
251
|
+
const isProtected = protectedPredicate(input);
|
|
252
|
+
const relative = path.relative(resolved.root.path, resolved.absolutePath).split(path.sep).join("/");
|
|
253
|
+
if (isProtected(relative, resolved.absolutePath)) return unavailable("document-protected");
|
|
254
|
+
// Roots are canonical and the walk never traverses a link, so the listed path
|
|
255
|
+
// is already the canonical one and this equality holds by construction. It is
|
|
256
|
+
// re-derived anyway: the cost is a hash, and what it protects is the rule that
|
|
257
|
+
// the browser can only ever name a document the listing chose to expose.
|
|
258
|
+
if (ref("document", resolved.absolutePath) !== input.documentRef) return unavailable("document-changed");
|
|
259
|
+
|
|
260
|
+
const extracted = extractDocument(resolved);
|
|
261
|
+
if (extracted.status === "error") {
|
|
262
|
+
return { ...unavailable(extracted.reason.includes("needs OCR") ? "document-needs-ocr"
|
|
263
|
+
: extracted.reason.includes("pdftotext") ? "document-converter-unavailable" : "document-unreadable"),
|
|
264
|
+
name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef };
|
|
265
|
+
}
|
|
266
|
+
// The same redaction the document tool applies before the model sees a file.
|
|
267
|
+
// The operator can open the original in an editor; what crosses this boundary
|
|
268
|
+
// is a projection, and projections in this read model do not carry secrets.
|
|
269
|
+
const safe = redactSensitiveText(extracted.text);
|
|
270
|
+
const capped = safe.text.length > DOCUMENT_WORKSPACE_LIMITS.text
|
|
271
|
+
? { text: safe.text.slice(0, DOCUMENT_WORKSPACE_LIMITS.text), truncated: true }
|
|
272
|
+
: { text: safe.text, truncated: false };
|
|
273
|
+
return { ...base, state: "ready", name: entry.name, relativePath: entry.relativePath, rootRef: entry.rootRef,
|
|
274
|
+
format: extracted.kind, text: capped.text, sizeBytes: entry.sizeBytes,
|
|
275
|
+
truncated: extracted.truncated || capped.truncated, redacted: safe.redacted, reasonCode: null };
|
|
276
|
+
}
|
|
@@ -13,19 +13,34 @@ function readRequiredJsonObject(file: string): Record<string, unknown> {
|
|
|
13
13
|
return value as Record<string, unknown>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// A project profile is operator-supplied and may be missing, malformed, a
|
|
17
|
+
// symlink, or enormous. Every reader of it needs the same refusal, so both
|
|
18
|
+
// callers below go through this one.
|
|
19
|
+
function safeProjectProfile(packageRoot: string, cwd: string): Record<string, unknown> {
|
|
18
20
|
const profileFile = path.join(cwd, ".pi", "piagent-profile.json");
|
|
19
|
-
let profile: Record<string, unknown> = {};
|
|
20
21
|
try {
|
|
21
22
|
const stat = fs.lstatSync(profileFile);
|
|
22
23
|
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1_048_576) {
|
|
23
|
-
|
|
24
|
+
return readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
|
|
24
25
|
}
|
|
25
26
|
} catch {
|
|
26
27
|
// Missing or unsafe project profiles never remove the platform policy.
|
|
27
28
|
}
|
|
28
|
-
return
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
|
|
33
|
+
const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
|
|
34
|
+
return effectiveProtectedPaths(policy, safeProjectProfile(packageRoot, cwd)).readProtectedPaths;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Directories the operator granted for reading documents outside the project.
|
|
38
|
+
// Returned raw: resolveDocumentRoots is what decides which of them exist and
|
|
39
|
+
// canonicalises them, and duplicating that judgement here would let the two
|
|
40
|
+
// answers drift.
|
|
41
|
+
export function runtimeDocumentReadRoots(packageRoot: string, cwd: string): string[] {
|
|
42
|
+
const granted = safeProjectProfile(packageRoot, cwd).additionalReadRoots;
|
|
43
|
+
return Array.isArray(granted) ? granted.filter((item): item is string => typeof item === "string") : [];
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
export function runtimeConnectionDefinitions(cwd: string): {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The identity and revision shape a Pi session is addressed by.
|
|
2
|
+
//
|
|
3
|
+
// Both the in-session bridge and the Gateway have to speak it: the bridge
|
|
4
|
+
// because it owns one live session, the Gateway because it synthesises the same
|
|
5
|
+
// shape from an inspection snapshot for the many sessions it drives. The layer
|
|
6
|
+
// rules let extension and gateway each import server, and neither import the
|
|
7
|
+
// other, so this is the one place both can agree on.
|
|
8
|
+
|
|
9
|
+
type NullableRef = string | null;
|
|
10
|
+
|
|
11
|
+
export type BridgeIdentity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null;
|
|
12
|
+
taskRunId: string | null; agentOperationId: string | null; toolCallId: null };
|
|
13
|
+
|
|
14
|
+
export type BridgeRevisions = { runtimeRevision: string; taskRevision: NullableRef; controlRevision: NullableRef;
|
|
15
|
+
workspaceRevision: NullableRef; indexRevision: NullableRef; approvalRevision: NullableRef;
|
|
16
|
+
sessionOptionRevision: NullableRef; queueRevision: NullableRef };
|
|
17
|
+
|
|
18
|
+
export type BridgeSnapshot = { state: "unbound" | "ready" | "replacement-pending" | "shutdown";
|
|
19
|
+
identity: BridgeIdentity | null; revisions: BridgeRevisions | null; liveness: "idle" | "running" | "unknown";
|
|
20
|
+
taskState: "none" | "active" | "pause-requested" | "paused" | "terminal" | "unknown"; eventSequence: number };
|
|
@@ -8,7 +8,7 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
|
|
|
8
8
|
buildUsageSnapshot, commandArgs, emitProfileStatus, emitProfileTechStatus, emitRuntimeMessage,
|
|
9
9
|
fs, loadProfileFromContext, prefixCompletions, projectContextFilePath, projectProfilePath,
|
|
10
10
|
registerRuntimeCommand, resolveMemorySettings, runProfileTechWizard, selectRuntimeAction, sendWorkflowFollowUp,
|
|
11
|
-
shortTaskLabel, techStackPath
|
|
11
|
+
freshRequestParts, shortTaskLabel, techStackPath
|
|
12
12
|
} = deps;
|
|
13
13
|
type WorkflowCommandName = "task" | "scout" | "be-to-fe" | "discuss" | "plan" | "review" | "platform-improve" | "commit" | "pr" | "onboard";
|
|
14
14
|
|
|
@@ -377,17 +377,18 @@ export function registerWorkflowCommands(pi: ExtensionAPI, deps: Record<string,
|
|
|
377
377
|
});
|
|
378
378
|
|
|
379
379
|
async function startFreshWorkflow(workflow: "task" | "scout" | "be-to-fe", args: string, ctx: any) {
|
|
380
|
-
const
|
|
380
|
+
const parsed = freshRequestParts(String(args ?? ""));
|
|
381
|
+
const request = parsed.request;
|
|
381
382
|
if (!request) {
|
|
382
383
|
ctx.ui.notify(`Usage: /fresh ${workflow} <request>`, "warning");
|
|
383
384
|
return;
|
|
384
385
|
}
|
|
385
386
|
|
|
386
|
-
const label = shortTaskLabel(request);
|
|
387
|
+
const label = parsed.sessionTitle ?? shortTaskLabel(request);
|
|
387
388
|
const command = `/${workflow} ${request}`;
|
|
388
389
|
const result = await ctx.newSession({
|
|
389
390
|
withSession: async (nextCtx) => {
|
|
390
|
-
pi.setSessionName(`pi:${
|
|
391
|
+
pi.setSessionName(`pi:${label}`);
|
|
391
392
|
await nextCtx.sendUserMessage(command);
|
|
392
393
|
}
|
|
393
394
|
});
|
|
@@ -98,7 +98,11 @@ export function buildFreshCommand(cwd: string, workflow: FreshWorkflow, original
|
|
|
98
98
|
const task = extractTaskRequest(originalText);
|
|
99
99
|
if (originalText.length >= LONG_INPUT_CHARS) {
|
|
100
100
|
const intakePath = writeTaskInbox(cwd, workflow, originalText);
|
|
101
|
-
|
|
101
|
+
// Keep the large intake out of the command/session name, but carry a small
|
|
102
|
+
// deterministic label across the new-session boundary. The /fresh handler
|
|
103
|
+
// consumes this private option before it sends the workflow prompt, so it
|
|
104
|
+
// never becomes task instructions or provider-visible document content.
|
|
105
|
+
return `/fresh ${workflow} --session-title "${shortTaskLabel(task)}" Read task intake from ${intakePath}. ${reason}`;
|
|
102
106
|
}
|
|
103
107
|
return `/fresh ${workflow} ${trimTaskForInline(task)}`;
|
|
104
108
|
}
|
|
@@ -106,8 +110,15 @@ export function buildFreshCommand(cwd: string, workflow: FreshWorkflow, original
|
|
|
106
110
|
export function shortTaskLabel(text: string): string {
|
|
107
111
|
const compact = text
|
|
108
112
|
.replace(/```[\s\S]*?```/g, " ")
|
|
109
|
-
.replace(/[
|
|
113
|
+
.replace(/[^\p{L}\p{N}_-]+/gu, " ")
|
|
110
114
|
.trim()
|
|
111
115
|
.slice(0, 64);
|
|
112
116
|
return compact || "piagent task";
|
|
113
117
|
}
|
|
118
|
+
|
|
119
|
+
export function freshRequestParts(value: string): { request: string; sessionTitle: string | null } {
|
|
120
|
+
const input = String(value ?? "").trim();
|
|
121
|
+
const marked = /^--session-title\s+"([^"\r\n]{1,64})"\s+([\s\S]+)$/u.exec(input);
|
|
122
|
+
if (!marked) return { request: input, sessionTitle: null };
|
|
123
|
+
return { request: marked[2]!.trim(), sessionTitle: shortTaskLabel(marked[1]!) };
|
|
124
|
+
}
|