@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +5 -1
  3. package/README.vi.md +21 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +6 -0
  9. package/docs/release-install-policy.md +6 -6
  10. package/docs/security-threat-model.md +1 -1
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/context-engine.html +1 -1
  16. package/docs-site/en/architecture.html +1 -1
  17. package/docs-site/en/benchmark.html +1 -1
  18. package/docs-site/en/commands.html +1 -1
  19. package/docs-site/en/context-engine.html +1 -1
  20. package/docs-site/en/index.html +1 -1
  21. package/docs-site/en/mcp-approval.html +1 -1
  22. package/docs-site/en/mcp-auth.html +1 -1
  23. package/docs-site/en/mcp-servers.html +1 -1
  24. package/docs-site/en/mcp.html +1 -1
  25. package/docs-site/en/permissions.html +1 -1
  26. package/docs-site/en/profiles.html +1 -1
  27. package/docs-site/en/quickstart.html +1 -1
  28. package/docs-site/en/runtime.html +1 -1
  29. package/docs-site/en/scope.html +1 -1
  30. package/docs-site/en/security.html +1 -1
  31. package/docs-site/en/team.html +1 -1
  32. package/docs-site/en/whats-new.html +1 -1
  33. package/docs-site/en/workflows.html +1 -1
  34. package/docs-site/index.html +1 -1
  35. package/docs-site/mcp-approval.html +1 -1
  36. package/docs-site/mcp-auth.html +1 -1
  37. package/docs-site/mcp-servers.html +1 -1
  38. package/docs-site/mcp.html +1 -1
  39. package/docs-site/permissions.html +1 -1
  40. package/docs-site/profiles.html +1 -1
  41. package/docs-site/quickstart.html +1 -1
  42. package/docs-site/runtime.html +1 -1
  43. package/docs-site/scope.html +1 -1
  44. package/docs-site/security.html +1 -1
  45. package/docs-site/team.html +1 -1
  46. package/docs-site/whats-new.html +1 -1
  47. package/docs-site/workflows.html +1 -1
  48. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  49. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  50. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  51. package/package.json +1 -1
  52. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  53. package/packages/piagent-core/extensions/piagent-guard.ts +2 -2
  54. package/packages/piagent-core/package.json +1 -1
  55. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  56. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  57. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  58. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  59. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  60. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  61. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  62. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  63. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
  64. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  65. package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
  66. package/packages/piagent-webui/dist/client/index.html +2 -2
  67. package/packages/piagent-webui/extension/piagent-webui.ts +15 -2
  68. package/packages/piagent-webui/extension/same-session-bridge.ts +9 -9
  69. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  70. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  71. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  72. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  73. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  74. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  75. package/packages/piagent-webui/gateway/session-inspection-registry.ts +30 -10
  76. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +19 -10
  77. package/packages/piagent-webui/package.json +3 -1
  78. package/packages/piagent-webui/server/core-inspection-provider.ts +48 -11
  79. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  80. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  81. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  82. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  83. package/packages/piagent-webui/server/transcript-projection.ts +43 -2
  84. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  85. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  86. package/schemas/piagent-webui/catalog-v1.json +9 -0
  87. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  88. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  89. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  90. package/scripts/verify-local.sh +16 -1
  91. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
  92. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +0 -172
@@ -19,7 +19,8 @@ const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024;
19
19
  const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
20
20
 
21
21
  type ReadCapabilities = () => unknown | Promise<unknown>;
22
- type RateState = { windowStart: number; requests: number; bootstraps: number; controls: number };
22
+ type RateState = { windowStart: number; requests: number; bootstraps: number };
23
+ type ControlRateState = { windowStart: number; controls: number };
23
24
 
