ultimate-pi 0.19.0 → 0.20.0
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/.agents/skills/web-retrieval/SKILL.md +163 -0
- package/.agents/skills/wiki-autoresearch/SKILL.md +6 -6
- package/.pi/SYSTEM.md +30 -12
- package/.pi/agents/harness/planning/implementation-researcher.md +1 -1
- package/.pi/agents/harness/planning/stack-researcher.md +5 -1
- package/.pi/agents/harness/running/executor.md +42 -1
- package/.pi/agents/harness/web-retrieval/web-answerer.md +35 -0
- package/.pi/agents/harness/web-retrieval/web-criteria-verifier.md +28 -0
- package/.pi/agents/harness/web-retrieval/web-gap-analyzer.md +31 -0
- package/.pi/agents/harness/web-retrieval/web-query-expander-fast.md +34 -0
- package/.pi/agents/harness/web-retrieval/web-query-expander.md +60 -0
- package/.pi/agents/harness/web-retrieval/web-summarizer.md +18 -0
- package/.pi/extensions/harness-anchored-edit.ts +141 -0
- package/.pi/extensions/harness-web-guard.ts +2 -1
- package/.pi/extensions/harness-web-tools.ts +689 -51
- package/.pi/harness/agents.manifest.json +30 -6
- package/.pi/harness/agents.policy.yaml +37 -4
- package/.pi/harness/docs/adrs/0050-agentic-web-retrieval-stack.md +46 -0
- package/.pi/harness/docs/adrs/0051-hash-anchored-executor-edits.md +41 -0
- package/.pi/harness/docs/adrs/README.md +2 -0
- package/.pi/harness/docs/harness-web-search.md +97 -0
- package/.pi/harness/docs/practice-map.md +11 -0
- package/.pi/harness/env.harness.template +9 -1
- package/.pi/harness/examples/web-heuristic-angles.project.yaml +22 -0
- package/.pi/harness/web-heuristic-angles.json +278 -0
- package/.pi/harness/web-heuristic-angles.yaml +182 -0
- package/.pi/lib/agents-policy.d.mts +4 -0
- package/.pi/lib/agents-policy.mjs +49 -1
- package/.pi/lib/agents-policy.ts +1 -0
- package/.pi/lib/harness-anchored-edit/.hash_anchors +1721 -0
- package/.pi/lib/harness-anchored-edit/anchor-state.ts +320 -0
- package/.pi/lib/harness-anchored-edit/apply-anchored-edits.ts +161 -0
- package/.pi/lib/harness-anchored-edit/edit-executor.ts +146 -0
- package/.pi/lib/harness-anchored-edit/index.ts +9 -0
- package/.pi/lib/harness-anchored-edit/line-protocol.ts +38 -0
- package/.pi/lib/harness-anchored-edit/settings.ts +1 -0
- package/.pi/lib/harness-anchored-edit/task-id.ts +8 -0
- package/.pi/lib/harness-anchored-edit/types.ts +19 -0
- package/.pi/lib/harness-lens/clients/anchored-edit-autopatch.ts +158 -0
- package/.pi/lib/harness-lens/index.ts +24 -7
- package/.pi/lib/harness-subagent-auth.ts +39 -9
- package/.pi/lib/harness-subagents-bridge.ts +24 -1
- package/.pi/lib/harness-web/artifacts.ts +200 -0
- package/.pi/lib/harness-web/cache.ts +369 -0
- package/.pi/lib/harness-web/run-cli.ts +42 -2
- package/.pi/prompts/harness-plan.md +1 -0
- package/.pi/prompts/harness-setup.md +3 -1
- package/.pi/prompts/harness-steer.md +1 -1
- package/.pi/scripts/gen-web-heuristic-angles-json.mjs +24 -0
- package/.pi/scripts/harness-anchored-edit-smoke.mjs +45 -0
- package/.pi/scripts/harness-cli-verify.sh +5 -0
- package/.pi/scripts/harness-verify.mjs +145 -0
- package/.pi/scripts/harness-web-policy-guard.mjs +1 -1
- package/.pi/scripts/harness-web.py +218 -15
- package/.pi/scripts/harness_web/deep_search.py +55 -0
- package/.pi/scripts/harness_web/evidence_bundle.py +47 -0
- package/.pi/scripts/harness_web/find_similar.py +88 -0
- package/.pi/scripts/harness_web/heuristic_angles_shipped.py +85 -0
- package/.pi/scripts/harness_web/heuristic_config.py +251 -0
- package/.pi/scripts/harness_web/highlights.py +47 -0
- package/.pi/scripts/harness_web/multi_search.py +59 -0
- package/.pi/scripts/harness_web/output.py +24 -0
- package/.pi/scripts/harness_web/query_angles.py +116 -0
- package/.pi/scripts/harness_web/rank.py +163 -0
- package/.pi/scripts/harness_web/scrape.py +30 -0
- package/.pi/scripts/run-tests.mjs +64 -0
- package/.pi/scripts/tests/test_harness_web_heuristic_config.py +132 -0
- package/.pi/scripts/tests/test_harness_web_query_angles.py +45 -0
- package/.pi/scripts/tests/test_harness_web_rank.py +56 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +12 -0
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +7 -4
- package/vendor/pi-subagents/src/agents.ts +5 -0
- package/vendor/pi-subagents/src/subagents.ts +22 -3
- package/.agents/skills/scrapling-web/SKILL.md +0 -98
- package/.pi/extensions/00-posthog-network-bootstrap.ts +0 -11
- package/.pi/scripts/harness_web/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/config.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/output.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/scrape.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_ddg.cpython-314.pyc +0 -0
- package/.pi/scripts/harness_web/__pycache__/search_searxng.cpython-314.pyc +0 -0
- package/.pi/scripts/release.sh +0 -338
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs";
|
|
2
|
+
import { AnchorStateManager } from "../../harness-anchored-edit/anchor-state.js";
|
|
3
|
+
import { EditExecutor } from "../../harness-anchored-edit/edit-executor.js";
|
|
4
|
+
import { splitAnchor } from "../../harness-anchored-edit/line-protocol.js";
|
|
5
|
+
import type { AnchoredEdit } from "../../harness-anchored-edit/types.js";
|
|
6
|
+
import { tryCorrectIndentationMismatchFromContent } from "./edit-autopatch.js";
|
|
7
|
+
import { retargetReplacementIndentation } from "./indent-retarget.js";
|
|
8
|
+
|
|
9
|
+
function leadingIndent(line: string): string {
|
|
10
|
+
return line.match(/^[\t ]*/)?.[0] ?? "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isIndentationOnlyChange(before: string, after: string): boolean {
|
|
14
|
+
const beforeLines = before.replace(/\r\n/g, "\n").split("\n");
|
|
15
|
+
const afterLines = after.replace(/\r\n/g, "\n").split("\n");
|
|
16
|
+
if (beforeLines.length !== afterLines.length) return false;
|
|
17
|
+
return beforeLines.every(
|
|
18
|
+
(line, index) => line.trim() === afterLines[index].trim(),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type AnchoredEditInput = {
|
|
23
|
+
edits?: AnchoredEdit[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function isAnchoredEditToolInput(
|
|
27
|
+
editInput: unknown,
|
|
28
|
+
): editInput is AnchoredEditInput {
|
|
29
|
+
if (!editInput || typeof editInput !== "object") return false;
|
|
30
|
+
const edits = (editInput as AnchoredEditInput).edits;
|
|
31
|
+
if (!Array.isArray(edits) || edits.length === 0) return false;
|
|
32
|
+
return typeof edits[0]?.anchor === "string";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Indentation-only correction for harness anchored edit.text before apply.
|
|
37
|
+
*/
|
|
38
|
+
export function applyAnchoredEditAutopatch(
|
|
39
|
+
filePath: string,
|
|
40
|
+
editInput: AnchoredEditInput,
|
|
41
|
+
taskId: string,
|
|
42
|
+
): { block: true; reason: string } | undefined {
|
|
43
|
+
const edits = editInput.edits;
|
|
44
|
+
if (!edits?.length) return undefined;
|
|
45
|
+
|
|
46
|
+
let crlfContent: string;
|
|
47
|
+
try {
|
|
48
|
+
crlfContent = nodeFs.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lines = crlfContent.split("\n");
|
|
54
|
+
const lineAnchors = AnchorStateManager.reconcile(filePath, lines, taskId);
|
|
55
|
+
const executor = new EditExecutor();
|
|
56
|
+
const { resolvedEdits, failedEdits } = executor.resolveEdits(
|
|
57
|
+
edits,
|
|
58
|
+
lines,
|
|
59
|
+
lineAnchors,
|
|
60
|
+
);
|
|
61
|
+
if (failedEdits.length > 0) return undefined;
|
|
62
|
+
|
|
63
|
+
const corrected: Array<{
|
|
64
|
+
label: string;
|
|
65
|
+
original: string;
|
|
66
|
+
corrected: string;
|
|
67
|
+
indentationOnly: boolean;
|
|
68
|
+
apply: (value: string) => void;
|
|
69
|
+
}> = [];
|
|
70
|
+
|
|
71
|
+
for (const { lineIdx, endIdx, edit } of resolvedEdits) {
|
|
72
|
+
const editType = edit.edit_type ?? "replace";
|
|
73
|
+
const text = edit.text ?? "";
|
|
74
|
+
if (!text.trim()) continue;
|
|
75
|
+
|
|
76
|
+
let referenceBlock: string;
|
|
77
|
+
if (editType === "replace") {
|
|
78
|
+
referenceBlock = lines.slice(lineIdx, endIdx + 1).join("\n");
|
|
79
|
+
} else {
|
|
80
|
+
referenceBlock = lines[lineIdx] ?? "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const correctedText = tryCorrectIndentationMismatchFromContent(
|
|
84
|
+
text,
|
|
85
|
+
crlfContent,
|
|
86
|
+
);
|
|
87
|
+
if (correctedText === undefined) {
|
|
88
|
+
const refIndent = leadingIndent(referenceBlock.split("\n")[0] ?? "");
|
|
89
|
+
const textIndent = leadingIndent(text.split("\n")[0] ?? "");
|
|
90
|
+
if (
|
|
91
|
+
refIndent !== textIndent &&
|
|
92
|
+
isIndentationOnlyChange(
|
|
93
|
+
textIndent + text.trimStart(),
|
|
94
|
+
refIndent + text.trimStart(),
|
|
95
|
+
)
|
|
96
|
+
) {
|
|
97
|
+
const retargeted = retargetReplacementIndentation(
|
|
98
|
+
text,
|
|
99
|
+
textIndent + text.trimStart(),
|
|
100
|
+
refIndent + text.trimStart(),
|
|
101
|
+
);
|
|
102
|
+
if (retargeted !== undefined) {
|
|
103
|
+
corrected.push({
|
|
104
|
+
label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
|
|
105
|
+
original: text,
|
|
106
|
+
corrected: retargeted,
|
|
107
|
+
indentationOnly: true,
|
|
108
|
+
apply: (value) => {
|
|
109
|
+
edit.text = value;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (correctedText !== text) {
|
|
118
|
+
const retargeted = retargetReplacementIndentation(
|
|
119
|
+
text,
|
|
120
|
+
text,
|
|
121
|
+
correctedText,
|
|
122
|
+
);
|
|
123
|
+
corrected.push({
|
|
124
|
+
label: `edits anchor ${splitAnchor(edit.anchor).anchor}`,
|
|
125
|
+
original: text,
|
|
126
|
+
corrected: retargeted ?? correctedText,
|
|
127
|
+
indentationOnly: isIndentationOnlyChange(text, correctedText),
|
|
128
|
+
apply: (value) => {
|
|
129
|
+
edit.text = value;
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (corrected.length === 0) return undefined;
|
|
136
|
+
|
|
137
|
+
const unsafe = corrected.filter((entry) => !entry.indentationOnly);
|
|
138
|
+
if (unsafe.length > 0) {
|
|
139
|
+
const details = unsafe
|
|
140
|
+
.map(({ label, original }) => {
|
|
141
|
+
const preview = original.trimStart().slice(0, 60).replace(/\n/g, "↵");
|
|
142
|
+
return `${label} ("${preview}…") cannot be auto-patched (not indentation-only).`;
|
|
143
|
+
})
|
|
144
|
+
.join("\n");
|
|
145
|
+
return {
|
|
146
|
+
block: true,
|
|
147
|
+
reason:
|
|
148
|
+
`🔄 RETRYABLE — Indentation mismatch on anchored edit text\n\n` +
|
|
149
|
+
`${details}\n\n` +
|
|
150
|
+
`Next action: re-read the relevant section, then retry with text matching file indentation.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of corrected) {
|
|
155
|
+
entry.apply(entry.corrected);
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
@@ -7,6 +7,11 @@ import * as nodeFs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { anchoredEditTaskId } from "../harness-anchored-edit/task-id.js";
|
|
11
|
+
import {
|
|
12
|
+
applyAnchoredEditAutopatch,
|
|
13
|
+
isAnchoredEditToolInput,
|
|
14
|
+
} from "./clients/anchored-edit-autopatch.js";
|
|
10
15
|
import {
|
|
11
16
|
tryCorrectIndentationMismatch,
|
|
12
17
|
tryCorrectIndentationMismatchFromContent,
|
|
@@ -496,13 +501,25 @@ export default function harnessLensExtension(pi: ExtensionAPI): void {
|
|
|
496
501
|
event as Parameters<typeof isToolCallEventType>[1],
|
|
497
502
|
)
|
|
498
503
|
) {
|
|
499
|
-
const editInput = (event as { input?: unknown }).input
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
504
|
+
const editInput = (event as { input?: unknown }).input;
|
|
505
|
+
if (isAnchoredEditToolInput(editInput)) {
|
|
506
|
+
const block = applyAnchoredEditAutopatch(
|
|
507
|
+
filePath,
|
|
508
|
+
editInput,
|
|
509
|
+
anchoredEditTaskId({
|
|
510
|
+
sessionId: (ctx as { sessionId?: string }).sessionId,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
if (block) return block;
|
|
514
|
+
} else {
|
|
515
|
+
const legacyInput = editInput as {
|
|
516
|
+
oldText?: string;
|
|
517
|
+
newText?: string;
|
|
518
|
+
edits?: Array<{ oldText?: string; newText?: string }>;
|
|
519
|
+
};
|
|
520
|
+
const block = applyEditAutopatch(filePath, legacyInput);
|
|
521
|
+
if (block) return block;
|
|
522
|
+
}
|
|
506
523
|
}
|
|
507
524
|
});
|
|
508
525
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Resolve concrete LLM credentials for harness subagent subprocesses.
|
|
3
3
|
*
|
|
4
4
|
* Harness subprocesses run with `--no-extensions`, so auth forwarding only uses
|
|
5
|
-
* concrete provider/model references from
|
|
5
|
+
* concrete provider/model references from env, agent config, or parent session.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { AgentConfig } from "../../vendor/pi-subagents/src/agents.js";
|
|
@@ -30,22 +30,52 @@ export interface ConcreteSubagentModel {
|
|
|
30
30
|
modelId: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function toConcrete(ref: string): ConcreteSubagentModel | undefined {
|
|
34
|
+
const parsed = parseModelRef(ref);
|
|
35
|
+
if (!parsed) return undefined;
|
|
36
|
+
return { modelRef: ref, ...parsed };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const WEB_FAST_AGENT_IDS = new Set([
|
|
40
|
+
"harness/web-retrieval/web-query-expander-fast",
|
|
41
|
+
"harness/web-retrieval/web-summarizer",
|
|
42
|
+
"harness/web-retrieval/web-gap-analyzer",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const WEB_QUALITY_AGENT_IDS = new Set([
|
|
46
|
+
"harness/web-retrieval/web-answerer",
|
|
47
|
+
"harness/web-retrieval/web-criteria-verifier",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function envModelRef(varName: string): string | undefined {
|
|
51
|
+
const v = process.env[varName]?.trim();
|
|
52
|
+
return v && parseModelRef(v) ? v : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function modelFromEnv(agentName: string): ConcreteSubagentModel | undefined {
|
|
56
|
+
const fast = envModelRef("HARNESS_WEB_FAST_MODEL");
|
|
57
|
+
if (fast && WEB_FAST_AGENT_IDS.has(agentName)) return toConcrete(fast);
|
|
58
|
+
const expander = envModelRef("HARNESS_WEB_EXPANDER_MODEL");
|
|
59
|
+
if (expander && agentName === "harness/web-retrieval/web-query-expander") return toConcrete(expander);
|
|
60
|
+
const quality = envModelRef("HARNESS_WEB_QUALITY_MODEL");
|
|
61
|
+
if (quality && WEB_QUALITY_AGENT_IDS.has(agentName)) return toConcrete(quality);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
export function resolveConcreteSubagentModel(
|
|
34
66
|
_parentCwd: string,
|
|
35
67
|
parentModel: { provider: string; id: string } | undefined,
|
|
36
68
|
agent: AgentConfig,
|
|
37
69
|
_taskSnippet?: string,
|
|
38
70
|
): ConcreteSubagentModel | undefined {
|
|
71
|
+
const envOverride = modelFromEnv(agent.name);
|
|
72
|
+
if (envOverride) return envOverride;
|
|
73
|
+
|
|
39
74
|
if (agent.model) {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
return { modelRef: agent.model, ...parsed };
|
|
43
|
-
}
|
|
75
|
+
const concrete = toConcrete(agent.model);
|
|
76
|
+
if (concrete) return concrete;
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
if (!parentModel || parentModel.provider === "router") return undefined;
|
|
47
|
-
|
|
48
|
-
const parsed = parseModelRef(modelRef);
|
|
49
|
-
if (!parsed) return undefined;
|
|
50
|
-
return { modelRef, ...parsed };
|
|
80
|
+
return toConcrete(`${parentModel.provider}/${parentModel.id}`);
|
|
51
81
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type SpawnAuthForward,
|
|
15
15
|
} from "../../vendor/pi-subagents/src/subagents.js";
|
|
16
16
|
import { subagentGovernanceExtensionPath } from "../extensions/subagent-governance.js";
|
|
17
|
-
import { getAgentKind } from "./agents-policy.mjs";
|
|
17
|
+
import { getAgentKind, resolveExtensionBundlePaths } from "./agents-policy.mjs";
|
|
18
18
|
import {
|
|
19
19
|
delegationEnvFromBundle,
|
|
20
20
|
mintSubagentDelegation,
|
|
@@ -43,6 +43,10 @@ import {
|
|
|
43
43
|
inferPhaseForPrecheck,
|
|
44
44
|
precheckHarnessSubagentSpawn,
|
|
45
45
|
} from "./harness-subagent-precheck.js";
|
|
46
|
+
import {
|
|
47
|
+
getRememberedSessionWebArtifactDir,
|
|
48
|
+
resolveWebArtifactScope,
|
|
49
|
+
} from "./harness-web/artifacts.js";
|
|
46
50
|
|
|
47
51
|
const spawnBudget = createSpawnBudgetState();
|
|
48
52
|
let lastSessionId = "harness";
|
|
@@ -122,6 +126,8 @@ export function createHarnessSubagentsExtension(
|
|
|
122
126
|
packageRoot,
|
|
123
127
|
subprocessGovernanceExtensionPath: governanceExtPath,
|
|
124
128
|
harnessSubprocessExtensionPath: governanceExtPath,
|
|
129
|
+
resolveExtensionBundlePaths: (bundleName) =>
|
|
130
|
+
resolveExtensionBundlePaths(packageRoot, bundleName),
|
|
125
131
|
resolveSubprocessEnv: (task, agent) => {
|
|
126
132
|
const projectRoot = process.cwd();
|
|
127
133
|
const base: Record<string, string> = {
|
|
@@ -130,6 +136,23 @@ export function createHarnessSubagentsExtension(
|
|
|
130
136
|
HARNESS_PKG_ROOT: packageRoot,
|
|
131
137
|
HARNESS_PROJECT_ROOT: projectRoot,
|
|
132
138
|
};
|
|
139
|
+
if (agent.name.startsWith("harness/web-retrieval/")) {
|
|
140
|
+
const ctx = parseSpawnContextFromTask(task);
|
|
141
|
+
const remembered = getRememberedSessionWebArtifactDir(lastSessionId);
|
|
142
|
+
if (remembered) {
|
|
143
|
+
base.HARNESS_WEB_ARTIFACT_DIR = remembered;
|
|
144
|
+
} else if (ctx?.run_id) {
|
|
145
|
+
base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
|
|
146
|
+
projectRoot,
|
|
147
|
+
explicitArtifactDir: `.web/runs/${ctx.run_id}`,
|
|
148
|
+
}).artifactDir;
|
|
149
|
+
} else {
|
|
150
|
+
base.HARNESS_WEB_ARTIFACT_DIR = resolveWebArtifactScope({
|
|
151
|
+
projectRoot,
|
|
152
|
+
piSessionId: lastSessionId,
|
|
153
|
+
}).artifactDir;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
133
156
|
const ctx = parseSpawnContextFromTask(task);
|
|
134
157
|
if (!ctx?.run_id) return base;
|
|
135
158
|
if (spawnCircuitOpen(ctx.run_id)) {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WRS workspace paths — flat `.web/` aliases + optional per-run/session isolation.
|
|
3
|
+
* Search/fetch payloads are pooled under `.web/cache/` (see cache.ts).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { activeRunPointerPath } from "../harness-run-context.js";
|
|
9
|
+
import { WEB_ROOT, webCacheHint } from "./cache.js";
|
|
10
|
+
|
|
11
|
+
export type WebArtifactScopeSource =
|
|
12
|
+
| "explicit"
|
|
13
|
+
| "run"
|
|
14
|
+
| "session"
|
|
15
|
+
| "workspace";
|
|
16
|
+
|
|
17
|
+
export interface WebArtifactScope {
|
|
18
|
+
/** Relative path under repo root, e.g. `.web` or `.web/runs/abc` */
|
|
19
|
+
artifactDir: string;
|
|
20
|
+
scopeId: string;
|
|
21
|
+
source: WebArtifactScopeSource;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function webIsolateEnabled(): boolean {
|
|
25
|
+
return (
|
|
26
|
+
process.env.HARNESS_WEB_ISOLATE === "1" ||
|
|
27
|
+
process.env.HARNESS_WEB_LEGACY_SCOPE === "1"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parent session → last resolved artifact dir (for web-retrieval subagent env). */
|
|
32
|
+
const sessionArtifactDirs = new Map<string, string>();
|
|
33
|
+
|
|
34
|
+
const CANONICAL_BASENAMES = new Set([
|
|
35
|
+
"angles.yaml",
|
|
36
|
+
"angles-inline.yaml",
|
|
37
|
+
"search-deep.json",
|
|
38
|
+
"search.json",
|
|
39
|
+
"evidence-bundle.json",
|
|
40
|
+
"answer.md",
|
|
41
|
+
"highlights.json",
|
|
42
|
+
"page.md",
|
|
43
|
+
"map.json",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export function sanitizeWebScopeId(id: string): string {
|
|
47
|
+
return id.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isScopedWebArtifactPath(path: string): boolean {
|
|
51
|
+
const n = path.replace(/\\/g, "/");
|
|
52
|
+
if (!n.startsWith(`${WEB_ROOT}/`)) return false;
|
|
53
|
+
const rest = n.slice(`${WEB_ROOT}/`.length);
|
|
54
|
+
const top = rest.split("/")[0];
|
|
55
|
+
return top === "runs" || top === "sessions";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readActiveHarnessRunId(projectRoot: string): string | null {
|
|
59
|
+
const pointerPath = activeRunPointerPath(projectRoot);
|
|
60
|
+
if (!existsSync(pointerPath)) return null;
|
|
61
|
+
try {
|
|
62
|
+
const raw = readFileSync(pointerPath, "utf-8");
|
|
63
|
+
const data = JSON.parse(raw) as { run_id?: string };
|
|
64
|
+
const runId = data.run_id?.trim();
|
|
65
|
+
return runId || null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveWebArtifactScope(options: {
|
|
72
|
+
projectRoot: string;
|
|
73
|
+
piSessionId?: string;
|
|
74
|
+
explicitScope?: string;
|
|
75
|
+
explicitArtifactDir?: string;
|
|
76
|
+
}): WebArtifactScope {
|
|
77
|
+
const explicitDir =
|
|
78
|
+
options.explicitArtifactDir?.trim() ||
|
|
79
|
+
options.explicitScope?.trim() ||
|
|
80
|
+
process.env.HARNESS_WEB_ARTIFACT_DIR?.trim() ||
|
|
81
|
+
process.env.HARNESS_WEB_SCOPE?.trim();
|
|
82
|
+
if (explicitDir) {
|
|
83
|
+
const normalized = normalizeArtifactDir(explicitDir);
|
|
84
|
+
return {
|
|
85
|
+
artifactDir: normalized,
|
|
86
|
+
scopeId: normalized.split("/").pop() ?? normalized,
|
|
87
|
+
source: "explicit",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (webIsolateEnabled()) {
|
|
92
|
+
const runId =
|
|
93
|
+
process.env.HARNESS_RUN_ID?.trim() ||
|
|
94
|
+
readActiveHarnessRunId(options.projectRoot);
|
|
95
|
+
if (runId) {
|
|
96
|
+
const id = sanitizeWebScopeId(runId);
|
|
97
|
+
return {
|
|
98
|
+
artifactDir: `${WEB_ROOT}/runs/${id}`,
|
|
99
|
+
scopeId: id,
|
|
100
|
+
source: "run",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sessionId = options.piSessionId?.trim();
|
|
105
|
+
if (sessionId) {
|
|
106
|
+
const id = sanitizeWebScopeId(sessionId);
|
|
107
|
+
return {
|
|
108
|
+
artifactDir: `${WEB_ROOT}/sessions/${id}`,
|
|
109
|
+
scopeId: id,
|
|
110
|
+
source: "session",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
artifactDir: WEB_ROOT,
|
|
117
|
+
scopeId: "workspace",
|
|
118
|
+
source: "workspace",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeArtifactDir(dir: string): string {
|
|
123
|
+
let n = dir.replace(/\\/g, "/").trim();
|
|
124
|
+
if (n.startsWith("./")) n = n.slice(2);
|
|
125
|
+
if (n === WEB_ROOT || n === `${WEB_ROOT}/`) return WEB_ROOT;
|
|
126
|
+
if (!n.startsWith(`${WEB_ROOT}/`)) {
|
|
127
|
+
n = `${WEB_ROOT}/${n.replace(/^\/+/, "")}`;
|
|
128
|
+
}
|
|
129
|
+
return n.replace(/\/+$/, "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function scopedWebArtifactPath(
|
|
133
|
+
artifactDir: string,
|
|
134
|
+
basename: string,
|
|
135
|
+
): string {
|
|
136
|
+
const base = normalizeArtifactDir(artifactDir);
|
|
137
|
+
if (base === WEB_ROOT) return `${WEB_ROOT}/${basename}`;
|
|
138
|
+
return `${base}/${basename}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve output path: honor explicit paths; optional isolation rewrites flat canonical names.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveWebOutputPath(options: {
|
|
145
|
+
projectRoot: string;
|
|
146
|
+
piSessionId?: string;
|
|
147
|
+
basename: string;
|
|
148
|
+
explicitOutput?: string;
|
|
149
|
+
webScope?: string;
|
|
150
|
+
}): { path: string; artifactDir: string; scope: WebArtifactScope } {
|
|
151
|
+
const scope = resolveWebArtifactScope({
|
|
152
|
+
projectRoot: options.projectRoot,
|
|
153
|
+
piSessionId: options.piSessionId,
|
|
154
|
+
explicitScope: options.webScope,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const explicit = options.explicitOutput?.trim();
|
|
158
|
+
if (explicit) {
|
|
159
|
+
const norm = explicit.replace(/\\/g, "/");
|
|
160
|
+
if (isScopedWebArtifactPath(norm)) {
|
|
161
|
+
const artifactDir = norm.slice(0, norm.lastIndexOf("/"));
|
|
162
|
+
return { path: norm, artifactDir, scope };
|
|
163
|
+
}
|
|
164
|
+
const base = norm.split("/").pop() ?? norm;
|
|
165
|
+
if (
|
|
166
|
+
webIsolateEnabled() &&
|
|
167
|
+
scope.source !== "workspace" &&
|
|
168
|
+
norm.startsWith(`${WEB_ROOT}/`) &&
|
|
169
|
+
CANONICAL_BASENAMES.has(base)
|
|
170
|
+
) {
|
|
171
|
+
const path = scopedWebArtifactPath(scope.artifactDir, base);
|
|
172
|
+
return { path, artifactDir: scope.artifactDir, scope };
|
|
173
|
+
}
|
|
174
|
+
return { path: norm, artifactDir: scope.artifactDir, scope };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const path = scopedWebArtifactPath(scope.artifactDir, options.basename);
|
|
178
|
+
return { path, artifactDir: scope.artifactDir, scope };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function rememberSessionWebArtifactDir(
|
|
182
|
+
sessionId: string,
|
|
183
|
+
artifactDir: string,
|
|
184
|
+
): void {
|
|
185
|
+
if (!sessionId?.trim() || !artifactDir?.trim()) return;
|
|
186
|
+
sessionArtifactDirs.set(sessionId.trim(), normalizeArtifactDir(artifactDir));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function getRememberedSessionWebArtifactDir(
|
|
190
|
+
sessionId: string,
|
|
191
|
+
): string | undefined {
|
|
192
|
+
return sessionArtifactDirs.get(sessionId.trim());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function webArtifactScopeHint(scope: WebArtifactScope): string {
|
|
196
|
+
const isolateNote = webIsolateEnabled()
|
|
197
|
+
? `Isolation on (${scope.artifactDir}/). Set HARNESS_WEB_ISOLATE=0 for shared workspace only.`
|
|
198
|
+
: `Shared workspace ${scope.artifactDir}/ for angles, search-deep, answer.md. Set HARNESS_WEB_ISOLATE=1 to isolate per session/run.`;
|
|
199
|
+
return `[WRS workspace] ${isolateNote} ${webCacheHint()}`;
|
|
200
|
+
}
|