@tea-agent/loop-agent 0.40.0-next.11 → 0.40.0-next.13
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 +45 -0
- package/README.md +1 -1
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +9 -5
- package/dist/cli/program.js +38 -3
- package/dist/commands/client-recovery.js +8 -36
- package/dist/commands/task-advance.js +23 -1
- package/dist/commands/task-source-prepare.js +5 -0
- package/dist/commands/task-status.js +1 -0
- package/dist/executors/pi-sdk-executor.js +33 -5
- package/dist/shared/operator/capabilities.js +9 -1
- package/dist/shared/pi-context-pressure/checkpoint.js +116 -0
- package/dist/shared/pi-context-pressure/compaction-policy.js +151 -0
- package/dist/shared/pi-context-pressure/env.js +58 -0
- package/dist/shared/pi-context-pressure/extension.js +100 -0
- package/dist/shared/pi-context-pressure/index.js +7 -0
- package/dist/shared/pi-context-pressure/overflow.js +252 -0
- package/dist/shared/pi-context-pressure/sift-bridge.js +386 -0
- package/dist/shared/pi-context-pressure/telemetry.js +51 -0
- package/dist/worker/console/chat/assistant-content.js +7 -0
- package/dist/worker/console/chat/chat-event-store.js +82 -11
- package/dist/worker/console/chat/goal-round-driver.js +72 -0
- package/dist/worker/console/chat/goals.js +300 -0
- package/dist/worker/console/chat/pi-runtime.js +285 -8
- package/dist/worker/console/chat/provider-error.js +2 -1
- package/dist/worker/console/chat/repo-browser.js +94 -12
- package/dist/worker/console/chat/routes.js +485 -29
- package/dist/worker/console/chat/session-catalog.js +48 -0
- package/dist/worker/console/chat/session-store.js +27 -18
- package/dist/worker/console/chat/shortcuts.js +7 -0
- package/dist/worker/console/chat/sift-bridge.js +1 -0
- package/dist/worker/console/chat/turn-process.js +32 -3
- package/dist/worker/console/operator-actions.js +16 -2
- package/dist/worker/console/operator-selection.js +6 -2
- package/dist/worker/console/pi-plugins.catalog.json +73658 -0
- package/dist/worker/console/pi-plugins.js +100 -0
- package/dist/worker/console/routes.js +221 -6
- package/dist/worker/console/server.js +172 -42
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Bs-CDAXM.js → abnfDiagram-N423BO3Z-Wj-fsQlk.js} +1 -1
- package/dist/worker/console/static/assets/{arc-CAkA3We3.js → arc-CkXT-vzQ.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DEmI_zqr.js → architectureDiagram-T3A2C74G-xlfnojWU.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-uDHANebE.js → blockDiagram-VBNYF7ZC-BilahvuB.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Bv97b4_B.js → c4Diagram-5PPSVZJV-DqtAVhjz.js} +1 -1
- package/dist/worker/console/static/assets/channel-SnQ_u-Td.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-shmQKsFz.js → chunk-2GRJ4B5K-pAqQXZ5y.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-BAraNvcN.js → chunk-2Q5K7J3B-BEWxeq2r.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BirhxUop.js → chunk-5RXB4S5H-CvlbGDTp.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C9LgUJpR.js → chunk-5VM5RSS4-D0uGd9-f.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DrU-s-t7.js → chunk-6Q2QTUOP-DSdWIteg.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Tx-H_FA2.js → chunk-GF5L2VYU-Dweg7f0v.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BjLtNFrR.js → chunk-JWPE2WC7-BpZKI_k7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-UNLVwSsy.js → chunk-KBJHAD2P-IPLknC4m.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CBEUX8XD.js → chunk-RYQCIY6F-E93k9lVI.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-WTL7IZOq.js → chunk-XXDRQBXY-BrESwkPq.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-SGM51NVY.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-SGM51NVY.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CkHG6W3s.js → cose-bilkent-JH36ORCC-DmFHvJkB.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BRzFqTaL.js → cynefin-VYW2F7L2-CDigXiSL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-2_CFp_i7.js → cynefinDiagram-MW4NZA55-B0zhrbaA.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJtsmdkE.js → dagre-VZM6K2ZE-DrKybVxm.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DNEFFo6w.js → diagram-7IWD3JNH-CeUrnbFk.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B_IWV7eo.js → diagram-B4RE2ZJO-BJyunUMi.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DvvRx2dz.js → diagram-LBJQPF4R-a5TBP73u.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CsibsWLg.js → diagram-Q27KOJAE-ChRgBURJ.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-les_djBQ.js → diagram-UB23O5K3-CqCnPRSk.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BEm2n7qJ.js → ebnfDiagram-BXEA7PRR-BI-UD9_S.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BI7vHHVm.js → erDiagram-JOGREHBK-CDLvvkYZ.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CUFCERrB.js → flowDiagram-UKHOOZJN-Heoo4afj.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-wxyLiGWt.js → ganttDiagram-PKOTCBZU-kFpOitHd.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bt897K8Z.js → gitGraphDiagram-DS77QQ5N-FvyZ9WAY.js} +1 -1
- package/dist/worker/console/static/assets/index-C80SO8y0.js +437 -0
- package/dist/worker/console/static/assets/index-CmJCHmJP.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DYNx_IPv.js → infoDiagram-6WML65LV-HBzlwtgI.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CzpLNEu6.js → ishikawaDiagram-WSZJBQD7-C9Iim5sm.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-97f9owNU.js → journeyDiagram-NVQOT4AX-CJwJTg0S.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BxM9hyBn.js → kanban-definition-27J2QSJJ-CZNJ7S4q.js} +1 -1
- package/dist/worker/console/static/assets/{linear-ByuxcHvp.js → linear-RZ0AnGi8.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DwPGvpCv.js → mermaid.core-EYSdm-nd.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DCoqG9RT.js → mindmap-definition-FAOFIHXS-D0OLECFN.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BLSBmBMx.js → pegDiagram-VL7TDLO6-CPTvGbh7.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B0t_0oJW.js → pieDiagram-7S7Q4E2Y-Ddc1aDea.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js → quadrantDiagram-CIZ2JOQS-CWCTCdAt.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-B3jRdATC.js → railroadDiagram-AXF67PYL-CfsaBL8E.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-cTtXEN.js → requirementDiagram-LRYGKXZP-BH2rqGf6.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B25igJRE.js → sankeyDiagram-W5VNT64P-BZgJ1Til.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-jW_lM1_D.js → sequenceDiagram-SI44F4Z6-DmPr-o-y.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CrzTgsHE.js → sizeCapture-X5ZJPWSS-CF6HzDh_.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-B01MvlqE.js → stateDiagram-OKZ733FA-DhS8N_B9.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-N56zxwsk.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-YcF12FiU.js → swimlanes-SLNWSIFB-CYNzoiaD.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-o47kGgVt.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ClnRcg2s.js → timeline-definition-Z64GVDOM-QBICF5qH.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-B6gr_z-q.js → vennDiagram-T6HMQDX7-fquBvWcf.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CvSgguGH.js → wardleyDiagram-T6FBY63Y-BYaJLlSd.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-23OtSZCb.js → xychartDiagram-ELKLHX3M--ePe_JCJ.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +95 -25
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +35 -1
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +3 -1
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +24 -19
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +0 -11
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +8 -1
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +34 -4
- package/dist/worker/console/static-src/operator-chat/workspace-reorder.js +18 -0
- package/dist/worker/console/static-src/shell/useWorkspaces.js +150 -34
- package/dist/worker/console/workspace-context.js +28 -3
- package/dist/worker/console/workspace-registry.js +333 -60
- package/dist/worker/observe/static/inspect-bootstrap.d.ts +8 -0
- package/dist/worker/observe/static/inspect-bootstrap.js +57 -0
- package/dist/worker/observe/static/inspect-workspace.js +54 -10
- package/dist/worker/observe/static/operator-chrome.css +23 -4
- package/dist/worker/observe/static/operator-chrome.js +6 -2
- package/dist/worker/observe/static/styles.css +87 -25
- package/dist/worker/observe/static/views/dag-inspector.js +42 -1
- package/dist/worker/observe/static/views/dags.js +7 -5
- package/dist/worker/observe/static/views/session-timeline.js +135 -0
- package/dist/workflows/dag/init-hybrid.js +3 -3
- package/docs/architecture/runtime-boundaries.md +2 -1
- package/docs/templates/backend-test-dag.json +3 -3
- package/package.json +3 -2
- package/skills/loop-agent/references/command-reference.md +14 -0
- package/dist/worker/console/static/assets/channel-amxUpk7o.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +0 -1
- package/dist/worker/console/static/assets/index-BHhUOTri.css +0 -1
- package/dist/worker/console/static/assets/index-DqJbO3-p.js +0 -407
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +0 -8
|
@@ -36,10 +36,13 @@ import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteTo
|
|
|
36
36
|
import { createOperatorChatResourceLoader, } from "./resource-loader.js";
|
|
37
37
|
import { applySkillAutoInvocationPreferences, isAutoInvocationEnabled, loadResourcePreferences, skillResourceId, } from "./resource-preferences-store.js";
|
|
38
38
|
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
39
|
+
import { applyProcessCompactionOverride, createCompactionController, createContextPressureExtension, createSiftBridgeState, housekeepSiftStash, isConsoleSiftEnabled, parseCompactionPolicy, parseSiftBridgeConfig, projectPromptPressure, deriveEffectiveWindow, projectSafeTelemetry, emptyContextPressureCounters, } from "../../../shared/pi-context-pressure/index.js";
|
|
40
|
+
import { incrementCounter as incrementPressureCounter } from "../../../shared/pi-context-pressure/telemetry.js";
|
|
39
41
|
import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
|
|
40
42
|
import { filterActiveInterviewTools } from "../interview/tools.js";
|
|
41
43
|
import { RUNTIME_CONTEXT_TEXT_MAX, redactRuntimeText, } from "./runtime-context.js";
|
|
42
44
|
import { buildTodoContinuationContext, } from "./todos.js";
|
|
45
|
+
import { buildGoalContinuationContext, } from "./goals.js";
|
|
43
46
|
/**
|
|
44
47
|
* Base system prompt fragment every General Operator Chat session receives.
|
|
45
48
|
*
|
|
@@ -64,9 +67,9 @@ export const OPERATOR_CHAT_SYSTEM_PROMPT_BASE = [
|
|
|
64
67
|
"You are General Operator Chat.",
|
|
65
68
|
"Use operator tools first (dagReport, dagRerunPlan, dagRerun). Pi read/write/edit/bash/grep/find/ls are available; obey repository AGENTS.md. apply_patch, full-tools, shell and coding-chat are denied. Prefer safe-read/safe-grep for probes.",
|
|
66
69
|
"No harness and asked to init: `loop-agent init instructions --repo-root .`, then `loop-agent init --repo-root . --profile full --merge`; verify with doctor, inspect and docs audit. Updates: `loop-agent init upgrade --repo-root . --json` until stable; `loop-agent init check-update` is read-only.",
|
|
67
|
-
"Long DAGs: prepareDagExecution → runDag → operationId. Never foreground-Bash task advance, tail/head pipes or nohup/Start-Process/start; do not end on accepted/queued/running or an operationId — NOT completion. Supervise operationGet/status/dagReport/dagDoctor/operationWait with 60s → 90s → 120s → 180s backoff; reset to 60s on state change; 30-60s re-checks when stall suspected. Every operationWait continuation MUST use afterSeq=previous nextSeq (
|
|
68
|
-
"On failure consume terminal runSummary first. If progress exists, wait; else dagReport then dagRerunPlan(fromNode=primaryFailure.nodeId); eligible → dagRerun (node rerun only: reruns the failed subgraph, keeps prior successes). If plan ineligible (unsafe-shell subgraph, binding/workspace drift): Worker Failed → workerTaskRetry; standalone → standaloneTaskRerun after contract adopt (regenerates the whole task, not node continue). Do not call dagResume.
|
|
69
|
-
"Iterate toward the goal in bounded rounds: first summarize the previous round's error in one line
|
|
70
|
+
"Long DAGs: prepareDagExecution → runDag → operationId. Never foreground-Bash task advance, tail/head pipes or nohup/Start-Process/start; do not end on accepted/queued/running or an operationId — NOT completion. Supervise operationGet/status/dagReport/dagDoctor/operationWait with 60s → 90s → 120s → 180s backoff; reset to 60s on state change; 30-60s re-checks when stall suspected. Every operationWait continuation MUST use afterSeq=previous nextSeq (recommendedNextCall); never reuse older cursors. Heartbeats: not meaningful progress. Run id 20260829-slug ≠ task id 2026-08-29-slug.",
|
|
71
|
+
"On failure consume terminal runSummary first. If progress exists, wait; else dagReport then dagRerunPlan(fromNode=primaryFailure.nodeId); eligible → dagRerun (node rerun only: reruns the failed subgraph, keeps prior successes). If plan ineligible (unsafe-shell subgraph, binding/workspace drift): Worker Failed → workerTaskRetry; standalone → standaloneTaskRerun after contract adopt (regenerates the whole task, not node continue). Do not call dagResume. Run-failed task without open gate is terminal: do not re-advance a run-failed task. Before run start, two identical lifecycleState/blocker/revision/DAG-hash observations mean no progress: stop and report after one bounded probe; never loop identical bash/read/grep. Auto-approve only bounded writeSet inside allowedPaths, outside forbiddenPaths, with structured verification.",
|
|
72
|
+
"Iterate toward the goal in bounded rounds: first summarize the previous round's error in one line, fix its root cause — not the symptom — then rerun the failed node with a fresh plan. Never resubmit an unchanged fix; after two rounds failing the same way, stop and report evidence and remaining routes.",
|
|
70
73
|
"Continue until success, user stop, or no safe recovery remains; then stop and report failure/evidence. Never create a new task for a transient failure or replace repair-pi with direct edits.",
|
|
71
74
|
].join("\n");
|
|
72
75
|
/** Compose the inspectable system prompt actually injected into Operator Chat. */
|
|
@@ -257,6 +260,43 @@ export function createTodoCompactionContextExtension(options) {
|
|
|
257
260
|
},
|
|
258
261
|
};
|
|
259
262
|
}
|
|
263
|
+
/** Goal survives compaction and Turn boundaries; unlike Todo it is not reset
|
|
264
|
+
* at turn_start. The extension only injects after a successful compaction so
|
|
265
|
+
* ordinary contexts still rely on the model-callable get_goal source. */
|
|
266
|
+
export function createGoalCompactionContextExtension(options) {
|
|
267
|
+
return {
|
|
268
|
+
name: "operator-chat-goal-compaction-context",
|
|
269
|
+
hidden: true,
|
|
270
|
+
factory: (api) => {
|
|
271
|
+
let compacted = false;
|
|
272
|
+
api.on("session_compact", () => {
|
|
273
|
+
compacted = true;
|
|
274
|
+
});
|
|
275
|
+
api.on("context", (rawEvent) => {
|
|
276
|
+
if (!compacted)
|
|
277
|
+
return undefined;
|
|
278
|
+
const goal = options.registry.get(options.sessionId);
|
|
279
|
+
const context = buildGoalContinuationContext(goal);
|
|
280
|
+
if (!context)
|
|
281
|
+
return undefined;
|
|
282
|
+
const messages = Array.isArray(rawEvent.messages) ? rawEvent.messages : [];
|
|
283
|
+
return {
|
|
284
|
+
messages: [
|
|
285
|
+
...messages,
|
|
286
|
+
{
|
|
287
|
+
role: "custom",
|
|
288
|
+
customType: "operator-chat-goal-context",
|
|
289
|
+
content: context,
|
|
290
|
+
display: false,
|
|
291
|
+
details: { goal },
|
|
292
|
+
timestamp: Date.now(),
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
260
300
|
/**
|
|
261
301
|
* Build Pi custom ToolDefinition objects for every whitelisted operator action.
|
|
262
302
|
* Each tool's execute() routes into the operator action dispatcher (Gate 3).
|
|
@@ -406,6 +446,80 @@ async function buildTodoWriteTool(options) {
|
|
|
406
446
|
},
|
|
407
447
|
});
|
|
408
448
|
}
|
|
449
|
+
const GET_GOAL_DESCRIPTION = "Read the current session's durable cross-turn Goal, including revision, status, round limit, and whether this Console process is authorized to continue it.";
|
|
450
|
+
const CREATE_GOAL_DESCRIPTION = "Create a durable cross-turn Goal only when the user explicitly asks to start or create a Goal. Never silently upgrade an ordinary request. A session may have only one unfinished Goal.";
|
|
451
|
+
const UPDATE_GOAL_DESCRIPTION = "Finish or block the current durable Goal using its exact goalId and revision. Set complete only when the objective is genuinely achieved and no required work remains. Set blocked only when safe progress cannot continue; include the blocker.";
|
|
452
|
+
async function buildGoalTools(options) {
|
|
453
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
454
|
+
import("typebox"),
|
|
455
|
+
import("@earendil-works/pi-coding-agent"),
|
|
456
|
+
]);
|
|
457
|
+
const textResult = (value) => ({
|
|
458
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
459
|
+
details: value,
|
|
460
|
+
});
|
|
461
|
+
return [
|
|
462
|
+
defineTool({
|
|
463
|
+
name: "get_goal",
|
|
464
|
+
label: "Get goal",
|
|
465
|
+
description: GET_GOAL_DESCRIPTION,
|
|
466
|
+
promptSnippet: GET_GOAL_DESCRIPTION,
|
|
467
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
468
|
+
async execute() {
|
|
469
|
+
return textResult({ goal: options.registry.get(options.sessionId) });
|
|
470
|
+
},
|
|
471
|
+
}),
|
|
472
|
+
defineTool({
|
|
473
|
+
name: "create_goal",
|
|
474
|
+
label: "Create goal",
|
|
475
|
+
description: CREATE_GOAL_DESCRIPTION,
|
|
476
|
+
promptSnippet: CREATE_GOAL_DESCRIPTION,
|
|
477
|
+
parameters: Type.Object({
|
|
478
|
+
objective: Type.String({
|
|
479
|
+
description: "Concrete final objective and completion standard explicitly requested by the user.",
|
|
480
|
+
}),
|
|
481
|
+
}, { additionalProperties: false }),
|
|
482
|
+
async execute(_toolCallId, params) {
|
|
483
|
+
if (!options.registry.consumeModelCreateGrant(options.sessionId)) {
|
|
484
|
+
return textResult({
|
|
485
|
+
ok: false,
|
|
486
|
+
code: "GOAL_EXPLICIT_REQUEST_REQUIRED",
|
|
487
|
+
message: "create_goal requires an explicit user request to create or start a Goal",
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return textResult(options.registry.create({
|
|
491
|
+
sessionId: options.sessionId,
|
|
492
|
+
objective: params.objective,
|
|
493
|
+
}));
|
|
494
|
+
},
|
|
495
|
+
}),
|
|
496
|
+
defineTool({
|
|
497
|
+
name: "update_goal",
|
|
498
|
+
label: "Update goal",
|
|
499
|
+
description: UPDATE_GOAL_DESCRIPTION,
|
|
500
|
+
promptSnippet: UPDATE_GOAL_DESCRIPTION,
|
|
501
|
+
parameters: Type.Object({
|
|
502
|
+
goalId: Type.String(),
|
|
503
|
+
revision: Type.Number({ minimum: 1 }),
|
|
504
|
+
status: Type.Union([
|
|
505
|
+
Type.Literal("complete"),
|
|
506
|
+
Type.Literal("blocked"),
|
|
507
|
+
]),
|
|
508
|
+
blocker: Type.Optional(Type.String()),
|
|
509
|
+
}, { additionalProperties: false }),
|
|
510
|
+
async execute(_toolCallId, params) {
|
|
511
|
+
return textResult(options.registry.update({
|
|
512
|
+
sessionId: options.sessionId,
|
|
513
|
+
goalId: params.goalId,
|
|
514
|
+
expectedRevision: params.revision,
|
|
515
|
+
phase: params.status,
|
|
516
|
+
blocker: params.blocker,
|
|
517
|
+
arm: false,
|
|
518
|
+
}));
|
|
519
|
+
},
|
|
520
|
+
}),
|
|
521
|
+
];
|
|
522
|
+
}
|
|
409
523
|
/**
|
|
410
524
|
* Build the safe explore custom tools (roadmap M0-A / G16): safe-read,
|
|
411
525
|
* safe-grep, gitStatus, gitDiff. These replace the bare SDK bash/read/grep so
|
|
@@ -845,6 +959,9 @@ export class ConsolePiRuntime {
|
|
|
845
959
|
sessionModels = new Map();
|
|
846
960
|
/** SessionIds disposed while materialization was still in flight. */
|
|
847
961
|
disposedSessions = new Set();
|
|
962
|
+
compactionControllers = new Map();
|
|
963
|
+
siftStates = new Map();
|
|
964
|
+
/** Session-less tool definition caches (no AgentSession state). */
|
|
848
965
|
/** Session-less tool definition caches (no AgentSession state). */
|
|
849
966
|
operatorCustomToolsPromise;
|
|
850
967
|
exploreCustomToolsPromise;
|
|
@@ -892,14 +1009,25 @@ export class ConsolePiRuntime {
|
|
|
892
1009
|
registry: this.options.todoRegistry,
|
|
893
1010
|
})
|
|
894
1011
|
: undefined;
|
|
1012
|
+
const pressureExtension = sessionId
|
|
1013
|
+
? this.createContextPressureInlineExtension(sessionId)
|
|
1014
|
+
: undefined;
|
|
1015
|
+
const goalContextExtension = sessionId && this.options.goalRegistry
|
|
1016
|
+
? createGoalCompactionContextExtension({
|
|
1017
|
+
sessionId,
|
|
1018
|
+
registry: this.options.goalRegistry,
|
|
1019
|
+
})
|
|
1020
|
+
: undefined;
|
|
1021
|
+
const extraFactories = [
|
|
1022
|
+
todoContextExtension,
|
|
1023
|
+
goalContextExtension,
|
|
1024
|
+
pressureExtension,
|
|
1025
|
+
].filter((item) => Boolean(item));
|
|
895
1026
|
return {
|
|
896
1027
|
...base,
|
|
897
|
-
...(
|
|
1028
|
+
...(extraFactories.length > 0
|
|
898
1029
|
? {
|
|
899
|
-
extensionFactories: [
|
|
900
|
-
...inheritedFactories,
|
|
901
|
-
todoContextExtension,
|
|
902
|
-
],
|
|
1030
|
+
extensionFactories: [...inheritedFactories, ...extraFactories],
|
|
903
1031
|
}
|
|
904
1032
|
: {}),
|
|
905
1033
|
skillsOverride: (current) => {
|
|
@@ -924,6 +1052,126 @@ export class ConsolePiRuntime {
|
|
|
924
1052
|
this.cachedSkillPrefs = prefs;
|
|
925
1053
|
return prefs;
|
|
926
1054
|
}
|
|
1055
|
+
getCompactionController(sessionId) {
|
|
1056
|
+
let controller = this.compactionControllers.get(sessionId);
|
|
1057
|
+
if (!controller) {
|
|
1058
|
+
controller = createCompactionController();
|
|
1059
|
+
this.compactionControllers.set(sessionId, controller);
|
|
1060
|
+
}
|
|
1061
|
+
if (this.options.todoRegistry) {
|
|
1062
|
+
controller.todos = this.options.todoRegistry.get(sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
return controller;
|
|
1065
|
+
}
|
|
1066
|
+
createContextPressureInlineExtension(sessionId) {
|
|
1067
|
+
const policy = parseCompactionPolicy();
|
|
1068
|
+
const siftEnabled = isConsoleSiftEnabled();
|
|
1069
|
+
let sift;
|
|
1070
|
+
if (siftEnabled.enabled) {
|
|
1071
|
+
sift = this.siftStates.get(sessionId);
|
|
1072
|
+
if (!sift) {
|
|
1073
|
+
const config = parseSiftBridgeConfig({
|
|
1074
|
+
sessionId,
|
|
1075
|
+
defaultStashRoot: path.join(this.options.sessionDir, "sift-stash"),
|
|
1076
|
+
});
|
|
1077
|
+
sift = createSiftBridgeState(config);
|
|
1078
|
+
sift.counters = this.getCompactionController(sessionId).counters;
|
|
1079
|
+
this.siftStates.set(sessionId, sift);
|
|
1080
|
+
const housekeep = housekeepSiftStash({
|
|
1081
|
+
root: path.dirname(config.stashDir),
|
|
1082
|
+
activeSessionId: sessionId,
|
|
1083
|
+
retentionDays: config.retentionDays,
|
|
1084
|
+
});
|
|
1085
|
+
if (housekeep.failed)
|
|
1086
|
+
sift.diagnostics.add("housekeep-failed");
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return createContextPressureExtension({
|
|
1090
|
+
name: "operator-chat-context-pressure",
|
|
1091
|
+
policy,
|
|
1092
|
+
controller: this.getCompactionController(sessionId),
|
|
1093
|
+
sift,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
applySessionCompactionOverride(sessionId, services, session) {
|
|
1097
|
+
const manager = services?.settingsManager;
|
|
1098
|
+
const window = session?.getSessionStats?.()?.contextUsage?.contextWindow;
|
|
1099
|
+
applyProcessCompactionOverride(manager, {
|
|
1100
|
+
policy: parseCompactionPolicy(),
|
|
1101
|
+
...(typeof window === "number" ? { contextWindow: window } : {}),
|
|
1102
|
+
});
|
|
1103
|
+
void sessionId;
|
|
1104
|
+
}
|
|
1105
|
+
async runPrePromptCompaction(sessionId, session, promptText, imageCount, onEvent) {
|
|
1106
|
+
const policy = parseCompactionPolicy();
|
|
1107
|
+
if (policy.mode === "off")
|
|
1108
|
+
return;
|
|
1109
|
+
const controller = this.getCompactionController(sessionId);
|
|
1110
|
+
const stats = session.getSessionStats?.();
|
|
1111
|
+
const window = stats?.contextUsage?.contextWindow;
|
|
1112
|
+
const tokens = stats?.contextUsage?.tokens;
|
|
1113
|
+
if (typeof window === "number" && window > 0) {
|
|
1114
|
+
this.applySessionCompactionOverride(sessionId, this.serviceScopes.get(sessionId) ?? {}, session);
|
|
1115
|
+
}
|
|
1116
|
+
const projection = projectPromptPressure({
|
|
1117
|
+
currentContextTokens: tokens,
|
|
1118
|
+
promptText,
|
|
1119
|
+
imageCount,
|
|
1120
|
+
});
|
|
1121
|
+
if (projection.usageUnavailable) {
|
|
1122
|
+
incrementPressureCounter(controller.counters, "usage-unavailable");
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const effective = typeof window === "number" && window > 0
|
|
1126
|
+
? deriveEffectiveWindow(window, policy)
|
|
1127
|
+
: undefined;
|
|
1128
|
+
if (!effective || projection.projected < effective.triggerTokens)
|
|
1129
|
+
return;
|
|
1130
|
+
if (typeof session.compact !== "function")
|
|
1131
|
+
return;
|
|
1132
|
+
const compactionId = newCompactionId(sessionId);
|
|
1133
|
+
onEvent({
|
|
1134
|
+
type: "compaction_start",
|
|
1135
|
+
sessionId,
|
|
1136
|
+
compactionId,
|
|
1137
|
+
reason: "threshold",
|
|
1138
|
+
});
|
|
1139
|
+
controller.proactiveCompaction = true;
|
|
1140
|
+
try {
|
|
1141
|
+
incrementPressureCounter(controller.counters, "pre-prompt-threshold");
|
|
1142
|
+
const result = await session.compact();
|
|
1143
|
+
const snapshot = projectCompactSnapshot(result ?? {});
|
|
1144
|
+
onEvent({
|
|
1145
|
+
type: "compaction_end",
|
|
1146
|
+
sessionId,
|
|
1147
|
+
compactionId,
|
|
1148
|
+
reason: "threshold",
|
|
1149
|
+
status: "completed",
|
|
1150
|
+
willRetry: false,
|
|
1151
|
+
summary: snapshot.summary,
|
|
1152
|
+
...(snapshot.firstKeptEntryId
|
|
1153
|
+
? { firstKeptEntryId: snapshot.firstKeptEntryId }
|
|
1154
|
+
: {}),
|
|
1155
|
+
...(snapshot.tokensBefore === undefined
|
|
1156
|
+
? {}
|
|
1157
|
+
: { tokensBefore: snapshot.tokensBefore }),
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
incrementPressureCounter(controller.counters, "l0-compact-failed");
|
|
1162
|
+
onEvent({
|
|
1163
|
+
type: "compaction_end",
|
|
1164
|
+
sessionId,
|
|
1165
|
+
compactionId,
|
|
1166
|
+
reason: "threshold",
|
|
1167
|
+
status: "failed",
|
|
1168
|
+
willRetry: false,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
finally {
|
|
1172
|
+
controller.proactiveCompaction = false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
927
1175
|
/**
|
|
928
1176
|
* Resolve a skill in the current session inventory by server-issued resourceId.
|
|
929
1177
|
* Returns undefined when the id is unknown (caller maps to PI_SKILL_NOT_FOUND).
|
|
@@ -1156,6 +1404,7 @@ export class ConsolePiRuntime {
|
|
|
1156
1404
|
resourceLoader: services.resourceLoader,
|
|
1157
1405
|
settingsManager: services.settingsManager,
|
|
1158
1406
|
});
|
|
1407
|
+
this.applySessionCompactionOverride(sessionId, services, session);
|
|
1159
1408
|
this.revisions.set(sessionId, 1);
|
|
1160
1409
|
if (!this.thinkingSelections.has(sessionId)) {
|
|
1161
1410
|
this.thinkingSelections.set(sessionId, "auto");
|
|
@@ -1191,11 +1440,15 @@ export class ConsolePiRuntime {
|
|
|
1191
1440
|
const todoTool = this.options.todoRegistry
|
|
1192
1441
|
? await buildTodoWriteTool({ sessionId, registry: this.options.todoRegistry })
|
|
1193
1442
|
: undefined;
|
|
1443
|
+
const goalTools = this.options.goalRegistry
|
|
1444
|
+
? await buildGoalTools({ sessionId, registry: this.options.goalRegistry })
|
|
1445
|
+
: [];
|
|
1194
1446
|
return [
|
|
1195
1447
|
...operatorCustomTools,
|
|
1196
1448
|
...exploreCustomTools,
|
|
1197
1449
|
...(questionTool ? [questionTool] : []),
|
|
1198
1450
|
...(todoTool ? [todoTool] : []),
|
|
1451
|
+
...goalTools,
|
|
1199
1452
|
];
|
|
1200
1453
|
}
|
|
1201
1454
|
loadOperatorCustomTools() {
|
|
@@ -1470,6 +1723,9 @@ export class ConsolePiRuntime {
|
|
|
1470
1723
|
// the latest resource-preferences.json written by PATCH.
|
|
1471
1724
|
await this.refreshSkillPrefsCache().catch(() => undefined);
|
|
1472
1725
|
await session.reload();
|
|
1726
|
+
const services = this.serviceScopes.get(sessionId);
|
|
1727
|
+
if (services)
|
|
1728
|
+
this.applySessionCompactionOverride(sessionId, services, session);
|
|
1473
1729
|
}
|
|
1474
1730
|
catch (error) {
|
|
1475
1731
|
return {
|
|
@@ -1482,6 +1738,9 @@ export class ConsolePiRuntime {
|
|
|
1482
1738
|
this.reloadInFlight.delete(sessionId);
|
|
1483
1739
|
}
|
|
1484
1740
|
this.bumpRevision(sessionId);
|
|
1741
|
+
// Reload replaces the effective resource/session context. Persisted Goal
|
|
1742
|
+
// remains visible, but continuation authorization must be renewed by user.
|
|
1743
|
+
this.options.goalRegistry?.disarm(sessionId);
|
|
1485
1744
|
return { ok: true, revision: this.revisions.get(sessionId) ?? 0 };
|
|
1486
1745
|
}
|
|
1487
1746
|
/**
|
|
@@ -1927,6 +2186,9 @@ export class ConsolePiRuntime {
|
|
|
1927
2186
|
if (this.sessions.has(sessionId)) {
|
|
1928
2187
|
return { ok: true, handle: await this.snapshotActive(sessionId) };
|
|
1929
2188
|
}
|
|
2189
|
+
// A newly materialized Pi session must never inherit process-local Goal
|
|
2190
|
+
// activation from an earlier disposed runtime instance.
|
|
2191
|
+
this.options.goalRegistry?.disarm(sessionId);
|
|
1930
2192
|
if (!init.sessionFile) {
|
|
1931
2193
|
return {
|
|
1932
2194
|
ok: false,
|
|
@@ -2076,6 +2338,7 @@ export class ConsolePiRuntime {
|
|
|
2076
2338
|
});
|
|
2077
2339
|
await bindSessionExtensions(session);
|
|
2078
2340
|
pinOperatorChatTools(session);
|
|
2341
|
+
this.applySessionCompactionOverride(init.sessionId, services, session);
|
|
2079
2342
|
return {
|
|
2080
2343
|
session,
|
|
2081
2344
|
modelRuntime,
|
|
@@ -2117,6 +2380,12 @@ export class ConsolePiRuntime {
|
|
|
2117
2380
|
session?.hasPendingBashMessages === true,
|
|
2118
2381
|
};
|
|
2119
2382
|
}
|
|
2383
|
+
/** Low-cardinality context-pressure counters; never includes payload text. */
|
|
2384
|
+
getContextPressureTelemetry(sessionId) {
|
|
2385
|
+
const counters = this.compactionControllers.get(sessionId)?.counters ??
|
|
2386
|
+
this.siftStates.get(sessionId)?.counters;
|
|
2387
|
+
return projectSafeTelemetry(counters ?? emptyContextPressureCounters());
|
|
2388
|
+
}
|
|
2120
2389
|
/**
|
|
2121
2390
|
* Send a prompt and stream events. Gate 3: each tool invocation is
|
|
2122
2391
|
* re-authorized by authorizeOperatorChatTool before the dispatcher runs it
|
|
@@ -2159,6 +2428,9 @@ export class ConsolePiRuntime {
|
|
|
2159
2428
|
if (options?.turnId && this.options.todoRegistry) {
|
|
2160
2429
|
this.options.todoRegistry.startTurn(sessionId, options.turnId);
|
|
2161
2430
|
}
|
|
2431
|
+
if (this.options.goalRegistry) {
|
|
2432
|
+
this.options.goalRegistry.startTurn(sessionId, text);
|
|
2433
|
+
}
|
|
2162
2434
|
// AC-FIX-002: if Stop already fired before readiness, never start model work.
|
|
2163
2435
|
if (options?.signal?.aborted) {
|
|
2164
2436
|
return;
|
|
@@ -2212,6 +2484,7 @@ export class ConsolePiRuntime {
|
|
|
2212
2484
|
}
|
|
2213
2485
|
};
|
|
2214
2486
|
emitContextUsage();
|
|
2487
|
+
await this.runPrePromptCompaction(sessionId, session, text, options?.images?.length ?? 0, onEvent);
|
|
2215
2488
|
let stepStartedAt;
|
|
2216
2489
|
let firstTokenAt;
|
|
2217
2490
|
let pendingProviderError;
|
|
@@ -2461,13 +2734,17 @@ export class ConsolePiRuntime {
|
|
|
2461
2734
|
throw new Error("CHAT_TURN_ACTIVE");
|
|
2462
2735
|
if (typeof session.compact !== "function")
|
|
2463
2736
|
throw new Error("COMPACTION_UNSUPPORTED");
|
|
2737
|
+
incrementPressureCounter(this.getCompactionController(sessionId).counters, "manual");
|
|
2464
2738
|
const result = await session.compact(customInstructions);
|
|
2465
2739
|
return projectCompactSnapshot(result ?? {});
|
|
2466
2740
|
}
|
|
2467
2741
|
dispose(sessionId) {
|
|
2468
2742
|
// Mark disposed first so a late materialization never registers ready.
|
|
2469
2743
|
this.disposedSessions.add(sessionId);
|
|
2744
|
+
this.options.goalRegistry?.disarm(sessionId);
|
|
2470
2745
|
this.sessionInit.delete(sessionId);
|
|
2746
|
+
this.compactionControllers.delete(sessionId);
|
|
2747
|
+
this.siftStates.delete(sessionId);
|
|
2471
2748
|
const session = this.sessions.get(sessionId);
|
|
2472
2749
|
if (session) {
|
|
2473
2750
|
try {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isContextOverflow } from "../../../shared/pi-context-pressure/overflow.js";
|
|
1
2
|
const MAX_RAW_CHARS = 8_000;
|
|
2
3
|
const MAX_MESSAGE_CHARS = 1_000;
|
|
3
4
|
function asRecord(value) {
|
|
@@ -52,7 +53,7 @@ function classify(input) {
|
|
|
52
53
|
status === 403 ||
|
|
53
54
|
/认证|鉴权|unauthori[sz]ed|forbidden|authentication|invalid api[-_ ]?key/.test(haystack))
|
|
54
55
|
return "authentication";
|
|
55
|
-
if (
|
|
56
|
+
if (isContextOverflow(`${input.message}\n${input.raw}`))
|
|
56
57
|
return "context-overflow";
|
|
57
58
|
if (status === 429 ||
|
|
58
59
|
/rate.?limit|too many requests|请求.{0,8}(?:频繁|过多)|限流/.test(haystack))
|
|
@@ -4,8 +4,12 @@ import { lstat, open, readdir } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { guardReadPath, isSensitivePath, scrubSecrets, } from "./explore-tools.js";
|
|
6
6
|
const TREE_LIMIT = 500;
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/** DSH-style bounded source read: large text files return this leading slice
|
|
8
|
+
* instead of being rejected solely because the complete file is larger. */
|
|
9
|
+
export const TEXT_PREVIEW_LIMIT = 512 * 1024;
|
|
10
|
+
/** Full-file ceiling for the sandboxed HTML preview route and its assets. */
|
|
11
|
+
export const HTML_PREVIEW_ASSET_LIMIT = 20 * 1024 * 1024;
|
|
12
|
+
export const REPO_HTML_PREVIEW_ROUTE_PREFIX = "/api/operator/v1/chat/repo/html/";
|
|
9
13
|
const SKIP_DIRS = new Set([
|
|
10
14
|
".git",
|
|
11
15
|
".harness",
|
|
@@ -32,6 +36,45 @@ export class RepoBrowserError extends Error {
|
|
|
32
36
|
function guardedCode(code) {
|
|
33
37
|
return code === "PATH_OUTSIDE_REPO" ? "OUT_OF_REPO" : code;
|
|
34
38
|
}
|
|
39
|
+
/** Match the path-encoded HTML preview route. Keeping workspace + file path in
|
|
40
|
+
* the URL path lets browser-relative CSS/image references retain their scope. */
|
|
41
|
+
export function matchRepoHtmlPreviewRoute(pathname) {
|
|
42
|
+
if (!pathname.startsWith(REPO_HTML_PREVIEW_ROUTE_PREFIX))
|
|
43
|
+
return null;
|
|
44
|
+
const rest = pathname.slice(REPO_HTML_PREVIEW_ROUTE_PREFIX.length);
|
|
45
|
+
const [workspaceId = "", ...pathSegments] = rest.split("/");
|
|
46
|
+
return { workspaceId, rawPath: pathSegments.join("/") };
|
|
47
|
+
}
|
|
48
|
+
export function repoHtmlAssetContentType(rawPath) {
|
|
49
|
+
const extension = path.extname(rawPath).toLowerCase();
|
|
50
|
+
const types = {
|
|
51
|
+
".html": "text/html; charset=utf-8",
|
|
52
|
+
".htm": "text/html; charset=utf-8",
|
|
53
|
+
".css": "text/css; charset=utf-8",
|
|
54
|
+
".js": "text/javascript; charset=utf-8",
|
|
55
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
56
|
+
".json": "application/json; charset=utf-8",
|
|
57
|
+
".txt": "text/plain; charset=utf-8",
|
|
58
|
+
".svg": "image/svg+xml",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".jpg": "image/jpeg",
|
|
61
|
+
".jpeg": "image/jpeg",
|
|
62
|
+
".gif": "image/gif",
|
|
63
|
+
".webp": "image/webp",
|
|
64
|
+
".avif": "image/avif",
|
|
65
|
+
".bmp": "image/bmp",
|
|
66
|
+
".ico": "image/x-icon",
|
|
67
|
+
".woff": "font/woff",
|
|
68
|
+
".woff2": "font/woff2",
|
|
69
|
+
".ttf": "font/ttf",
|
|
70
|
+
".otf": "font/otf",
|
|
71
|
+
".mp3": "audio/mpeg",
|
|
72
|
+
".wav": "audio/wav",
|
|
73
|
+
".mp4": "video/mp4",
|
|
74
|
+
".webm": "video/webm",
|
|
75
|
+
};
|
|
76
|
+
return types[extension] ?? "application/octet-stream";
|
|
77
|
+
}
|
|
35
78
|
export async function listRepoDirectory(repoRoot, rawPath) {
|
|
36
79
|
const root = rawPath === "";
|
|
37
80
|
const guard = await guardReadPath(repoRoot, root ? "." : rawPath);
|
|
@@ -92,8 +135,6 @@ export async function readRepoPreview(repoRoot, rawPath) {
|
|
|
92
135
|
const info = await handle.stat();
|
|
93
136
|
if (!info.isFile())
|
|
94
137
|
throw new RepoBrowserError(400, "NOT_A_FILE", "path is not a regular file");
|
|
95
|
-
if (info.size > READ_LIMIT)
|
|
96
|
-
throw new RepoBrowserError(413, "TOO_LARGE", `file exceeds ${READ_LIMIT} byte preview limit`);
|
|
97
138
|
const rechecked = await guardReadPath(repoRoot, rawPath);
|
|
98
139
|
if (!rechecked.ok)
|
|
99
140
|
throw new RepoBrowserError(403, guardedCode(rechecked.code), "file path changed while it was being opened");
|
|
@@ -103,9 +144,10 @@ export async function readRepoPreview(repoRoot, rawPath) {
|
|
|
103
144
|
current.ino !== info.ino)
|
|
104
145
|
throw new RepoBrowserError(403, "OUT_OF_REPO", "file identity changed while it was being opened");
|
|
105
146
|
size = info.size;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
147
|
+
const readSize = Math.min(size, TEXT_PREVIEW_LIMIT);
|
|
148
|
+
bytes = Buffer.alloc(readSize);
|
|
149
|
+
const bytesRead = readSize
|
|
150
|
+
? (await handle.read(bytes, 0, readSize, 0)).bytesRead
|
|
109
151
|
: 0;
|
|
110
152
|
bytes = bytes.subarray(0, bytesRead);
|
|
111
153
|
}
|
|
@@ -114,17 +156,14 @@ export async function readRepoPreview(repoRoot, rawPath) {
|
|
|
114
156
|
}
|
|
115
157
|
if (bytes.includes(0))
|
|
116
158
|
throw new RepoBrowserError(415, "BINARY_UNSUPPORTED", "binary files cannot be previewed");
|
|
117
|
-
|
|
118
|
-
while (end > 0 && end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
119
|
-
end -= 1;
|
|
120
|
-
const content = scrubSecrets(bytes.subarray(0, end).toString("utf8")).scrubbed;
|
|
159
|
+
const content = scrubSecrets(bytes.toString("utf8")).scrubbed;
|
|
121
160
|
return {
|
|
122
161
|
path: guard.repoRelative,
|
|
123
162
|
kind: "text",
|
|
124
163
|
content,
|
|
125
164
|
language: path.extname(guard.repoRelative).slice(1).toLowerCase() || "text",
|
|
126
165
|
bytes: size,
|
|
127
|
-
truncated: bytes.length
|
|
166
|
+
truncated: size > bytes.length,
|
|
128
167
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
129
168
|
};
|
|
130
169
|
}
|
|
@@ -138,3 +177,46 @@ export async function readRepoPreview(repoRoot, rawPath) {
|
|
|
138
177
|
throw error;
|
|
139
178
|
}
|
|
140
179
|
}
|
|
180
|
+
/** Read one complete file for the sandboxed HTML iframe. Unlike source reads,
|
|
181
|
+
* this route needs complete HTML/CSS/image bytes so documents and relative
|
|
182
|
+
* assets render correctly; the separate 20 MiB cap keeps memory bounded. */
|
|
183
|
+
export async function readRepoHtmlAsset(repoRoot, rawPath) {
|
|
184
|
+
const guard = await guardReadPath(repoRoot, rawPath);
|
|
185
|
+
if (!guard.ok)
|
|
186
|
+
throw new RepoBrowserError(403, guardedCode(guard.code), guard.message);
|
|
187
|
+
try {
|
|
188
|
+
const handle = await open(guard.abs, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
189
|
+
try {
|
|
190
|
+
const info = await handle.stat();
|
|
191
|
+
if (!info.isFile())
|
|
192
|
+
throw new RepoBrowserError(400, "NOT_A_FILE", "path is not a regular file");
|
|
193
|
+
if (info.size > HTML_PREVIEW_ASSET_LIMIT)
|
|
194
|
+
throw new RepoBrowserError(413, "TOO_LARGE", `file exceeds ${HTML_PREVIEW_ASSET_LIMIT} byte HTML preview limit`);
|
|
195
|
+
const rechecked = await guardReadPath(repoRoot, rawPath);
|
|
196
|
+
if (!rechecked.ok)
|
|
197
|
+
throw new RepoBrowserError(403, guardedCode(rechecked.code), "file path changed while it was being opened");
|
|
198
|
+
const current = await lstat(rechecked.abs);
|
|
199
|
+
if (current.isSymbolicLink() ||
|
|
200
|
+
current.dev !== info.dev ||
|
|
201
|
+
current.ino !== info.ino)
|
|
202
|
+
throw new RepoBrowserError(403, "OUT_OF_REPO", "file identity changed while it was being opened");
|
|
203
|
+
return {
|
|
204
|
+
body: await handle.readFile(),
|
|
205
|
+
contentType: repoHtmlAssetContentType(guard.repoRelative),
|
|
206
|
+
bytes: info.size,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await handle.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error instanceof RepoBrowserError)
|
|
215
|
+
throw error;
|
|
216
|
+
if (error.code === "ENOENT")
|
|
217
|
+
throw new RepoBrowserError(404, "NOT_FOUND", "file does not exist");
|
|
218
|
+
if (error.code === "ELOOP")
|
|
219
|
+
throw new RepoBrowserError(403, "OUT_OF_REPO", "symbolic links are not previewable");
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|