24
25
  export type LoopbackServer = {
25
26
  origin: string;
@@ -57,6 +58,7 @@ export async function startLoopbackServer(options: {
57
58
  readSessionCatalog?: () => unknown | Promise<unknown>;
58
59
  readSessionCreationOptions?: () => unknown | Promise<unknown>;
59
60
  readSessionModel?: (sessionRef: string) => WebUiReadModelProvider | Promise<WebUiReadModelProvider>;
61
+ executeSessionAttachment?: (sessionRef: string, command: unknown) => unknown | Promise<unknown>;
60
62
  readSessionConnections?: (sessionRef: string) => unknown | Promise<unknown>;
61
63
  executeSessionConnection?: (command: unknown) => unknown | Promise<unknown>;
62
64
  readMcpAuthJob?: (jobRef: string) => unknown | Promise<unknown>;
@@ -82,6 +84,15 @@ export async function startLoopbackServer(options: {
82
84
  const auth = new SessionAuthority({ bootstrapTtlMs: options.bootstrapTtlMs, sessionTtlMs: options.sessionTtlMs });
83
85
  const sse = options.readModel ? new SseHub(options.readModel) : null;
84
86
  const rates = new Map<string, RateState>();
87
+ const controlRates = new Map<string, ControlRateState>();
88
+ const consumeControl = (sessionId: string, now: number): boolean => {
89
+ for (const [id, value] of controlRates) if (now - value.windowStart >= 60_000) controlRates.delete(id);
90
+ const value = controlRates.get(sessionId) ?? { windowStart: now, controls: 0 };
91
+ if (now - value.windowStart >= 60_000) Object.assign(value, { windowStart: now, controls: 0 });
92
+ value.controls += 1; controlRates.set(sessionId, value);
93
+ while (controlRates.size > 64) controlRates.delete(controlRates.keys().next().value as string);
94
+ return value.controls <= CONTROLS_PER_MINUTE;
95
+ };
85
96
  let origin = "";
86
97
  const server = http.createServer(async (request, response) => {
87
98
  applySecurityHeaders(response, styleNonce);
@@ -90,9 +101,16 @@ export async function startLoopbackServer(options: {
90
101
  const requestOrigin = request.headers.origin;
91
102
  if (requestOrigin && requestOrigin !== origin) return errorResponse(response, 403, "origin-mismatch");
92
103
  const remote = request.socket.remoteAddress ?? "unknown", now = Date.now();
93
- const rate = rates.get(remote) ?? { windowStart: now, requests: 0, bootstraps: 0, controls: 0 };
94
- if (now - rate.windowStart >= 60_000) Object.assign(rate, { windowStart: now, requests: 0, bootstraps: 0, controls: 0 });
95
- rate.requests += 1; rates.set(remote, rate);
104
+ // A browser session is already a launch-scoped authenticated capability.
105
+ // Charging every tab and every prior launch to one localhost IP lets a
106
+ // refresh or an unauthenticated local process lock out the active operator.
107
+ const browserSession = auth.authenticate(request, now);
108
+ const rateKey = browserSession ? `${remote}:${browserSession.id}` : `remote:${remote}`;
109
+ for (const [key, value] of rates) if (now - value.windowStart >= 60_000) rates.delete(key);
110
+ const rate = rates.get(rateKey) ?? { windowStart: now, requests: 0, bootstraps: 0 };
111
+ if (now - rate.windowStart >= 60_000) Object.assign(rate, { windowStart: now, requests: 0, bootstraps: 0 });
112
+ rate.requests += 1; rates.set(rateKey, rate);
113
+ while (rates.size > 128) rates.delete(rates.keys().next().value as string);
96
114
  if (rate.requests > REQUESTS_PER_MINUTE) return errorResponse(response, 429, "rate-limit");
97
115
  let url: URL;
98
116
  try { url = new URL(request.url ?? "/", origin); }
@@ -122,10 +140,10 @@ export async function startLoopbackServer(options: {
122
140
 
123
141
  if (request.method === "POST" && ["/api/v1/chat/messages", "/api/v1/session-options", "/api/v1/lifecycle",
124
142
  "/api/v1/control/resume-and-continue", "/api/v1/reviews", "/api/v1/source-mutations", "/api/v1/source-handoffs"].includes(url.pathname) && options.executeControl) {
125
- rate.controls += 1;
126
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
127
143
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
128
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
144
+ const mutationSession = auth.authorizeMutation(request);
145
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
146
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
129
147
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
130
148
  let command: unknown;
131
149
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -135,10 +153,10 @@ export async function startLoopbackServer(options: {
135
153
  }
136
154
 
137
155
  if (request.method === "POST" && url.pathname === "/api/v1/projects/import" && options.executeProjectImport) {
138
- rate.controls += 1;
139
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
140
156
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
141
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
157
+ const mutationSession = auth.authorizeMutation(request);
158
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
159
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
142
160
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
143
161
  let command: unknown;
144
162
  try { command = JSON.parse((await requestBody(request, MAX_BOOTSTRAP_BODY_BYTES)).toString("utf8")); }
@@ -155,10 +173,10 @@ export async function startLoopbackServer(options: {
155
173
  }
156
174
 
157
175
  if (request.method === "POST" && url.pathname === "/api/v1/provider-auth" && options.executeProviderAuth) {
158
- rate.controls += 1;
159
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
160
176
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
161
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
177
+ const mutationSession = auth.authorizeMutation(request);
178
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
179
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
162
180
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
163
181
  let command: unknown;
164
182
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -172,10 +190,10 @@ export async function startLoopbackServer(options: {
172
190
  }
173
191
 
174
192
  if (request.method === "POST" && url.pathname === "/api/v1/session-connections" && options.executeSessionConnection) {
175
- rate.controls += 1;
176
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
177
193
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
178
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
194
+ const mutationSession = auth.authorizeMutation(request);
195
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
196
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
179
197
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
180
198
  let command: unknown;
181
199
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -188,9 +206,10 @@ export async function startLoopbackServer(options: {
188
206
  }
189
207
 
190
208
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/mcp-auth/jobs/") && url.pathname.endsWith("/cancel") && options.cancelMcpAuthJob) {
191
- rate.controls += 1;
192
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
193
- if (requestOrigin !== origin || !auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
209
+ if (requestOrigin !== origin) return errorResponse(response, 403, "mutation-authority-rejected");
210
+ const mutationSession = auth.authorizeMutation(request);
211
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
212
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
194
213
  const jobRef = url.pathname.slice("/api/v1/mcp-auth/jobs/".length, -"/cancel".length);
195
214
  if (!CURSOR.test(jobRef) || jobRef.includes("/") || url.search) return errorResponse(response, 400, "invalid-mcp-auth-job-ref");
196
215
  try { return jsonResponse(response, 200, await options.cancelMcpAuthJob(jobRef)); }
@@ -198,10 +217,10 @@ export async function startLoopbackServer(options: {
198
217
  }
199
218
 
200
219
  if (request.method === "POST" && url.pathname === "/api/v1/attachments" && options.executeAttachment) {
201
- rate.controls += 1;
202
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
203
220
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
204
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
221
+ const mutationSession = auth.authorizeMutation(request);
222
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
223
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
205
224
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
206
225
  let command: unknown;
207
226
  try { command = JSON.parse((await requestBody(request, MAX_ATTACHMENT_BODY_BYTES)).toString("utf8")); }
@@ -210,11 +229,30 @@ export async function startLoopbackServer(options: {
210
229
  catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
211
230
  }
212
231
 
232
+ // The Gateway drives many sessions, so an attachment has to name the one it
233
+ // belongs to in the path. The body is the same bounded stage/discard command
234
+ // the single-session route takes, and every guard in front of it is the same.
235
+ if (request.method === "POST" && url.pathname.startsWith("/api/v1/sessions/") && url.pathname.endsWith("/attachments")
236
+ && options.executeSessionAttachment) {
237
+ if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
238
+ const mutationSession = auth.authorizeMutation(request);
239
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
240
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
241
+ const sessionRef = url.pathname.slice("/api/v1/sessions/".length, -"/attachments".length);
242
+ if (!CURSOR.test(sessionRef) || sessionRef.includes("/")) return errorResponse(response, 400, "invalid-session-attachment-ref");
243
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
244
+ let command: unknown;
245
+ try { command = JSON.parse((await requestBody(request, MAX_ATTACHMENT_BODY_BYTES)).toString("utf8")); }
246
+ catch (error) { return errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-attachment-command"); }
247
+ try { return jsonResponse(response, 200, await options.executeSessionAttachment(sessionRef, command)); }
248
+ catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
249
+ }
250
+
213
251
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
214
- rate.controls += 1;
215
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
216
252
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
217
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
253
+ const mutationSession = auth.authorizeMutation(request);
254
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
255
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
218
256
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
219
257
  const approvalRef = url.pathname.slice("/api/v1/approvals/".length, -"/decision".length);
220
258
  if (!CURSOR.test(approvalRef) || approvalRef.includes("/")) return errorResponse(response, 400, "invalid-approval-ref");
@@ -22,6 +22,8 @@ export type WebUiReadModelProvider = {
22
22
  handoffHistory(runRef: string): unknown | Promise<unknown>;
23
23
  subagentTree(runRef: string): unknown | Promise<unknown>;
24
24
  releaseMonitor(): unknown | Promise<unknown>;
25
+ documents(): unknown | Promise<unknown>;
26
+ document(documentRef: string): unknown | Promise<unknown>;
25
27
  activity(): unknown | Promise<unknown>;
26
28
  logPreview(activityRef: string): unknown | Promise<unknown>;
27
29
  transcript(beforeCursor: string | null, limit: number): unknown | Promise<unknown>;
@@ -29,6 +29,12 @@ export async function routeReadOnlyRequest(url: URL, provider: WebUiReadModelPro
29
29
  if (url.search || !OPAQUE_REF.test(ref) || ref.includes("/")) return { handled: true, status: 400, value: { error: { code: "invalid-task-run-ref" } } };
30
30
  return { handled: true, status: 200, value: await provider.subagentTree(ref) };
31
31
  }
32
+ if (url.pathname === "/api/v1/documents" && !url.search) return { handled: true, status: 200, value: await provider.documents() };
33
+ if (url.pathname.startsWith("/api/v1/documents/")) {
34
+ const ref = url.pathname.slice("/api/v1/documents/".length);
35
+ if (url.search || !OPAQUE_REF.test(ref) || ref.includes("/")) return { handled: true, status: 400, value: { error: { code: "invalid-document-ref" } } };
36
+ return { handled: true, status: 200, value: await provider.document(ref) };
37
+ }
32
38
  if (url.pathname === "/api/v1/chat/queue" && !url.search) return { handled: true, status: 200, value: await provider.queue() };
33
39
  if (url.pathname === "/api/v1/session-options/models" && !url.search) return { handled: true, status: 200, value: await provider.modelCatalog() };
34
40
  if (url.pathname.startsWith("/api/v1/approvals/")) {
@@ -13,8 +13,10 @@ type TranscriptRevision = { runtimeRevision: string; taskRevision: string | null
13
13
  indexRevision: string | null; approvalRevision: string | null; sessionOptionRevision: string | null; queueRevision: string | null };
14
14
  type TranscriptContent = { state: "available" | "redacted" | "unavailable"; text: string | null; textChars: number | null; digest: string | null;
15
15
  truncated: boolean; redacted: boolean; imageCount: number; reasonCode: string | null };
16
+ type TranscriptAttachment = { displayName: string; kind: "file" | "image" | "document"; mimeType: string; truncated: boolean };
16
17
  type TranscriptItem = { messageRef: string; parentMessageRef: string | null; role: "user" | "assistant" | "tool-result"; recordedAt: string;
17
18
  agentOperationId: string | null; turnIndex: number | null; content: TranscriptContent;
19
+ attachments?: TranscriptAttachment[];
18
20
  toolCalls: Array<{ toolCallRef: string; toolName: string; state: "requested" | "completed" | "failed" | "unknown" }> };
19
21
  export type TranscriptDocument = { schemaVersion: 1; version: "piagent-webui-transcript-v1"; generatedAt: string; identity: TranscriptIdentity;
20
22
  revision: TranscriptRevision; eventCursor: string; state: "ready" | "unavailable"; items: TranscriptItem[];
@@ -41,6 +43,37 @@ function messageText(message: any): string {
41
43
  if (!Array.isArray(message?.content)) return "";
42
44
  return message.content.filter((item: any) => item?.type === "text" && typeof item.text === "string").map((item: any) => item.text).join("\n");
43
45
  }
46
+ function safeAttachmentName(value: unknown): string {
47
+ const projected = safeText(String(value ?? "attachment"));
48
+ return projected.full.replace(/[\\/]/g, "_").replace(/\s+/g, " ").trim().slice(0, 160) || "attachment";
49
+ }
50
+ function userMessageProjection(message: any): { text: string; attachments: TranscriptAttachment[] } {
51
+ if (message?.role !== "user" || !Array.isArray(message?.content)) return { text: messageText(message), attachments: [] };
52
+ const textParts = message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string");
53
+ const attachments: TranscriptAttachment[] = [];
54
+ for (const part of textParts.slice(1)) {
55
+ const lines = String(part.text).split("\n", 3);
56
+ if (!lines[0]?.startsWith("attached file: ") || !lines[1]?.startsWith("format: ")) continue;
57
+ let displayName: unknown;
58
+ try { displayName = JSON.parse(lines[0].slice("attached file: ".length)); } catch { continue; }
59
+ if (typeof displayName !== "string") continue;
60
+ const format = /^format: ([A-Za-z0-9][A-Za-z0-9.+-]*\/[A-Za-z0-9][A-Za-z0-9.+-]*)(, truncated)?$/.exec(lines[1]);
61
+ if (!format) continue;
62
+ const mimeType = format[1]!;
63
+ const kind = mimeType === "application/pdf" || mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
64
+ ? "document" as const : "file" as const;
65
+ attachments.push({ displayName: safeAttachmentName(displayName), kind, mimeType, truncated: Boolean(format[2]) });
66
+ }
67
+ for (const part of message.content) {
68
+ if (part?.type !== "image" || typeof part.mimeType !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.+-]*\/[A-Za-z0-9][A-Za-z0-9.+-]*$/.test(part.mimeType)) continue;
69
+ attachments.push({ displayName: "Image", kind: "image", mimeType: part.mimeType.slice(0, 120), truncated: false });
70
+ }
71
+ // Attachment bodies are provider input, not transcript prose. Showing or
72
+ // hashing them here would turn the chat bubble into a document dump and make
73
+ // a bounded UI digest an oracle over user files. The first block is the exact
74
+ // text the operator typed; the remaining recognized blocks become cards.
75
+ return { text: attachments.length ? String(textParts[0]?.text ?? "") : messageText(message), attachments: attachments.slice(0, 4) };
76
+ }
44
77
  function assistantFailureReason(message: any): string | null {
45
78
  if (message?.role !== "assistant" || message?.stopReason !== "error") return null;
46
79
  const detail = String(message?.errorMessage ?? "").toLowerCase();
@@ -82,15 +115,22 @@ function toolCalls(message: any, sessionRef: string, role: TranscriptItem["role"
82
115
  function role(message: any): TranscriptItem["role"] | null {
83
116
  return message?.role === "user" ? "user" : message?.role === "assistant" ? "assistant" : message?.role === "toolResult" ? "tool-result" : null;
84
117
  }
118
+ function isInternalFreshTransition(message: any): boolean {
119
+ if (message?.role !== "user") return false;
120
+ return /^\/fresh\s+(?:task|scout|be-to-fe)\s+(?:--session-title\s+"[^"\r\n]{1,64}"\s+)?Read task intake from \.pi\/task-inbox\/[A-Za-z0-9._-]+\.md\.\s+Current session is near context limits; use a fresh governed session\.$/u
121
+ .test(messageText(message).trim());
122
+ }
85
123
  function unavailableContent(reasonCode: string): TranscriptContent {
86
124
  return { state: "unavailable", text: null, textChars: null, digest: null, truncated: false, redacted: false, imageCount: 0, reasonCode };
87
125
  }
88
126
  function item(entry: any, identity: TranscriptIdentity): TranscriptItem | null {
89
127
  if (!entry || entry.type !== "message" || !entry.message) return null;
128
+ if (isInternalFreshTransition(entry.message)) return null;
90
129
  const itemRole = role(entry.message), recordedAt = timestamp(entry.timestamp ?? entry.message.timestamp);
91
130
  if (!itemRole || !recordedAt || typeof entry.id !== "string" || entry.id.length === 0) return null;
131
+ const userProjection = userMessageProjection(entry.message);
92
132
  const content = itemRole === "tool-result" ? unavailableContent("tool-output-in-activity-preview") : (() => {
93
- const projected = safeText(messageText(entry.message));
133
+ const projected = safeText(itemRole === "user" ? userProjection.text : messageText(entry.message));
94
134
  const failureReason = projected.full.length === 0 ? assistantFailureReason(entry.message) : null;
95
135
  if (failureReason) return unavailableContent(failureReason);
96
136
  return { state: projected.redacted ? "redacted" as const : "available" as const, text: projected.preview,
@@ -98,7 +138,8 @@ function item(entry: any, identity: TranscriptIdentity): TranscriptItem | null {
98
138
  redacted: projected.redacted, imageCount: imageCount(entry.message), reasonCode: projected.redacted ? "sensitive-values-redacted" as const : null };
99
139
  })();
100
140
  return { messageRef: opaque("message", [identity.sessionRef, entry.id]), parentMessageRef: null, role: itemRole, recordedAt,
101
- agentOperationId: null, turnIndex: null, content, toolCalls: toolCalls(entry.message, identity.sessionRef, itemRole) };
141
+ agentOperationId: null, turnIndex: null, content, ...(userProjection.attachments.length ? { attachments: userProjection.attachments } : {}),
142
+ toolCalls: toolCalls(entry.message, identity.sessionRef, itemRole) };
102
143
  }
103
144
  function unavailable(input: TranscriptProjectionInput, reasonCode: string, limit: number): TranscriptDocument {
104
145
  return { schemaVersion: 1, version: "piagent-webui-transcript-v1", generatedAt: input.generatedAt ?? new Date().toISOString(),
@@ -49,7 +49,12 @@
49
49
  "image/bmp",
50
50
  "text/plain",
51
51
  "text/markdown",
52
- "application/json"
52
+ "text/csv",
53
+ "text/tab-separated-values",
54
+ "application/json",
55
+ "application/yaml",
56
+ "application/pdf",
57
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
53
58
  ]
54
59
  },
55
60
  "dataBase64": {
@@ -86,7 +91,7 @@
86
91
  "attachment": {
87
92
  "type": "object",
88
93
  "additionalProperties": false,
89
- "required": ["attachmentRef", "messageRequestId", "displayName", "kind", "mimeType", "sizeBytes", "expiresAt"],
94
+ "required": ["attachmentRef", "messageRequestId", "displayName", "kind", "mimeType", "sizeBytes", "sourceBytes", "truncated", "expiresAt"],
90
95
  "properties": {
91
96
  "attachmentRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
92
97
  "messageRequestId": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
@@ -96,14 +101,29 @@
96
101
  "maxLength": 160,
97
102
  "pattern": "^[^\\u0000-\\u001F\\u007F/\\\\]*$"
98
103
  },
99
- "kind": { "enum": ["file", "image"] },
104
+ "kind": { "enum": ["file", "image", "document"] },
100
105
  "mimeType": {
101
106
  "type": "string",
102
107
  "minLength": 3,
103
108
  "maxLength": 127,
104
109
  "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+*-]+$"
105
110
  },
106
- "sizeBytes": { "type": "integer", "minimum": 1, "maximum": 8388608 },
111
+ "sizeBytes": {
112
+ "description": "Bytes held for dispatch: image bytes, or the UTF-8 text extracted from a text or document file.",
113
+ "type": "integer",
114
+ "minimum": 1,
115
+ "maximum": 8388608
116
+ },
117
+ "sourceBytes": {
118
+ "description": "Bytes of the file the operator selected, before extraction. Equal to sizeBytes for images.",
119
+ "type": "integer",
120
+ "minimum": 1,
121
+ "maximum": 8388608
122
+ },
123
+ "truncated": {
124
+ "description": "True when extraction produced more text than the per-attachment cap, so Pi receives a prefix.",
125
+ "type": "boolean"
126
+ },
107
127
  "expiresAt": { "$ref": "common-v1.schema.json#/$defs/timestamp" }
108
128
  }
109
129
  },
@@ -535,9 +535,9 @@
535
535
  "kinds": {
536
536
  "type": "array",
537
537
  "minItems": 1,
538
- "maxItems": 2,
538
+ "maxItems": 3,
539
539
  "uniqueItems": true,
540
- "items": { "enum": ["file", "image"] }
540
+ "items": { "enum": ["file", "image", "document"] }
541
541
  },
542
542
  "mimeTypes": {
543
543
  "type": "array",
@@ -173,6 +173,15 @@
173
173
  "supported": true,
174
174
  "current": true
175
175
  },
176
+ {
177
+ "name": "document-workspace-v1",
178
+ "file": "document-workspace-v1.schema.json",
179
+ "id": "https://piagent.io.vn/schemas/piagent-webui/document-workspace-v1.schema.json",
180
+ "documentVersion": 1,
181
+ "role": "read-model",
182
+ "supported": true,
183
+ "current": true
184
+ },
176
185
  {
177
186
  "name": "control-command-v1",
178
187
  "file": "control-command-v1.schema.json",
@@ -0,0 +1,155 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://piagent.io.vn/schemas/piagent-webui/document-workspace-v1.schema.json",
4
+ "title": "Piagent WebUI bounded document workspace v1",
5
+ "oneOf": [
6
+ { "$ref": "#/$defs/listing" },
7
+ { "$ref": "#/$defs/document" }
8
+ ],
9
+ "$defs": {
10
+ "displayText": {
11
+ "type": "string",
12
+ "minLength": 1,
13
+ "maxLength": 1024,
14
+ "pattern": "^[^\\u0000-\\u001F\\u007F]*$"
15
+ },
16
+ "nullableDisplayText": {
17
+ "anyOf": [
18
+ { "$ref": "#/$defs/displayText" },
19
+ { "type": "null" }
20
+ ]
21
+ },
22
+ "root": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["rootRef", "path", "source", "documentCount"],
26
+ "properties": {
27
+ "rootRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
28
+ "path": { "$ref": "#/$defs/displayText" },
29
+ "source": { "enum": ["project", "profile", "environment"] },
30
+ "documentCount": { "type": "integer", "minimum": 0, "maximum": 2000 }
31
+ }
32
+ },
33
+ "entry": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": ["documentRef", "rootRef", "name", "relativePath", "extension", "sizeBytes", "modifiedAt"],
37
+ "properties": {
38
+ "documentRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
39
+ "rootRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
40
+ "name": { "$ref": "#/$defs/displayText" },
41
+ "relativePath": { "$ref": "#/$defs/displayText" },
42
+ "extension": {
43
+ "enum": ["md", "markdown", "txt", "text", "csv", "tsv", "json", "yaml", "yml", "pdf", "docx"]
44
+ },
45
+ "sizeBytes": { "type": "integer", "minimum": 1, "maximum": 12582912 },
46
+ "modifiedAt": { "$ref": "common-v1.schema.json#/$defs/timestamp" }
47
+ }
48
+ },
49
+ "listing": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": ["schemaVersion", "version", "messageType", "generatedAt", "state", "roots", "documents", "truncated", "reasonCode"],
53
+ "properties": {
54
+ "schemaVersion": { "const": 1 },
55
+ "version": { "const": "piagent-webui-document-workspace-v1" },
56
+ "messageType": { "const": "listing" },
57
+ "generatedAt": { "$ref": "common-v1.schema.json#/$defs/timestamp" },
58
+ "state": { "enum": ["ready", "unavailable"] },
59
+ "roots": {
60
+ "type": "array",
61
+ "maxItems": 32,
62
+ "uniqueItems": true,
63
+ "items": { "$ref": "#/$defs/root" }
64
+ },
65
+ "documents": {
66
+ "type": "array",
67
+ "maxItems": 2000,
68
+ "items": { "$ref": "#/$defs/entry" }
69
+ },
70
+ "truncated": { "type": "boolean" },
71
+ "reasonCode": { "$ref": "common-v1.schema.json#/$defs/nullableReasonCode" }
72
+ },
73
+ "allOf": [
74
+ {
75
+ "if": { "properties": { "state": { "const": "unavailable" } }, "required": ["state"] },
76
+ "then": {
77
+ "properties": {
78
+ "roots": { "type": "array", "maxItems": 0 },
79
+ "documents": { "type": "array", "maxItems": 0 },
80
+ "reasonCode": { "$ref": "common-v1.schema.json#/$defs/reasonCode" }
81
+ }
82
+ }
83
+ },
84
+ {
85
+ "if": { "properties": { "state": { "const": "ready" } }, "required": ["state"] },
86
+ "then": {
87
+ "properties": {
88
+ "roots": { "type": "array", "minItems": 1 },
89
+ "reasonCode": { "type": "null" }
90
+ }
91
+ }
92
+ }
93
+ ]
94
+ },
95
+ "document": {
96
+ "type": "object",
97
+ "additionalProperties": false,
98
+ "required": ["schemaVersion", "version", "messageType", "generatedAt", "documentRef", "state", "name", "relativePath", "rootRef", "format", "text", "sizeBytes", "truncated", "redacted", "reasonCode"],
99
+ "properties": {
100
+ "schemaVersion": { "const": 1 },
101
+ "version": { "const": "piagent-webui-document-workspace-v1" },
102
+ "messageType": { "const": "document" },
103
+ "generatedAt": { "$ref": "common-v1.schema.json#/$defs/timestamp" },
104
+ "documentRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
105
+ "state": { "enum": ["ready", "unavailable"] },
106
+ "name": { "$ref": "#/$defs/nullableDisplayText" },
107
+ "relativePath": { "$ref": "#/$defs/nullableDisplayText" },
108
+ "rootRef": { "$ref": "common-v1.schema.json#/$defs/nullableOpaqueRef" },
109
+ "format": { "enum": ["text", "docx", "pdf", null] },
110
+ "text": {
111
+ "anyOf": [
112
+ { "type": "string", "maxLength": 400000 },
113
+ { "type": "null" }
114
+ ]
115
+ },
116
+ "sizeBytes": {
117
+ "anyOf": [
118
+ { "type": "integer", "minimum": 1, "maximum": 12582912 },
119
+ { "type": "null" }
120
+ ]
121
+ },
122
+ "truncated": { "type": "boolean" },
123
+ "redacted": { "type": "boolean" },
124
+ "reasonCode": { "$ref": "common-v1.schema.json#/$defs/nullableReasonCode" }
125
+ },
126
+ "allOf": [
127
+ {
128
+ "if": { "properties": { "state": { "const": "ready" } }, "required": ["state"] },
129
+ "then": {
130
+ "properties": {
131
+ "name": { "$ref": "#/$defs/displayText" },
132
+ "relativePath": { "$ref": "#/$defs/displayText" },
133
+ "rootRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
134
+ "format": { "enum": ["text", "docx", "pdf"] },
135
+ "text": { "type": "string", "maxLength": 400000 },
136
+ "reasonCode": { "type": "null" }
137
+ }
138
+ }
139
+ },
140
+ {
141
+ "if": { "properties": { "state": { "const": "unavailable" } }, "required": ["state"] },
142
+ "then": {
143
+ "properties": {
144
+ "text": { "type": "null" },
145
+ "format": { "type": "null" },
146
+ "truncated": { "const": false },
147
+ "redacted": { "const": false },
148
+ "reasonCode": { "$ref": "common-v1.schema.json#/$defs/reasonCode" }
149
+ }
150
+ }
151
+ }
152
+ ]
153
+ }
154
+ }
155
+ }
@@ -56,7 +56,11 @@
56
56
  "modelRef": { "$ref": "common-v1.schema.json#/$defs/nullableOpaqueRef" },
57
57
  "thinkingLevel": { "$ref": "#/$defs/thinkingLevel" },
58
58
  "message": { "type": "string", "minLength": 1, "maxLength": 32768, "pattern": "^[^\\u0000]*$" },
59
- "messageRequestId": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" }
59
+ "messageRequestId": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
60
+ "deferInitialMessage": {
61
+ "description": "Create the durable session without dispatching the first turn so the client can stage selected attachments against the new session before sending it.",
62
+ "type": "boolean"
63
+ }
60
64
  }
61
65
  }
62
66
  }
@@ -75,12 +79,19 @@
75
79
  "payload": {
76
80
  "type": "object",
77
81
  "additionalProperties": false,
78
- "required": ["delivery", "message", "messageRequestId", "expectedOperationRef"],
82
+ "required": ["delivery", "message", "messageRequestId", "expectedOperationRef", "attachmentRefs"],
79
83
  "properties": {
80
84
  "delivery": { "enum": ["new-operation", "follow-up", "steer"] },
81
85
  "message": { "type": "string", "minLength": 1, "maxLength": 32768, "pattern": "^[^\\u0000]*$" },
82
86
  "messageRequestId": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
83
- "expectedOperationRef": { "$ref": "common-v1.schema.json#/$defs/nullableOpaqueRef" }
87
+ "expectedOperationRef": { "$ref": "common-v1.schema.json#/$defs/nullableOpaqueRef" },
88
+ "attachmentRefs": {
89
+ "description": "Refs staged for this exact messageRequestId. Claimed once at dispatch and consumed.",
90
+ "type": "array",
91
+ "maxItems": 4,
92
+ "uniqueItems": true,
93
+ "items": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" }
94
+ }
84
95
  },
85
96
  "allOf": [
86
97
  {
@@ -188,6 +188,12 @@
188
188
  "agentOperationId": { "$ref": "common-v1.schema.json#/$defs/nullableOpaqueRef" },
189
189
  "turnIndex": { "$ref": "#/$defs/nullableCount" },
190
190
  "content": { "$ref": "#/$defs/content" },
191
+ "attachments": {
192
+ "type": "array",
193
+ "maxItems": 4,
194
+ "uniqueItems": true,
195
+ "items": { "$ref": "#/$defs/attachmentSummary" }
196
+ },
191
197
  "toolCalls": {
192
198
  "type": "array",
193
199
  "maxItems": 64,
@@ -217,6 +223,27 @@
217
223
  }
218
224
  ]
219
225
  },
226
+ "attachmentSummary": {
227
+ "type": "object",
228
+ "additionalProperties": false,
229
+ "required": ["displayName", "kind", "mimeType", "truncated"],
230
+ "properties": {
231
+ "displayName": {
232
+ "type": "string",
233
+ "minLength": 1,
234
+ "maxLength": 160,
235
+ "pattern": "^[^\\u0000-\\u001F\\u007F/\\\\]*$"
236
+ },
237
+ "kind": { "enum": ["file", "image", "document"] },
238
+ "mimeType": {
239
+ "type": "string",
240
+ "minLength": 1,
241
+ "maxLength": 120,
242
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9.+-]*/[A-Za-z0-9][A-Za-z0-9.+-]*$"
243
+ },
244
+ "truncated": { "type": "boolean" }
245
+ }
246
+ },
220
247
  "page": {
221
248
  "type": "object",
222
249
  "additionalProperties": false,