pi-harness-runtime 0.10.16 → 0.10.19
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/package.json +3 -4
- package/.versionrc.js +0 -32
- package/cli.js +0 -112
- package/footer-status.js +0 -155
- package/harness/agent-handoff.js +0 -123
- package/harness/auto-compact.js +0 -243
- package/harness/auto-quota-resume.js +0 -104
- package/harness/blackboard.js +0 -258
- package/harness/context-compact-orchestrator.js +0 -365
- package/harness/context-window-manager.js +0 -330
- package/harness/continue-prompt.js +0 -164
- package/harness/e2e/minimax-quota-parser.js +0 -52
- package/harness/e2e/minimax-quota-scraper.js +0 -475
- package/harness/e2e/openai-quota-scraper.js +0 -332
- package/harness/e2e/playwright-runner.js +0 -165
- package/harness/e2e/quota-status.js +0 -140
- package/harness/e2e/test-engine.js +0 -290
- package/harness/forked-summarizer.js +0 -212
- package/harness/index.js +0 -54
- package/harness/job-state-machine.js +0 -277
- package/harness/loop-runtime.js +0 -531
- package/harness/master-planner.js +0 -229
- package/harness/notification-events.js +0 -233
- package/harness/output-limit-handler.js +0 -233
- package/harness/partial-recovery.js +0 -413
- package/harness/project-detector/detector.js +0 -283
- package/harness/repair-engine.js +0 -256
- package/harness/session-memory.js +0 -293
- package/harness/task-graph.js +0 -88
- package/index.js +0 -1121
- package/mirror.js +0 -205
- package/proactive-compact.js +0 -42
- package/renderer.js +0 -134
- package/status-parsers.js +0 -63
- package/tracker.js +0 -49
- package/windows.js +0 -90
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-runtime",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.19",
|
|
4
4
|
"description": "[BETA] Codex-style /usage status + autonomous coding harness for pi. Not production ready — expect breaking changes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -16,16 +16,15 @@
|
|
|
16
16
|
"auth:minimax:scrape": "bun packages/auth/src/run-minimax-auth.ts scrape",
|
|
17
17
|
"skills:sync": "bun scripts/skills-sync.ts",
|
|
18
18
|
"skills:sync:check": "bun scripts/skills-sync.ts --check-only",
|
|
19
|
-
"build": "for pkg in packages/*/; do [ -f \"${pkg}tsconfig.json\" ] && [ \"$pkg\" != \"packages/provider-router/\" ] && node_modules/.bin/tsc -p \"${pkg}tsconfig.json\" --skipLibCheck > /dev/null 2>&1 || true; done
|
|
19
|
+
"build": "for pkg in packages/*/; do [ -f \"${pkg}tsconfig.json\" ] && [ \"$pkg\" != \"packages/provider-router/\" ] && node_modules/.bin/tsc -p \"${pkg}tsconfig.json\" --skipLibCheck > /dev/null 2>&1 || true; done",
|
|
20
|
+
"prepublishOnly": "bun run build"
|
|
20
21
|
},
|
|
21
22
|
"bin": {
|
|
22
23
|
"harness-auth": "packages/auth/src/run-minimax-auth.ts"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
25
26
|
"*.ts",
|
|
26
|
-
"*.js",
|
|
27
27
|
"harness/**/*.ts",
|
|
28
|
-
"harness/**/*.js",
|
|
29
28
|
"packages/**/src/**/*.{ts,js}",
|
|
30
29
|
"packages/**/dist/**/*.{js,d.ts,js.map,d.ts.map}",
|
|
31
30
|
"skills/**/*",
|
package/.versionrc.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// standard-version config
|
|
2
|
-
// https://github.com/conventional-changelog/standard-version
|
|
3
|
-
//
|
|
4
|
-
// ESM version (project uses "type": "module")
|
|
5
|
-
|
|
6
|
-
export default {
|
|
7
|
-
types: [
|
|
8
|
-
{ type: "feat", section: "Features" },
|
|
9
|
-
{ type: "fix", section: "Bug Fixes" },
|
|
10
|
-
{ type: "perf", section: "Performance" },
|
|
11
|
-
{ type: "refactor", section: "Refactoring" },
|
|
12
|
-
{ type: "docs", section: "Documentation" },
|
|
13
|
-
{ type: "test", section: "Tests" },
|
|
14
|
-
{ type: "ci", section: "CI/CD" },
|
|
15
|
-
{ type: "chore", section: "Maintenance", hidden: false },
|
|
16
|
-
],
|
|
17
|
-
bumpFiles: [
|
|
18
|
-
{
|
|
19
|
-
filename: "package.json",
|
|
20
|
-
type: "json",
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
|
-
packageFiles: ["package.json"],
|
|
24
|
-
bumpInChangelog: "package.json",
|
|
25
|
-
tagPrefix: "v",
|
|
26
|
-
commitUrlFormat:
|
|
27
|
-
"https://github.com/ManotLuijiu/pi-harness-runtime/commit/{{hash}}",
|
|
28
|
-
compareUrlFormat:
|
|
29
|
-
"https://github.com/ManotLuijiu/pi-harness-runtime/compare/v{{previousTag}}...v{{currentTag}}",
|
|
30
|
-
issueUrlFormat:
|
|
31
|
-
"https://github.com/ManotLuijiu/pi-harness-runtime/issues/{{id}}",
|
|
32
|
-
};
|
package/cli.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure helper functions — testable without pi context.
|
|
3
|
-
* Imported by tracker.ts, mirror.ts, windows.ts, renderer.ts.
|
|
4
|
-
*/
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
/** Resolve the ~/.pi/usage-status/ directory. Override with PI_USAGE_DIR env var (testing). */
|
|
9
|
-
export function getUsageDir() {
|
|
10
|
-
const override = process.env.PI_USAGE_DIR;
|
|
11
|
-
if (override)
|
|
12
|
-
return override;
|
|
13
|
-
return join(homedir(), ".pi", "usage-status");
|
|
14
|
-
}
|
|
15
|
-
/** Resolve the JSONL usage log path. */
|
|
16
|
-
export function getUsageLogPath() {
|
|
17
|
-
return join(getUsageDir(), "usage.jsonl");
|
|
18
|
-
}
|
|
19
|
-
/** Resolve the mirror JSON path. */
|
|
20
|
-
export function getMirrorPath() {
|
|
21
|
-
return join(getUsageDir(), "mirror.json");
|
|
22
|
-
}
|
|
23
|
-
/** Ensure the directory exists. Idempotent. */
|
|
24
|
-
export function ensureUsageDir() {
|
|
25
|
-
const dir = getUsageDir();
|
|
26
|
-
if (!existsSync(dir)) {
|
|
27
|
-
mkdirSync(dir, { recursive: true });
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
/** Read the JSONL usage log as an array of UsageRecord. Returns [] if missing. */
|
|
31
|
-
export function readJsonl(path) {
|
|
32
|
-
if (!existsSync(path))
|
|
33
|
-
return [];
|
|
34
|
-
const text = readFileSync(path, "utf-8");
|
|
35
|
-
const out = [];
|
|
36
|
-
for (const line of text.split("\n")) {
|
|
37
|
-
const trimmed = line.trim();
|
|
38
|
-
if (!trimmed)
|
|
39
|
-
continue;
|
|
40
|
-
try {
|
|
41
|
-
out.push(JSON.parse(trimmed));
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// skip corrupted line
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
/** Append a single JSON line to a JSONL file. */
|
|
50
|
-
export function appendJsonl(path, record) {
|
|
51
|
-
ensureUsageDir();
|
|
52
|
-
const line = JSON.stringify(record) + "\n";
|
|
53
|
-
appendFileSync(path, line, "utf-8");
|
|
54
|
-
}
|
|
55
|
-
/** Read JSON file safely. Returns null if missing or corrupted. */
|
|
56
|
-
export function readJson(path) {
|
|
57
|
-
if (!existsSync(path))
|
|
58
|
-
return null;
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
/** Write JSON file (creates parent dir if needed). */
|
|
67
|
-
export function writeJson(path, data) {
|
|
68
|
-
ensureUsageDir();
|
|
69
|
-
if (!existsSync(dirname(path))) {
|
|
70
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
-
}
|
|
72
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
73
|
-
}
|
|
74
|
-
/** Format milliseconds as "Xh Ym" or "Xd Yh" or "Xm" or "Xs". */
|
|
75
|
-
export function formatDuration(ms) {
|
|
76
|
-
const abs = Math.max(0, Math.floor(ms / 1000));
|
|
77
|
-
const sec = abs % 60;
|
|
78
|
-
const min = Math.floor(abs / 60) % 60;
|
|
79
|
-
const hr = Math.floor(abs / 3600) % 24;
|
|
80
|
-
const day = Math.floor(abs / 86400);
|
|
81
|
-
if (day > 0)
|
|
82
|
-
return `${day}d ${hr}h`;
|
|
83
|
-
if (hr > 0)
|
|
84
|
-
return `${hr}h ${min}m`;
|
|
85
|
-
if (min > 0)
|
|
86
|
-
return `${min}m`;
|
|
87
|
-
return `${sec}s`;
|
|
88
|
-
}
|
|
89
|
-
/** Format a timestamp as relative time "X min ago". */
|
|
90
|
-
export function formatRelative(fromIso, nowMs) {
|
|
91
|
-
const fromMs = Date.parse(fromIso);
|
|
92
|
-
if (isNaN(fromMs))
|
|
93
|
-
return "unknown";
|
|
94
|
-
const deltaMs = nowMs - fromMs;
|
|
95
|
-
return formatDuration(deltaMs) + " ago";
|
|
96
|
-
}
|
|
97
|
-
/** Format a token count with k/M suffix. */
|
|
98
|
-
export function formatTokens(n) {
|
|
99
|
-
if (n < 1000)
|
|
100
|
-
return String(n);
|
|
101
|
-
if (n < 1_000_000)
|
|
102
|
-
return `${(n / 1000).toFixed(1)}k`;
|
|
103
|
-
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
104
|
-
}
|
|
105
|
-
/** Format USD cost with 2-4 decimals depending on size. */
|
|
106
|
-
export function formatUsd(n) {
|
|
107
|
-
if (n < 0.01)
|
|
108
|
-
return `$${n.toFixed(4)}`;
|
|
109
|
-
if (n < 100)
|
|
110
|
-
return `$${n.toFixed(2)}`;
|
|
111
|
-
return `$${n.toFixed(0)}`;
|
|
112
|
-
}
|
package/footer-status.js
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { parseContextWindowStatusLine, parseQuotaUsageStatusLine, } from "./status-parsers.ts";
|
|
2
|
-
import { getProviderLabel } from "./packages/types/src/ai-providers.js";
|
|
3
|
-
import { providerHasContinuousScrape, providerHasTUISignal, } from "./packages/providers/src/provider-id.js";
|
|
4
|
-
export function parseFooterStatusValue(value) {
|
|
5
|
-
const quota = parseQuotaUsageStatusLine(value);
|
|
6
|
-
if (quota) {
|
|
7
|
-
return { kind: "quota", value: quota };
|
|
8
|
-
}
|
|
9
|
-
const contextWindow = parseContextWindowStatusLine(value);
|
|
10
|
-
if (contextWindow) {
|
|
11
|
-
return { kind: "context-window", value: contextWindow };
|
|
12
|
-
}
|
|
13
|
-
const todayMatch = value.match(/^today:\s*([\d,.]+)k tok\s*·\s*\$([\d,.]+)/i);
|
|
14
|
-
if (todayMatch) {
|
|
15
|
-
const tokens = Number.parseFloat(todayMatch[1].replace(/,/g, "")) * 1000;
|
|
16
|
-
const cost = Number.parseFloat(todayMatch[2].replace(/,/g, ""));
|
|
17
|
-
if (Number.isFinite(tokens) && Number.isFinite(cost)) {
|
|
18
|
-
return {
|
|
19
|
-
kind: "today",
|
|
20
|
-
value: { tokens, cost },
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return { kind: "unknown", value: null };
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Get display label for a provider id.
|
|
28
|
-
* Uses the canonical getProviderLabel() from packages/types/src/ai-providers.ts
|
|
29
|
-
* which handles all 9 known providers: minimax, openai, anthropic, glm,
|
|
30
|
-
* openrouter, openai-codex, deepseek, gemini, kimi.
|
|
31
|
-
*/
|
|
32
|
-
function providerDisplayName(provider) {
|
|
33
|
-
if (!provider)
|
|
34
|
-
return "Provider";
|
|
35
|
-
return getProviderLabel(provider);
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Hint shown when the provider has no usage data yet.
|
|
39
|
-
* Covers all 9 known providers from KNOWN_AI_PROVIDERS.
|
|
40
|
-
*/
|
|
41
|
-
function missingDataHint(provider, hasCookieSource) {
|
|
42
|
-
if (!provider)
|
|
43
|
-
return "no usage source configured";
|
|
44
|
-
// MiniMax: needs cookies for continuous scrape
|
|
45
|
-
if (provider === "minimax") {
|
|
46
|
-
return hasCookieSource
|
|
47
|
-
? "no data yet (updates after first scrape)"
|
|
48
|
-
: "drop minimax cookies into ~/.pi-harness-runtime/cookies/";
|
|
49
|
-
}
|
|
50
|
-
// TUI signal providers: one-shot signal on limit hit
|
|
51
|
-
if (providerHasTUISignal(provider)) {
|
|
52
|
-
return "no signal yet (updates on first limit hit)";
|
|
53
|
-
}
|
|
54
|
-
// Providers without any tracking implementation yet
|
|
55
|
-
// deepseek, gemini, kimi, or any unknown provider
|
|
56
|
-
if (provider === "deepseek") {
|
|
57
|
-
return "deepseek usage tracking not yet implemented";
|
|
58
|
-
}
|
|
59
|
-
if (provider === "gemini") {
|
|
60
|
-
return "gemini usage tracking not yet implemented";
|
|
61
|
-
}
|
|
62
|
-
if (provider === "kimi") {
|
|
63
|
-
return "kimi usage tracking not yet implemented";
|
|
64
|
-
}
|
|
65
|
-
// Generic fallback for any other unknown provider
|
|
66
|
-
if (!hasCookieSource)
|
|
67
|
-
return "no usage source configured";
|
|
68
|
-
return "no data yet";
|
|
69
|
-
}
|
|
70
|
-
/** Normalize an unknown mirror record into a ProviderMirrorRecord view. */
|
|
71
|
-
function toProviderView(mirror) {
|
|
72
|
-
if (!mirror)
|
|
73
|
-
return null;
|
|
74
|
-
const m = mirror;
|
|
75
|
-
if (typeof m.provider === "string")
|
|
76
|
-
return m;
|
|
77
|
-
// Legacy flat-shape: already a single record without a provider wrapper.
|
|
78
|
-
return {
|
|
79
|
-
synced_at: m.synced_at ?? new Date().toISOString(),
|
|
80
|
-
provider: m.provider ?? "",
|
|
81
|
-
source: "scrape",
|
|
82
|
-
model: m.model,
|
|
83
|
-
h5_used_pct: m.h5_used_pct,
|
|
84
|
-
h5_resets_at: m.h5_resets_at,
|
|
85
|
-
weekly_used_pct: m.weekly_used_pct,
|
|
86
|
-
weekly_resets_at: m.weekly_resets_at,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
export function buildFooterStatusValue(local, mirror, freshness, hasCookieSource = true, activeProvider = null) {
|
|
90
|
-
const view = toProviderView(mirror);
|
|
91
|
-
const provider = activeProvider ?? view?.provider ?? null;
|
|
92
|
-
const label = providerDisplayName(provider);
|
|
93
|
-
// TUI-signal exhaustion path: fires when limit is hit (one-shot signal).
|
|
94
|
-
// This takes priority over continuous data to show the most recent state.
|
|
95
|
-
if (provider &&
|
|
96
|
-
view &&
|
|
97
|
-
view.exhausted &&
|
|
98
|
-
(view.limitType !== undefined ||
|
|
99
|
-
view.resets_at !== undefined ||
|
|
100
|
-
view.h5_resets_at !== undefined)) {
|
|
101
|
-
const reset = view.resets_at ?? view.h5_resets_at ?? "soon";
|
|
102
|
-
const limitType = view.limitType ?? "tokens";
|
|
103
|
-
return `${label}: limit hit (${limitType}), reset ${reset}`;
|
|
104
|
-
}
|
|
105
|
-
// Continuous data path: providers with continuous scrape (MiniMax has 5h+weekly, OpenAI has weekly-only)
|
|
106
|
-
if (provider &&
|
|
107
|
-
providerHasContinuousScrape(provider) &&
|
|
108
|
-
view &&
|
|
109
|
-
freshness !== "expired" &&
|
|
110
|
-
(view.h5_used_pct !== undefined || view.weekly_used_pct !== undefined)) {
|
|
111
|
-
const weeklyPct = view.weekly_used_pct ?? 0;
|
|
112
|
-
const weeklyLeft = Math.max(0, 100 - weeklyPct);
|
|
113
|
-
const weeklyResets = view.weekly_resets_at ?? "soon";
|
|
114
|
-
let statusLine;
|
|
115
|
-
if (view.h5_used_pct !== undefined) {
|
|
116
|
-
// MiniMax: has both 5h and weekly windows
|
|
117
|
-
const h5Pct = view.h5_used_pct;
|
|
118
|
-
const h5Left = Math.max(0, 100 - h5Pct);
|
|
119
|
-
statusLine = `5h: ${h5Left.toFixed(0)}% left · week: ${weeklyLeft.toFixed(0)}% left`;
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
// OpenAI: weekly-only (no 5h window)
|
|
123
|
-
statusLine = `week: ${weeklyLeft.toFixed(0)}% left (resets ${weeklyResets})`;
|
|
124
|
-
}
|
|
125
|
-
const freshnessSuffix = freshness === "fresh" || freshness === "missing" ? "" : ` · ${freshness}`;
|
|
126
|
-
return `${label}: ${statusLine}${freshnessSuffix}`;
|
|
127
|
-
}
|
|
128
|
-
// TUI signal providers (OpenAI, Anthropic, GLM, OpenRouter):
|
|
129
|
-
// Show monitoring status when we have a record but haven't hit limits.
|
|
130
|
-
// These providers only emit data when a limit is hit.
|
|
131
|
-
if (provider &&
|
|
132
|
-
providerHasTUISignal(provider) &&
|
|
133
|
-
view &&
|
|
134
|
-
freshness !== "expired") {
|
|
135
|
-
// If exhausted is not set, we're monitoring normally
|
|
136
|
-
if (!view.exhausted) {
|
|
137
|
-
return `${label}: monitoring (no limits hit)`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// Discoverable hint when we have no data yet.
|
|
141
|
-
if (provider &&
|
|
142
|
-
(!view || freshness === "expired" || freshness === "missing")) {
|
|
143
|
-
const hint = missingDataHint(provider, hasCookieSource);
|
|
144
|
-
// MiniMax-on-fresh-machine still gets the cookie hint; for others
|
|
145
|
-
// the hint explains the signal-driven design.
|
|
146
|
-
if (provider === "minimax" && !hasCookieSource) {
|
|
147
|
-
return `${label}: 5h: -- (${hint})`;
|
|
148
|
-
}
|
|
149
|
-
return `${label}: 5h: -- · week: -- (${hint})`;
|
|
150
|
-
}
|
|
151
|
-
// Silent fallback: only reached if we genuinely don't know the provider.
|
|
152
|
-
// No "5h/week: --" line here because we'd be making up data.
|
|
153
|
-
const todayStr = `${(local.today.tokens / 1000).toFixed(1)}k tok · $${local.today.cost.toFixed(3)}`;
|
|
154
|
-
return `today: ${todayStr}`;
|
|
155
|
-
}
|
package/harness/agent-handoff.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent Handoff Protocol — RFC-0012
|
|
3
|
-
*
|
|
4
|
-
* Clean handoff between agents with context transfer.
|
|
5
|
-
* Ensures continuity when switching agents mid-task.
|
|
6
|
-
*/
|
|
7
|
-
import { writeJson, readJson } from "../cli.ts";
|
|
8
|
-
// @ts-expect-error - Bun has built-in Node.js types
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
export class AgentHandoffProtocol {
|
|
11
|
-
rootDir;
|
|
12
|
-
constructor(rootDir) {
|
|
13
|
-
this.rootDir = rootDir;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Create a handoff context for switching agents
|
|
17
|
-
*/
|
|
18
|
-
createHandoff(jobId, taskId, fromAgent, toAgent, currentState) {
|
|
19
|
-
const events = this.loadHandoffHistory(jobId, taskId);
|
|
20
|
-
return {
|
|
21
|
-
jobId,
|
|
22
|
-
taskId,
|
|
23
|
-
fromAgent,
|
|
24
|
-
toAgent,
|
|
25
|
-
sharedFiles: [],
|
|
26
|
-
taskHistory: events,
|
|
27
|
-
summary: this.generateSummary(taskId, currentState),
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Record a handoff event
|
|
32
|
-
*/
|
|
33
|
-
recordHandoff(context, result) {
|
|
34
|
-
const path = join(this.rootDir, "jobs", context.jobId, "handoffs", `${context.taskId}.json`);
|
|
35
|
-
const event = {
|
|
36
|
-
ts: new Date().toISOString(),
|
|
37
|
-
agentId: context.toAgent,
|
|
38
|
-
action: "handoff_received",
|
|
39
|
-
result,
|
|
40
|
-
};
|
|
41
|
-
context.taskHistory.push(event);
|
|
42
|
-
writeJson(path, context);
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Generate handoff prompt for the receiving agent
|
|
46
|
-
*/
|
|
47
|
-
generateHandoffPrompt(context) {
|
|
48
|
-
const lines = [
|
|
49
|
-
`## Agent Handoff`,
|
|
50
|
-
``,
|
|
51
|
-
`**From Agent:** ${context.fromAgent}`,
|
|
52
|
-
`**To Agent:** ${context.toAgent}`,
|
|
53
|
-
`**Task:** ${context.taskId}`,
|
|
54
|
-
``,
|
|
55
|
-
`### Task History`,
|
|
56
|
-
];
|
|
57
|
-
for (const event of context.taskHistory) {
|
|
58
|
-
lines.push(`- [${event.ts}] ${event.agentId}: ${event.action}`);
|
|
59
|
-
if (event.result) {
|
|
60
|
-
lines.push(` Result: ${event.result}`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
lines.push(``);
|
|
64
|
-
lines.push(`### Summary`);
|
|
65
|
-
lines.push(context.summary);
|
|
66
|
-
if (context.sharedFiles.length > 0) {
|
|
67
|
-
lines.push(``);
|
|
68
|
-
lines.push(`### Shared Files`);
|
|
69
|
-
for (const file of context.sharedFiles) {
|
|
70
|
-
lines.push(`- ${file}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return lines.join("\n");
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Validate handoff readiness
|
|
77
|
-
*/
|
|
78
|
-
validateHandoff(context) {
|
|
79
|
-
const issues = [];
|
|
80
|
-
if (!context.summary) {
|
|
81
|
-
issues.push("Task summary is empty");
|
|
82
|
-
}
|
|
83
|
-
if (context.taskHistory.length === 0) {
|
|
84
|
-
issues.push("No task history recorded");
|
|
85
|
-
}
|
|
86
|
-
// Check for recent handoffs
|
|
87
|
-
const recentHandoffs = context.taskHistory.filter((h) => {
|
|
88
|
-
const age = Date.now() - Date.parse(h.ts);
|
|
89
|
-
return age < 5 * 60 * 1000; // 5 minutes
|
|
90
|
-
});
|
|
91
|
-
if (recentHandoffs.length > 3) {
|
|
92
|
-
issues.push(`Too many recent handoffs (${recentHandoffs.length}). Possible ping-pong.`);
|
|
93
|
-
}
|
|
94
|
-
return { valid: issues.length === 0, issues };
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Load handoff history for a task
|
|
98
|
-
*/
|
|
99
|
-
loadHandoffHistory(jobId, taskId) {
|
|
100
|
-
const path = join(this.rootDir, "jobs", jobId, "handoffs", `${taskId}.json`);
|
|
101
|
-
const data = readJson(path);
|
|
102
|
-
return data?.taskHistory ?? [];
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Generate a summary of the task state
|
|
106
|
-
*/
|
|
107
|
-
generateSummary(taskId, currentState) {
|
|
108
|
-
if (!currentState) {
|
|
109
|
-
return `Task ${taskId} requires continuation. Check task files for current state.`;
|
|
110
|
-
}
|
|
111
|
-
const lines = [`Task ${taskId} is in progress.`];
|
|
112
|
-
if (currentState.filesModified) {
|
|
113
|
-
lines.push(`Files modified: ${currentState.filesModified.join(", ")}`);
|
|
114
|
-
}
|
|
115
|
-
if (currentState.lastAction) {
|
|
116
|
-
lines.push(`Last action: ${currentState.lastAction}`);
|
|
117
|
-
}
|
|
118
|
-
if (currentState.blockers) {
|
|
119
|
-
lines.push(`Blockers: ${currentState.blockers}`);
|
|
120
|
-
}
|
|
121
|
-
return lines.join("\n");
|
|
122
|
-
}
|
|
123
|
-
}
|
package/harness/auto-compact.js
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto Compact and Continue — RFC-0019
|
|
3
|
-
*
|
|
4
|
-
* Automatically resume work after model/session compaction without requiring
|
|
5
|
-
* human intervention. Keeps the human out of the message-bus role.
|
|
6
|
-
*
|
|
7
|
-
* This module integrates with:
|
|
8
|
-
* - `continue-prompt.ts` for prompt generation
|
|
9
|
-
* - `context-compact-orchestrator.ts` for compact decisions
|
|
10
|
-
* - `partial-recovery.ts` for artifact persistence
|
|
11
|
-
*
|
|
12
|
-
* Artifact Layout:
|
|
13
|
-
* harness/context/
|
|
14
|
-
* compaction_events.jsonl
|
|
15
|
-
* latest_compaction_summary.md
|
|
16
|
-
* continue_prompt.md
|
|
17
|
-
*/
|
|
18
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import { homedir } from "node:os";
|
|
21
|
-
import { continuePromptGenerator, } from "./continue-prompt.js";
|
|
22
|
-
const COMPACTION_PATTERNS = [
|
|
23
|
-
/\[compaction\]/i,
|
|
24
|
-
/compacted from \d+[,.]?\d* tokens/i,
|
|
25
|
-
/model stopped because it reached the maximum output token limit/i,
|
|
26
|
-
/error.*output.*token.*limit/i,
|
|
27
|
-
/context.*truncated/i,
|
|
28
|
-
/session.*compact/i,
|
|
29
|
-
/\[Earlier conversation summarized\]/i,
|
|
30
|
-
];
|
|
31
|
-
const CONTINUE_MARKERS = [
|
|
32
|
-
/would you like me to continue/i,
|
|
33
|
-
/should i continue/i,
|
|
34
|
-
/type.*continue/i,
|
|
35
|
-
/\[continue\]/i,
|
|
36
|
-
/waiting for your response/i,
|
|
37
|
-
/do not repeat work already completed/i,
|
|
38
|
-
];
|
|
39
|
-
export class AutoCompactEngine {
|
|
40
|
-
rootDir;
|
|
41
|
-
jobId;
|
|
42
|
-
requirement;
|
|
43
|
-
maxAttempts;
|
|
44
|
-
continueAttempts = 0;
|
|
45
|
-
constructor(config) {
|
|
46
|
-
this.rootDir =
|
|
47
|
-
config.rootDir ??
|
|
48
|
-
join(homedir(), ".pi", "harness", config.jobId, "context");
|
|
49
|
-
this.jobId = config.jobId;
|
|
50
|
-
this.requirement = config.requirement ?? "";
|
|
51
|
-
this.maxAttempts = config.maxContinueAttempts ?? 5;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Detect if output contains compaction markers
|
|
55
|
-
*/
|
|
56
|
-
detectCompaction(output) {
|
|
57
|
-
return COMPACTION_PATTERNS.some((pattern) => pattern.test(output));
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Detect if output is waiting for continue signal
|
|
61
|
-
*/
|
|
62
|
-
isWaitingForContinue(output) {
|
|
63
|
-
return CONTINUE_MARKERS.some((pattern) => pattern.test(output));
|
|
64
|
-
}
|
|
65
|
-
/**
|
|
66
|
-
* Parse compaction details from output
|
|
67
|
-
*/
|
|
68
|
-
parseCompactionEvent(output, taskId) {
|
|
69
|
-
let compactedFromTokens = 0;
|
|
70
|
-
let reason = "unknown";
|
|
71
|
-
let errorMessage;
|
|
72
|
-
// Extract compacted token count
|
|
73
|
-
const tokenMatch = output.match(/compacted from ([,\d]+(?:\.\d+)?)\s*tokens/i);
|
|
74
|
-
if (tokenMatch) {
|
|
75
|
-
compactedFromTokens = parseInt(tokenMatch[1].replace(/,/g, ""), 10);
|
|
76
|
-
}
|
|
77
|
-
// Extract reason
|
|
78
|
-
if (/output.*token.*limit/i.test(output)) {
|
|
79
|
-
reason = "output_token_limit";
|
|
80
|
-
}
|
|
81
|
-
else if (/context.*truncated/i.test(output)) {
|
|
82
|
-
reason = "context_truncated";
|
|
83
|
-
}
|
|
84
|
-
else if (/session.*compact/i.test(output)) {
|
|
85
|
-
reason = "session_compact";
|
|
86
|
-
}
|
|
87
|
-
else if (/\[Earlier conversation summarized\]/i.test(output)) {
|
|
88
|
-
reason = "context_compact";
|
|
89
|
-
}
|
|
90
|
-
// Extract error message
|
|
91
|
-
const errorMatch = output.match(/error[:\s]+(.+)/i);
|
|
92
|
-
if (errorMatch) {
|
|
93
|
-
errorMessage = errorMatch[1].trim();
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
timestamp: new Date().toISOString(),
|
|
97
|
-
jobId: this.jobId,
|
|
98
|
-
taskId,
|
|
99
|
-
compactedFromTokens,
|
|
100
|
-
reason,
|
|
101
|
-
errorMessage,
|
|
102
|
-
partialOutput: output,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Save compaction artifact
|
|
107
|
-
*/
|
|
108
|
-
saveCompactionArtifact(event) {
|
|
109
|
-
this.ensureDir();
|
|
110
|
-
// Append to events log
|
|
111
|
-
const eventsPath = join(this.rootDir, "compaction_events.jsonl");
|
|
112
|
-
appendFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
113
|
-
// Write latest summary
|
|
114
|
-
const summaryPath = join(this.rootDir, "latest_compaction_summary.md");
|
|
115
|
-
const summary = this.generateSummary(event);
|
|
116
|
-
writeFileSync(summaryPath, summary, "utf-8");
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Generate continue prompt using ContinuePromptGenerator
|
|
120
|
-
*/
|
|
121
|
-
generateContinuePrompt(taskId, completedWork, remainingWork) {
|
|
122
|
-
const context = {
|
|
123
|
-
taskId,
|
|
124
|
-
requirement: this.requirement || "Continue from previous session",
|
|
125
|
-
whatWasCompleted: completedWork,
|
|
126
|
-
whatNeedsToBeDone: remainingWork,
|
|
127
|
-
partialFiles: this.findPartialFiles(),
|
|
128
|
-
decisions: [],
|
|
129
|
-
};
|
|
130
|
-
const prompt = continuePromptGenerator.generate(context);
|
|
131
|
-
// Also save to file for persistence
|
|
132
|
-
const promptPath = join(this.rootDir, "continue_prompt.md");
|
|
133
|
-
writeFileSync(promptPath, prompt, "utf-8");
|
|
134
|
-
return prompt;
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Generate minimal continue prompt
|
|
138
|
-
*/
|
|
139
|
-
generateMinimalContinuePrompt(summary, recentContent) {
|
|
140
|
-
return [
|
|
141
|
-
"Continue from where you left off.",
|
|
142
|
-
"",
|
|
143
|
-
"## Summary",
|
|
144
|
-
summary,
|
|
145
|
-
"",
|
|
146
|
-
"## Recent Context",
|
|
147
|
-
recentContent.substring(0, 2000),
|
|
148
|
-
"",
|
|
149
|
-
"**Do not repeat work already completed.** Focus on completing the remaining work.",
|
|
150
|
-
].join("\n");
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Check if we should continue (respects max attempts)
|
|
154
|
-
*/
|
|
155
|
-
shouldContinue() {
|
|
156
|
-
this.continueAttempts++;
|
|
157
|
-
return this.continueAttempts <= this.maxAttempts;
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Get current continue attempt count
|
|
161
|
-
*/
|
|
162
|
-
getContinueAttempts() {
|
|
163
|
-
return this.continueAttempts;
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Reset continue attempts
|
|
167
|
-
*/
|
|
168
|
-
resetAttempts() {
|
|
169
|
-
this.continueAttempts = 0;
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Build the continue message for the next turn
|
|
173
|
-
*/
|
|
174
|
-
buildContinueMessage() {
|
|
175
|
-
const promptPath = join(this.rootDir, "continue_prompt.md");
|
|
176
|
-
if (existsSync(promptPath)) {
|
|
177
|
-
const prompt = readFileSync(promptPath, "utf-8");
|
|
178
|
-
return `continue\n\n${prompt}`;
|
|
179
|
-
}
|
|
180
|
-
return "continue";
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Load continue prompt from file
|
|
184
|
-
*/
|
|
185
|
-
loadContinuePrompt() {
|
|
186
|
-
const promptPath = join(this.rootDir, "continue_prompt.md");
|
|
187
|
-
if (existsSync(promptPath)) {
|
|
188
|
-
return readFileSync(promptPath, "utf-8");
|
|
189
|
-
}
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Check if there's a pending continue prompt
|
|
194
|
-
*/
|
|
195
|
-
hasContinuePrompt() {
|
|
196
|
-
return existsSync(join(this.rootDir, "continue_prompt.md"));
|
|
197
|
-
}
|
|
198
|
-
// --- Private Methods ------------------------------------------------
|
|
199
|
-
ensureDir() {
|
|
200
|
-
if (!existsSync(this.rootDir)) {
|
|
201
|
-
mkdirSync(this.rootDir, { recursive: true });
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
generateSummary(event) {
|
|
205
|
-
return `# Compaction Summary
|
|
206
|
-
|
|
207
|
-
**Time:** ${event.timestamp}
|
|
208
|
-
**Job:** ${event.jobId}
|
|
209
|
-
**Task:** ${event.taskId}
|
|
210
|
-
**Reason:** ${event.reason}
|
|
211
|
-
**Tokens Compacted:** ${event.compactedFromTokens.toLocaleString()}
|
|
212
|
-
|
|
213
|
-
${event.errorMessage ? `**Error:** ${event.errorMessage}` : ""}
|
|
214
|
-
|
|
215
|
-
## What Happened
|
|
216
|
-
|
|
217
|
-
Session was compacted due to ${event.reason}.
|
|
218
|
-
|
|
219
|
-
${event.compactedFromTokens > 0
|
|
220
|
-
? `Context was reduced from approximately ${event.compactedFromTokens.toLocaleString()} tokens.`
|
|
221
|
-
: "Context was compacted."}
|
|
222
|
-
|
|
223
|
-
## Next Step
|
|
224
|
-
|
|
225
|
-
Runtime will automatically continue this task.
|
|
226
|
-
|
|
227
|
-
Continue attempts: ${this.continueAttempts}/${this.maxAttempts}
|
|
228
|
-
`;
|
|
229
|
-
}
|
|
230
|
-
findPartialFiles() {
|
|
231
|
-
const partialDir = join(this.rootDir, "..", "partial", this.jobId);
|
|
232
|
-
if (!existsSync(partialDir)) {
|
|
233
|
-
return [];
|
|
234
|
-
}
|
|
235
|
-
try {
|
|
236
|
-
const files = readFileSync(join(partialDir, "files.json"), "utf-8");
|
|
237
|
-
return JSON.parse(files);
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return [];
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|