harlay 0.1.2 → 0.1.3
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/app/dist/main.cjs +485 -353
- package/app/renderer/popover.css +1 -0
- package/app/renderer/popover.js +98 -2
- package/dist/harlay.mjs +453 -314
- package/package.json +1 -1
package/dist/harlay.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
// ../core/src/types.ts
|
|
8
8
|
var TOOLS = ["claude-code", "codex", "cursor"];
|
|
9
9
|
var TOOL_LABEL = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
|
|
10
|
+
function isTool(v) {
|
|
11
|
+
return v === "claude-code" || v === "codex" || v === "cursor";
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
// ../core/src/errors.ts
|
|
12
15
|
var HarlayError = class extends Error {
|
|
@@ -24,20 +27,112 @@ var LaunchError = class extends HarlayError {
|
|
|
24
27
|
written;
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
// ../core/src/launch/launchers.ts
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
import { promisify } from "node:util";
|
|
35
|
+
var pexec = promisify(execFile);
|
|
36
|
+
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
37
|
+
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
38
|
+
var PROMPT_LIMIT = {
|
|
39
|
+
"claude-code": { app: 6e3, terminal: 1e5 },
|
|
40
|
+
codex: { app: 6e3, terminal: 1e5 },
|
|
41
|
+
cursor: { app: 9900, terminal: 1e5 }
|
|
42
|
+
};
|
|
43
|
+
function promptFits(target, mode, prompt) {
|
|
44
|
+
const length = mode === "app" ? encodeURIComponent(prompt).length : prompt.length;
|
|
45
|
+
return length <= PROMPT_LIMIT[target][mode];
|
|
46
|
+
}
|
|
47
|
+
var defaultRunner = {
|
|
48
|
+
async open(args) {
|
|
49
|
+
await pexec("/usr/bin/open", args);
|
|
50
|
+
},
|
|
51
|
+
async writeExecutable(path, content) {
|
|
52
|
+
await mkdir(dirname(path), { recursive: true });
|
|
53
|
+
await writeFile(path, content, { mode: 493 });
|
|
54
|
+
await chmod(path, 493);
|
|
55
|
+
},
|
|
56
|
+
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
57
|
+
};
|
|
58
|
+
function shq(s) {
|
|
59
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
60
|
+
}
|
|
61
|
+
var enc = encodeURIComponent;
|
|
62
|
+
function planLaunch(o) {
|
|
63
|
+
if (o.mode === "app") {
|
|
64
|
+
switch (o.target) {
|
|
65
|
+
case "claude-code":
|
|
66
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
67
|
+
case "codex":
|
|
68
|
+
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
69
|
+
case "cursor":
|
|
70
|
+
return {
|
|
71
|
+
target: o.target,
|
|
72
|
+
mode: o.mode,
|
|
73
|
+
steps: [
|
|
74
|
+
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
75
|
+
{ kind: "wait", ms: 1500 },
|
|
76
|
+
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
77
|
+
]
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const bin = o.binaries[o.target];
|
|
82
|
+
if (!bin) throw new HarlayError(`${TOOL_LABEL[o.target]} CLI not found. Install it or switch ${TOOL_LABEL[o.target]} to app mode.`);
|
|
83
|
+
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
84
|
+
const path = join(o.scriptDir, `continue-${o.target}.command`);
|
|
85
|
+
const content = `#!/bin/zsh -l
|
|
86
|
+
cd -- ${shq(o.cwd)} || exit 1
|
|
87
|
+
exec ${argv.map(shq).join(" ")}
|
|
88
|
+
`;
|
|
89
|
+
return {
|
|
90
|
+
target: o.target,
|
|
91
|
+
mode: o.mode,
|
|
92
|
+
steps: [
|
|
93
|
+
{ kind: "script", path, content },
|
|
94
|
+
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function executeLaunch(plan, runner = defaultRunner) {
|
|
99
|
+
for (const step of plan.steps) {
|
|
100
|
+
if (step.kind === "open") await runner.open(step.args);
|
|
101
|
+
else if (step.kind === "script") await runner.writeExecutable(step.path, step.content);
|
|
102
|
+
else await runner.sleep(step.ms);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
27
106
|
// ../core/src/util/text.ts
|
|
28
107
|
var CHARS_PER_TOKEN = 4;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (s.length <= max) return s;
|
|
32
|
-
const note = pointer ? ` \u2026 (truncated, see ${pointer})` : " \u2026 (truncated)";
|
|
33
|
-
return s.slice(0, Math.max(0, max - note.length)).trimEnd() + note;
|
|
34
|
-
}
|
|
108
|
+
var isHigh = (c) => c >= 55296 && c <= 56319;
|
|
109
|
+
var isLow = (c) => c >= 56320 && c <= 57343;
|
|
35
110
|
function tail(s, maxChars) {
|
|
36
|
-
|
|
111
|
+
if (s.length <= maxChars) return s;
|
|
112
|
+
let start = s.length - maxChars + 1;
|
|
113
|
+
if (start < s.length && isLow(s.charCodeAt(start))) start++;
|
|
114
|
+
return "\u2026" + s.slice(start);
|
|
115
|
+
}
|
|
116
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
117
|
+
function wellFormed(s) {
|
|
118
|
+
return s.replace(LONE_SURROGATE, "\uFFFD");
|
|
37
119
|
}
|
|
38
120
|
function oneLine(s, max = 120) {
|
|
39
121
|
const t = s.replace(/\s+/g, " ").trim();
|
|
40
|
-
|
|
122
|
+
if (t.length <= max) return t;
|
|
123
|
+
let end = max - 1;
|
|
124
|
+
if (end > 0 && isHigh(t.charCodeAt(end - 1))) end--;
|
|
125
|
+
return t.slice(0, end) + "\u2026";
|
|
126
|
+
}
|
|
127
|
+
function wordCount(s) {
|
|
128
|
+
return s.split(/\s+/).filter(Boolean).length;
|
|
129
|
+
}
|
|
130
|
+
function clipWords(s, max) {
|
|
131
|
+
const w = s.split(/\s+/).filter(Boolean);
|
|
132
|
+
return w.length <= max ? w.join(" ") : w.slice(0, max).join(" ") + " \u2026";
|
|
133
|
+
}
|
|
134
|
+
function splitSentences(s) {
|
|
135
|
+
return s.split(/(?<=[.!?])\s+|\n+/).map((x) => x.trim()).filter(Boolean);
|
|
41
136
|
}
|
|
42
137
|
var CONTEXT_BLOCK_RE = /<(ide_opened_file|ide_selection|artifact-view-context|artifact-content-authored-by-others)\b[^>]*?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
|
|
43
138
|
var MACHINE_TAG_RE = /^<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|local-command-caveat|task-notification|bash-input|bash-stdout|bash-stderr|environment_context|user_instructions)\b/;
|
|
@@ -48,8 +143,27 @@ function cleanPrompt(raw) {
|
|
|
48
143
|
if (MACHINE_TAG_RE.test(s)) return null;
|
|
49
144
|
return s ? s : null;
|
|
50
145
|
}
|
|
51
|
-
|
|
52
|
-
|
|
146
|
+
var HANDOFF_TAG = '<handoff from="';
|
|
147
|
+
var HANDOFF_END = "</handoff>";
|
|
148
|
+
var CLOSING_RE = /^\s*Continue from (?:the most recent user message|where it stopped)\. Start with the next concrete step\./;
|
|
149
|
+
var HANDOFF_ID_RE = /Only if you need more: \.harlay\/handoffs\/([^/\s]+)\//g;
|
|
150
|
+
function splitHandoffPrompt(p) {
|
|
151
|
+
const start = p.indexOf(HANDOFF_TAG);
|
|
152
|
+
const end = p.lastIndexOf(HANDOFF_END);
|
|
153
|
+
if (start !== -1 && end > start) {
|
|
154
|
+
const before = p.slice(0, start).trim();
|
|
155
|
+
const after = p.slice(end + HANDOFF_END.length).replace(CLOSING_RE, "").trim();
|
|
156
|
+
const id = [...p.slice(start, end).matchAll(HANDOFF_ID_RE)].at(-1)?.[1] ?? null;
|
|
157
|
+
return { handoff: true, rest: [before, after].filter(Boolean).join("\n\n"), id };
|
|
158
|
+
}
|
|
159
|
+
if (p.trimStart().startsWith(HANDOFF_TAG) || p.trim() === HANDOFF_PROMPT) return { handoff: true, rest: "", id: null };
|
|
160
|
+
return { handoff: false, rest: p, id: null };
|
|
161
|
+
}
|
|
162
|
+
var OSC_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
163
|
+
var CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
164
|
+
var CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
165
|
+
function stripControl(s) {
|
|
166
|
+
return s.replace(OSC_RE, "").replace(CSI_RE, "").replace(CONTROL_RE, "");
|
|
53
167
|
}
|
|
54
168
|
function slugify(s, max = 40) {
|
|
55
169
|
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
|
|
@@ -58,11 +172,11 @@ function slugify(s, max = 40) {
|
|
|
58
172
|
|
|
59
173
|
// ../core/src/sources/claude.ts
|
|
60
174
|
import { readFile, stat as stat2 } from "node:fs/promises";
|
|
61
|
-
import { basename, join as
|
|
175
|
+
import { basename, join as join3, relative, sep } from "node:path";
|
|
62
176
|
|
|
63
177
|
// ../core/src/util/files.ts
|
|
64
178
|
import { open, readdir, stat } from "node:fs/promises";
|
|
65
|
-
import { join } from "node:path";
|
|
179
|
+
import { join as join2 } from "node:path";
|
|
66
180
|
async function listSessionFiles(root, accept) {
|
|
67
181
|
let names;
|
|
68
182
|
try {
|
|
@@ -72,7 +186,7 @@ async function listSessionFiles(root, accept) {
|
|
|
72
186
|
}
|
|
73
187
|
const out = [];
|
|
74
188
|
for (const name of names) {
|
|
75
|
-
const path =
|
|
189
|
+
const path = join2(root, name);
|
|
76
190
|
if (!accept(path)) continue;
|
|
77
191
|
try {
|
|
78
192
|
const s = await stat(path);
|
|
@@ -280,8 +394,8 @@ async function loadDesktopTitles(dir) {
|
|
|
280
394
|
return out;
|
|
281
395
|
}
|
|
282
396
|
function createClaudeReader(o) {
|
|
283
|
-
const root =
|
|
284
|
-
const indexDir =
|
|
397
|
+
const root = join3(o.home, ".claude", "projects");
|
|
398
|
+
const indexDir = join3(o.home, "Library", "Application Support", "Claude", "claude-code-sessions");
|
|
285
399
|
const isSessionFile = (file) => file.startsWith(root + sep) && file.endsWith(".jsonl") && relative(root, file).split(sep).length === 2;
|
|
286
400
|
const cache = /* @__PURE__ */ new Map();
|
|
287
401
|
async function refForFile(file, titles) {
|
|
@@ -357,7 +471,7 @@ function createClaudeReader(o) {
|
|
|
357
471
|
|
|
358
472
|
// ../core/src/sources/codex.ts
|
|
359
473
|
import { readFile as readFile2, stat as stat3 } from "node:fs/promises";
|
|
360
|
-
import { basename as basename2, join as
|
|
474
|
+
import { basename as basename2, join as join4, sep as sep2 } from "node:path";
|
|
361
475
|
var OUTPUT_TAIL2 = 2e4;
|
|
362
476
|
var ROLLOUT_ID_RE = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
363
477
|
var KIND = { add: "create", update: "modify", delete: "delete" };
|
|
@@ -473,8 +587,8 @@ async function loadTitles(file) {
|
|
|
473
587
|
return out;
|
|
474
588
|
}
|
|
475
589
|
function createCodexReader(o) {
|
|
476
|
-
const root =
|
|
477
|
-
const indexFile =
|
|
590
|
+
const root = join4(o.home, ".codex", "sessions");
|
|
591
|
+
const indexFile = join4(o.home, ".codex", "session_index.jsonl");
|
|
478
592
|
const isSessionFile = (file) => file.startsWith(root + sep2) && basename2(file).startsWith("rollout-") && file.endsWith(".jsonl");
|
|
479
593
|
const cache = /* @__PURE__ */ new Map();
|
|
480
594
|
async function refForFile(file, titles) {
|
|
@@ -545,7 +659,7 @@ function createCodexReader(o) {
|
|
|
545
659
|
|
|
546
660
|
// ../core/src/sources/cursor.ts
|
|
547
661
|
import { readdir as readdir2, readFile as readFile3, stat as stat4 } from "node:fs/promises";
|
|
548
|
-
import { join as
|
|
662
|
+
import { join as join5, relative as relative2, sep as sep3 } from "node:path";
|
|
549
663
|
var norm = (name) => name.replace(/[^A-Za-z0-9]/g, "-");
|
|
550
664
|
async function resolveCursorSlug(slug, rootDir = "/") {
|
|
551
665
|
if (!slug || /^\d+$/.test(slug)) return null;
|
|
@@ -556,9 +670,9 @@ async function resolveCursorSlug(slug, rootDir = "/") {
|
|
|
556
670
|
for (const e of entries) {
|
|
557
671
|
if (!e.isDirectory()) continue;
|
|
558
672
|
const n = norm(e.name);
|
|
559
|
-
if (rest === n) return
|
|
673
|
+
if (rest === n) return join5(dir, e.name);
|
|
560
674
|
if (rest.startsWith(n + "-")) {
|
|
561
|
-
const found = await walk(
|
|
675
|
+
const found = await walk(join5(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
|
562
676
|
if (found) return found;
|
|
563
677
|
}
|
|
564
678
|
}
|
|
@@ -618,7 +732,7 @@ function normalizeCursor(records, ref, invalid = 0) {
|
|
|
618
732
|
return { ref, model: null, timeline, todos: todos ? [...todos.values()] : null, halt, usage: null, unknownRecords: unknown };
|
|
619
733
|
}
|
|
620
734
|
function createCursorReader(o) {
|
|
621
|
-
const root =
|
|
735
|
+
const root = join5(o.home, ".cursor", "projects");
|
|
622
736
|
const slugCache = /* @__PURE__ */ new Map();
|
|
623
737
|
const cwdFor = (slug) => {
|
|
624
738
|
let p = slugCache.get(slug);
|
|
@@ -655,8 +769,9 @@ function createCursorReader(o) {
|
|
|
655
769
|
for (const r of parseJsonLines(text).records) {
|
|
656
770
|
if (isObj(r) && r.role === "user" && isObj(r.message)) {
|
|
657
771
|
const p = cleanPrompt(blockText(r.message.content));
|
|
658
|
-
|
|
659
|
-
|
|
772
|
+
const rest = p ? splitHandoffPrompt(p).rest : "";
|
|
773
|
+
if (rest) {
|
|
774
|
+
title = oneLine(rest, 60);
|
|
660
775
|
break;
|
|
661
776
|
}
|
|
662
777
|
}
|
|
@@ -695,14 +810,14 @@ function createCursorReader(o) {
|
|
|
695
810
|
}
|
|
696
811
|
|
|
697
812
|
// ../core/src/git/snapshot.ts
|
|
698
|
-
import { execFile } from "node:child_process";
|
|
813
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
699
814
|
import { createHash } from "node:crypto";
|
|
700
|
-
import { promisify } from "node:util";
|
|
701
|
-
var
|
|
815
|
+
import { promisify as promisify2 } from "node:util";
|
|
816
|
+
var pexec2 = promisify2(execFile2);
|
|
702
817
|
var MAX_DIFF_CHARS = 2e6;
|
|
703
818
|
var MAX_FILES = 500;
|
|
704
819
|
async function git(cwd, args) {
|
|
705
|
-
const { stdout } = await
|
|
820
|
+
const { stdout } = await pexec2("git", args, {
|
|
706
821
|
cwd,
|
|
707
822
|
maxBuffer: 64 * 1024 * 1024,
|
|
708
823
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }
|
|
@@ -857,11 +972,75 @@ var Redactor = class {
|
|
|
857
972
|
|
|
858
973
|
// ../core/src/extract/extract.ts
|
|
859
974
|
import { basename as basename3, isAbsolute, relative as relative3 } from "node:path";
|
|
860
|
-
|
|
975
|
+
|
|
976
|
+
// ../core/src/extract/recent.ts
|
|
977
|
+
var RECENT = { budgetTokens: 450, min: 2, max: 6, words: 120 };
|
|
978
|
+
function pickRecentMessages(prompts) {
|
|
979
|
+
const out = [];
|
|
980
|
+
let used = 0;
|
|
981
|
+
for (let i = prompts.length - 1; i >= 0 && out.length < RECENT.max; i--) {
|
|
982
|
+
const m = clipWords(prompts[i], RECENT.words);
|
|
983
|
+
if (!m) continue;
|
|
984
|
+
const cost = Math.ceil(m.length / CHARS_PER_TOKEN);
|
|
985
|
+
if (out.length >= RECENT.min && used + cost > RECENT.budgetTokens) break;
|
|
986
|
+
out.unshift(m);
|
|
987
|
+
used += cost;
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ../core/src/extract/rules.ts
|
|
993
|
+
var RULES2 = { maxRules: 10, budgetTokens: 300, words: 40, pastedWords: 400 };
|
|
994
|
+
var DIRECTIVE_RE = /\b(never|always|don['’]?t|do not|must|make sure|prefer|avoid|no need to|instead of|from now on|going forward|remember|stop|only)\b/i;
|
|
995
|
+
var norm2 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
996
|
+
function findPinnedRules(prompts, recent) {
|
|
997
|
+
const recentText = norm2(recent.join("\n"));
|
|
998
|
+
const found = [];
|
|
999
|
+
prompts.forEach((p, message) => {
|
|
1000
|
+
if (wordCount(p) > RULES2.pastedWords) return;
|
|
1001
|
+
for (const sentence of splitSentences(p)) {
|
|
1002
|
+
if (sentence.endsWith("?") || sentence.endsWith(":") || !DIRECTIVE_RE.test(sentence)) continue;
|
|
1003
|
+
if (recentText.includes(norm2(sentence))) continue;
|
|
1004
|
+
found.push({ text: clipWords(sentence, RULES2.words), message });
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
return capRules(latestCopies(found));
|
|
1008
|
+
}
|
|
1009
|
+
function mergePinnedRules(older, newer, recent) {
|
|
1010
|
+
const recentText = norm2(recent.join("\n"));
|
|
1011
|
+
const all = [...older.map((r) => ({ text: r.text, message: -1 })), ...newer].filter((r) => !recentText.includes(norm2(r.text)));
|
|
1012
|
+
return capRules(latestCopies(all));
|
|
1013
|
+
}
|
|
1014
|
+
function latestCopies(rules) {
|
|
1015
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1016
|
+
for (const r of rules) {
|
|
1017
|
+
const key = norm2(r.text);
|
|
1018
|
+
byKey.delete(key);
|
|
1019
|
+
byKey.set(key, r);
|
|
1020
|
+
}
|
|
1021
|
+
return [...byKey.values()];
|
|
1022
|
+
}
|
|
1023
|
+
function capRules(all) {
|
|
1024
|
+
const kept = [];
|
|
1025
|
+
let used = 0;
|
|
1026
|
+
for (let i = all.length - 1; i >= 0 && kept.length < RULES2.maxRules; i--) {
|
|
1027
|
+
const cost = Math.ceil(all[i].text.length / CHARS_PER_TOKEN);
|
|
1028
|
+
if (used + cost > RULES2.budgetTokens) break;
|
|
1029
|
+
kept.unshift(all[i]);
|
|
1030
|
+
used += cost;
|
|
1031
|
+
}
|
|
1032
|
+
return { rules: kept, more: all.length - kept.length };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ../core/src/extract/extract.ts
|
|
861
1036
|
var TRANSCRIPT_MAX = 2e5;
|
|
1037
|
+
var SLASH_COMMAND_RE = /^\/[a-z][\w-]*(\s|$)/i;
|
|
1038
|
+
function isSlashCommand(p) {
|
|
1039
|
+
return SLASH_COMMAND_RE.test(p.trim());
|
|
1040
|
+
}
|
|
862
1041
|
function isSubstantive(p) {
|
|
863
1042
|
const t = p.trim();
|
|
864
|
-
return !t
|
|
1043
|
+
return !isSlashCommand(t) && t.split(/\s+/).length >= 3;
|
|
865
1044
|
}
|
|
866
1045
|
var PROBES = /* @__PURE__ */ new Set([
|
|
867
1046
|
"rg",
|
|
@@ -902,46 +1081,41 @@ function isProbeCommand(cmd) {
|
|
|
902
1081
|
while (i < words.length && words[i].startsWith("-")) i += words[i] === "-C" || words[i] === "-c" ? 2 : 1;
|
|
903
1082
|
return GIT_PROBES.has(words[i] ?? "");
|
|
904
1083
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1084
|
+
var FAILURE_WINDOW = 5;
|
|
1085
|
+
function findLastFailure(cmds) {
|
|
1086
|
+
const real = cmds.filter((c) => !isProbeCommand(c.cmd));
|
|
1087
|
+
for (let i = real.length - 1; i >= Math.max(0, real.length - FAILURE_WINDOW); i--) {
|
|
1088
|
+
const c = real[i];
|
|
1089
|
+
if (c.exitCode === null || c.exitCode === 0) continue;
|
|
1090
|
+
if (real.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) continue;
|
|
1091
|
+
return { cmd: c.cmd, exitCode: c.exitCode, output: dropPartialLine(c.output) };
|
|
910
1092
|
}
|
|
911
1093
|
return null;
|
|
912
1094
|
}
|
|
913
|
-
function
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
if (c.exitCode === null || c.exitCode === 0 || seen.has(c.cmd) || isProbeCommand(c.cmd)) return;
|
|
918
|
-
if (blocker && c.cmd === blocker.cmd) return;
|
|
919
|
-
if (cmds.slice(i + 1).some((d) => d.cmd === c.cmd && d.exitCode === 0)) return;
|
|
920
|
-
seen.add(c.cmd);
|
|
921
|
-
out.push({ cmd: c.cmd, exitCode: c.exitCode });
|
|
922
|
-
});
|
|
923
|
-
return out.slice(-5);
|
|
924
|
-
}
|
|
925
|
-
function findDecisions(prompts) {
|
|
926
|
-
const out = [];
|
|
927
|
-
for (const p of prompts) {
|
|
928
|
-
for (const s of p.split(/(?<=[.!?])\s+|\n+/)) {
|
|
929
|
-
const t = s.trim();
|
|
930
|
-
if (t.length >= 8 && t.length <= 300 && DECISION_RE.test(t) && !out.includes(t)) out.push(t);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
return out.slice(-8);
|
|
1095
|
+
function dropPartialLine(output) {
|
|
1096
|
+
if (!output.startsWith("\u2026")) return output;
|
|
1097
|
+
const nl = output.indexOf("\n");
|
|
1098
|
+
return nl === -1 ? "" : output.slice(nl + 1);
|
|
934
1099
|
}
|
|
935
|
-
function buildTranscript(s, title, rel) {
|
|
1100
|
+
function buildTranscript(s, title, rel, redactor) {
|
|
1101
|
+
const r = (x) => redactor.apply(x);
|
|
936
1102
|
const lines = [`# Condensed transcript: ${title}`, "", `Source: ${TOOL_LABEL[s.ref.tool]} session \`${s.ref.id}\``, ""];
|
|
937
1103
|
for (const t of s.timeline) {
|
|
938
|
-
if (t.kind === "user")
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1104
|
+
if (t.kind === "user") {
|
|
1105
|
+
const { handoff, rest } = splitHandoffPrompt(t.text);
|
|
1106
|
+
if (handoff) lines.push("**Harlay handoff prompt** (from the previous agent; context is in the brief)", "");
|
|
1107
|
+
if (rest) lines.push("**User:**", r(rest), "");
|
|
1108
|
+
} else if (t.kind === "assistant") {
|
|
1109
|
+
const text = r(t.text);
|
|
1110
|
+
lines.push("**Agent:**", text.length > 600 ? text.slice(0, 599) + "\u2026" : text, "");
|
|
1111
|
+
} else if (t.kind === "command") lines.push(`\`$ ${oneLine(r(t.run.cmd), 200)}\`${t.run.exitCode === null ? "" : ` \u2192 exit ${t.run.exitCode}`}`, "");
|
|
1112
|
+
else lines.push(`Edited \`${r(rel(t.edit.path))}\` (${t.edit.kind})`, "");
|
|
942
1113
|
}
|
|
943
1114
|
const out = lines.join("\n");
|
|
944
|
-
|
|
1115
|
+
if (out.length <= TRANSCRIPT_MAX) return out;
|
|
1116
|
+
const cut = out.slice(out.length - TRANSCRIPT_MAX);
|
|
1117
|
+
const nl = cut.indexOf("\n");
|
|
1118
|
+
return "\u2026(earlier transcript truncated)\n" + (nl === -1 ? cut : cut.slice(nl + 1));
|
|
945
1119
|
}
|
|
946
1120
|
function extractHandoff(s, git2, o) {
|
|
947
1121
|
const cwd = s.ref.cwd;
|
|
@@ -952,39 +1126,45 @@ function extractHandoff(s, git2, o) {
|
|
|
952
1126
|
const r = relative3(workdir, p);
|
|
953
1127
|
return r.startsWith("..") || isAbsolute(r) ? p : r;
|
|
954
1128
|
};
|
|
955
|
-
const
|
|
956
|
-
const prompts =
|
|
1129
|
+
const split = s.timeline.flatMap((t) => t.kind === "user" ? [splitHandoffPrompt(t.text)] : []);
|
|
1130
|
+
const prompts = split.flatMap((p) => p.rest ? [p.rest] : []);
|
|
957
1131
|
const agentTexts = s.timeline.flatMap((t) => t.kind === "assistant" ? [t.text] : []);
|
|
958
1132
|
const cmds = s.timeline.flatMap((t) => t.kind === "command" ? [t.run] : []);
|
|
959
1133
|
const edits = s.timeline.flatMap((t) => t.kind === "edit" ? [t.edit] : []);
|
|
960
|
-
const
|
|
961
|
-
const
|
|
962
|
-
const
|
|
963
|
-
const
|
|
1134
|
+
const redactor = new Redactor();
|
|
1135
|
+
const realPrompts = prompts.filter((p) => !isSlashCommand(p)).map((p) => redactor.apply(p));
|
|
1136
|
+
const prev = o.previous;
|
|
1137
|
+
const fromPrev = prev ? split.some((p) => p.handoff && (p.id === null || p.id === prev.id)) : false;
|
|
1138
|
+
const carried = prev && prev.goal !== null && fromPrev ? prev : null;
|
|
1139
|
+
const carriedRecent = carried ? carried.recentUserMessages.map((m) => redactor.apply(m)) : [];
|
|
1140
|
+
const recentUserMessages = pickRecentMessages([...carriedRecent, ...realPrompts]);
|
|
1141
|
+
let { rules: pinnedRules, more: moreRules } = findPinnedRules(realPrompts, recentUserMessages);
|
|
1142
|
+
if (carried) {
|
|
1143
|
+
const older = [...carried.pinnedRules.map((r) => ({ ...r, text: redactor.apply(r.text) })), ...findPinnedRules(carriedRecent, []).rules];
|
|
1144
|
+
const merged = mergePinnedRules(older, pinnedRules, recentUserMessages);
|
|
1145
|
+
moreRules += merged.more + carried.moreRules;
|
|
1146
|
+
pinnedRules = merged.rules;
|
|
1147
|
+
}
|
|
1148
|
+
const originalGoal = carried ? carried.goal === null ? null : redactor.apply(carried.goal) : realPrompts.find(isSubstantive) ?? realPrompts[0] ?? null;
|
|
964
1149
|
const todos = (s.todos ?? []).filter((t) => t.status !== "cancelled");
|
|
965
|
-
const nextTodo = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
|
|
966
1150
|
const lastAgent = agentTexts.length ? agentTexts[agentTexts.length - 1] : null;
|
|
967
|
-
const
|
|
968
|
-
const
|
|
969
|
-
const
|
|
970
|
-
const
|
|
971
|
-
|
|
1151
|
+
const whereItStopped = lastAgent === null ? null : clipWords(redactor.apply(lastAgent), 150);
|
|
1152
|
+
const openTodos = todos.flatMap((t) => t.status === "in_progress" || t.status === "pending" ? [{ text: redactor.apply(t.text).replace(/\s+/g, " ").trim(), status: t.status }] : []).slice(0, 8);
|
|
1153
|
+
const failure = findLastFailure(cmds);
|
|
1154
|
+
const lastFailure = failure ? { ...failure, cmd: redactor.apply(failure.cmd), output: redactor.apply(failure.output) } : null;
|
|
1155
|
+
const filesTouched = [];
|
|
1156
|
+
for (const e of [...edits].reverse()) {
|
|
972
1157
|
const r = rel(e.path);
|
|
973
|
-
if (!
|
|
974
|
-
|
|
1158
|
+
if (!isAbsolute(r) && !filesTouched.includes(r)) filesTouched.push(r);
|
|
1159
|
+
if (filesTouched.length === 12) break;
|
|
975
1160
|
}
|
|
976
|
-
const
|
|
977
|
-
const progress = todos.length ? todos.map((t) => ({ text: t.text, done: t.status === "completed" })) : editedFiles.map((f) => ({ text: `\`${f}\``, done: true }));
|
|
978
|
-
const progressSource = todos.length ? "todos" : editedFiles.length ? "edits" : "none";
|
|
979
|
-
const redactor = new Redactor();
|
|
980
|
-
const title = carried ? carried.title : s.ref.title ?? (goal ? oneLine(redactor.apply(goal), 60) : "Untitled task");
|
|
1161
|
+
const title = carried ? carried.title : s.ref.title ?? (originalGoal ? oneLine(redactor.apply(originalGoal), 60) : "Untitled task");
|
|
981
1162
|
const pad = (n2) => String(n2).padStart(2, "0");
|
|
982
1163
|
const n = o.now;
|
|
983
1164
|
const baseId = `${pad(n.getMonth() + 1)}${pad(n.getDate())}-${pad(n.getHours())}${pad(n.getMinutes())}-${slugify(redactor.apply(title))}`;
|
|
984
1165
|
return {
|
|
985
1166
|
baseId,
|
|
986
1167
|
title,
|
|
987
|
-
status: "in_progress",
|
|
988
1168
|
createdAt: o.now,
|
|
989
1169
|
from: {
|
|
990
1170
|
tool: s.ref.tool,
|
|
@@ -996,142 +1176,155 @@ function extractHandoff(s, git2, o) {
|
|
|
996
1176
|
},
|
|
997
1177
|
workdir,
|
|
998
1178
|
git: git2,
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1179
|
+
originalGoal,
|
|
1180
|
+
recentUserMessages,
|
|
1181
|
+
pinnedRules,
|
|
1182
|
+
moreRules,
|
|
1183
|
+
whereItStopped,
|
|
1184
|
+
todos: openTodos,
|
|
1185
|
+
filesTouched,
|
|
1186
|
+
lastFailure,
|
|
1187
|
+
transcript: buildTranscript(s, title, rel, redactor),
|
|
1188
|
+
unknownRecords: s.unknownRecords,
|
|
1189
|
+
redactions: { ...redactor.counts },
|
|
1190
|
+
carriedFrom: carried ? carried.id : null
|
|
1011
1191
|
};
|
|
1012
1192
|
}
|
|
1013
1193
|
|
|
1014
|
-
// ../core/src/brief/
|
|
1015
|
-
|
|
1194
|
+
// ../core/src/brief/write.ts
|
|
1195
|
+
import { access, appendFile, mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
|
|
1196
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join6 } from "node:path";
|
|
1197
|
+
|
|
1198
|
+
// ../core/src/brief/prompt.ts
|
|
1199
|
+
var TIER_MAX = { full: 6e3, compact: 3e3 };
|
|
1016
1200
|
var REASON_TEXT = {
|
|
1017
1201
|
usage_limit: "it hit a usage limit",
|
|
1018
1202
|
crash: "it stopped with an error",
|
|
1019
1203
|
manual: "the user handed it off"
|
|
1020
1204
|
};
|
|
1021
|
-
var
|
|
1022
|
-
function
|
|
1023
|
-
return s.
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1205
|
+
var OUR_TAGS = /<(\/?)(handoff|original_goal|rules_from_user|recent_user_messages|where_it_stopped|files|last_failure)\b/gi;
|
|
1206
|
+
function safe(s) {
|
|
1207
|
+
return s.replace(OUR_TAGS, "\u2039$1$2");
|
|
1208
|
+
}
|
|
1209
|
+
var attr = (s) => safe(s).replace(/"/g, "'").replace(/\s+/g, " ").trim();
|
|
1210
|
+
var norm3 = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1211
|
+
function goalIsRecent(goal, recent) {
|
|
1212
|
+
const g = norm3(goal.replace(/\s…$/, ""));
|
|
1213
|
+
return recent.some((m) => norm3(m).startsWith(g));
|
|
1214
|
+
}
|
|
1215
|
+
function renderPrompt(rec, tier) {
|
|
1216
|
+
const full = tier === "full";
|
|
1217
|
+
const from = TOOL_LABEL[rec.from.tool];
|
|
1218
|
+
const dir = `.harlay/handoffs/${rec.id}`;
|
|
1219
|
+
const out = [
|
|
1220
|
+
`<handoff from="${from}" reason="${rec.from.reason}" task="${attr(rec.task.title)}">`,
|
|
1221
|
+
`You are taking over a coding session that ${from} could not finish (${REASON_TEXT[rec.from.reason]}). What you need to start is below; don't re-read the repo to rebuild it.`
|
|
1222
|
+
];
|
|
1223
|
+
const goal = rec.task.originalGoal;
|
|
1224
|
+
const noRecent = rec.task.recentUserMessages.length === 0;
|
|
1225
|
+
if (goal && (full ? !goalIsRecent(goal, rec.task.recentUserMessages) : noRecent)) out.push("", "<original_goal>", safe(goal), "</original_goal>");
|
|
1226
|
+
if (rec.task.pinnedRules.length) {
|
|
1227
|
+
out.push("", "<rules_from_user>", ...rec.task.pinnedRules.map((r) => `- ${safe(r.text)}`));
|
|
1228
|
+
if (rec.task.moreRules) out.push(`(${rec.task.moreRules} more in the transcript)`);
|
|
1229
|
+
out.push("</rules_from_user>");
|
|
1230
|
+
}
|
|
1231
|
+
const recent = full ? rec.task.recentUserMessages : rec.task.recentUserMessages.slice(-2);
|
|
1232
|
+
if (recent.length) out.push("", '<recent_user_messages oldest_first="true">', ...recent.map((m) => `- ${safe(m)}`), "</recent_user_messages>");
|
|
1233
|
+
const where = rec.state.whereItStopped === null ? null : full ? rec.state.whereItStopped : clipWords(rec.state.whereItStopped, 60);
|
|
1234
|
+
const todos = rec.state.todos.map((t) => `[${t.status === "in_progress" ? "in progress" : "pending"}] ${safe(t.text)}`);
|
|
1235
|
+
if (where || todos.length) {
|
|
1236
|
+
out.push("", "<where_it_stopped>");
|
|
1237
|
+
if (where) out.push(safe(where));
|
|
1238
|
+
if (todos.length) out.push(`Open todos: ${todos.join("; ")}`);
|
|
1239
|
+
out.push("</where_it_stopped>");
|
|
1240
|
+
}
|
|
1241
|
+
if (full && (rec.files.touched.length || rec.files.uncommitted.length)) {
|
|
1242
|
+
out.push("", "<files>");
|
|
1243
|
+
if (rec.files.touched.length) out.push(`Touched: ${rec.files.touched.map(safe).join(", ")}`);
|
|
1244
|
+
if (rec.files.uncommitted.length) {
|
|
1245
|
+
const list = rec.files.uncommitted.map((f) => `${safe(f.path)} (${f.status}${f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : ""})`);
|
|
1246
|
+
out.push(`Uncommitted: ${list.join(", ")}${rec.files.moreUncommitted ? `, and ${rec.files.moreUncommitted} more` : ""}`);
|
|
1034
1247
|
}
|
|
1035
|
-
out.push(
|
|
1036
|
-
used += line.length + 1;
|
|
1248
|
+
out.push("</files>");
|
|
1037
1249
|
}
|
|
1038
|
-
|
|
1250
|
+
if (rec.lastFailure) {
|
|
1251
|
+
out.push("", "<last_failure>", `$ ${safe(rec.lastFailure.cmd)} (exit ${rec.lastFailure.exitCode})`);
|
|
1252
|
+
if (full) out.push(safe(rec.lastFailure.outputTail));
|
|
1253
|
+
out.push("</last_failure>");
|
|
1254
|
+
}
|
|
1255
|
+
const more = [`${dir}/transcript.md (search it, don't read it all)`];
|
|
1256
|
+
if (rec.details.diff) more.push(`${dir}/diff.patch`);
|
|
1257
|
+
const next = noRecent ? "Continue from where it stopped." : "Continue from the most recent user message.";
|
|
1258
|
+
out.push("", `Only if you need more: ${more.join(" and ")}.`, "</handoff>", "", `${next} Start with the next concrete step.`);
|
|
1259
|
+
return wellFormed(out.join("\n"));
|
|
1039
1260
|
}
|
|
1040
|
-
function
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
const cut = t.slice(t.length - max);
|
|
1045
|
-
const nl = cut.indexOf("\n");
|
|
1046
|
-
return "\u2026\n" + (nl >= 0 ? cut.slice(nl + 1) : cut);
|
|
1047
|
-
}
|
|
1048
|
-
function fenceFor(body) {
|
|
1049
|
-
const longest = Math.max(0, ...(body.match(/`+/g) ?? []).map((r) => r.length));
|
|
1050
|
-
return "`".repeat(Math.max(3, longest + 1));
|
|
1051
|
-
}
|
|
1052
|
-
function fileLine(f) {
|
|
1053
|
-
const counts = f.added !== null && f.removed !== null ? `, +${f.added} -${f.removed}` : "";
|
|
1054
|
-
return `- \`${f.path}\` (${f.status}${counts})`;
|
|
1055
|
-
}
|
|
1056
|
-
function renderBrief(d, id, opts = {}) {
|
|
1057
|
-
const dir = `.harlay/handoffs/${id}`;
|
|
1058
|
-
const transcript = `${dir}/transcript.md`;
|
|
1059
|
-
const front = [
|
|
1060
|
-
"---",
|
|
1061
|
-
"harlay: 1",
|
|
1062
|
-
`id: ${j(id)}`,
|
|
1063
|
-
`status: ${j(d.status)}`,
|
|
1064
|
-
`created: ${j(d.createdAt.toISOString())}`,
|
|
1065
|
-
"from:",
|
|
1066
|
-
` tool: ${j(d.from.tool)}`,
|
|
1067
|
-
` model: ${j(d.from.model)}`,
|
|
1068
|
-
` session: ${j(d.from.session)}`,
|
|
1069
|
-
` reason: ${j(d.from.reason)}`,
|
|
1070
|
-
` resets_at: ${j(d.from.resetsAt ? d.from.resetsAt.toISOString() : null)}`,
|
|
1071
|
-
"repo:",
|
|
1072
|
-
` path: ${j(d.workdir)}`,
|
|
1073
|
-
...d.git ? [` branch: ${j(d.git.branch)}`, ` base_commit: ${j(d.git.head)}`, ` dirty_files: ${d.git.files.length}`, ` diff_sha: ${j(d.git.diffSha.slice(0, 12))}`] : [" git: false"],
|
|
1074
|
-
"---"
|
|
1075
|
-
];
|
|
1076
|
-
const header = [
|
|
1077
|
-
`# Continue: ${d.title}`,
|
|
1078
|
-
"",
|
|
1079
|
-
`> You are continuing a coding task that another AI agent (${TOOL_LABEL[d.from.tool]}) started but could not finish (${REASON_TEXT[d.from.reason]}).`,
|
|
1080
|
-
"> This brief was extracted from its session log and the repo.",
|
|
1081
|
-
d.git ? '> First run `git status` and check it matches "Changed files". Do not redo finished work.' : "> Check the files listed below before editing. Do not redo finished work."
|
|
1082
|
-
];
|
|
1083
|
-
const goal = ["## 1. Goal (user's words)", d.goal ? quote(clip(d.goal, BUDGET.goal, transcript)) : "Not recorded. See the transcript."];
|
|
1084
|
-
if (d.latestInstruction) goal.push("", "Latest instruction:", quote(clip(d.latestInstruction, BUDGET.latest, transcript)));
|
|
1085
|
-
const next = ["## 2. Next step"];
|
|
1086
|
-
if (!d.nextStep) next.push("Not recorded. Re-read the goal and the changed files, then decide.");
|
|
1087
|
-
else if (d.nextStep.source === "todo") next.push(clip(oneLine(d.nextStep.text, 200), BUDGET.next));
|
|
1088
|
-
else next.push("From the agent's last message:", quote(clip(d.nextStep.text, BUDGET.next)));
|
|
1089
|
-
const blocker = ["## 3. Blocker"];
|
|
1090
|
-
if (d.blocker) {
|
|
1091
|
-
const body = tailWithin(d.blocker.output, BUDGET.blocker);
|
|
1092
|
-
const fence = fenceFor(body);
|
|
1093
|
-
blocker.push(`\`${oneLine(d.blocker.cmd, 200)}\` failed (exit ${d.blocker.exitCode}):`, `${fence}text`, body, fence, `Full output: \`${dir}/last-error.txt\``);
|
|
1094
|
-
} else {
|
|
1095
|
-
blocker.push("No failing command recorded.");
|
|
1096
|
-
}
|
|
1097
|
-
const progress = ["## 4. Progress"];
|
|
1098
|
-
if (d.progressSource === "none") progress.push("Nothing recorded yet.");
|
|
1099
|
-
else {
|
|
1100
|
-
if (d.progressSource === "edits") progress.push("No task list was recorded. Files the agent edited:");
|
|
1101
|
-
progress.push(...listWithin(d.progress.map((p) => `- [${p.done ? "x" : " "}] ${oneLine(p.text, 200)}`), BUDGET.progress, transcript));
|
|
1261
|
+
function choosePrompt(rec, fits) {
|
|
1262
|
+
for (const tier of ["full", "compact"]) {
|
|
1263
|
+
const text = renderPrompt(rec, tier);
|
|
1264
|
+
if (text.length <= TIER_MAX[tier] && fits(text)) return { tier, text };
|
|
1102
1265
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1266
|
+
return { tier: "pointer", text: HANDOFF_PROMPT };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ../core/src/brief/record.ts
|
|
1270
|
+
var RECORD_SCHEMA = "harlay/handoff@2";
|
|
1271
|
+
var MAX_UNCOMMITTED = 15;
|
|
1272
|
+
function buildRecord(d, id, previousId, r) {
|
|
1273
|
+
const a = (s) => r.apply(stripControl(s));
|
|
1274
|
+
const files = d.git?.files ?? [];
|
|
1275
|
+
return {
|
|
1276
|
+
schema: RECORD_SCHEMA,
|
|
1277
|
+
id,
|
|
1278
|
+
createdAt: d.createdAt.toISOString(),
|
|
1279
|
+
from: {
|
|
1280
|
+
tool: d.from.tool,
|
|
1281
|
+
model: d.from.model,
|
|
1282
|
+
session: d.from.session,
|
|
1283
|
+
reason: d.from.reason,
|
|
1284
|
+
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null,
|
|
1285
|
+
message: d.from.message === null ? null : a(d.from.message)
|
|
1286
|
+
},
|
|
1287
|
+
repo: { root: d.workdir, git: d.git !== null, branch: d.git?.branch ?? null, head: d.git?.head ?? null, dirtyFiles: files.length },
|
|
1288
|
+
task: {
|
|
1289
|
+
title: a(d.title),
|
|
1290
|
+
originalGoal: d.originalGoal === null ? null : clipWords(a(d.originalGoal), 60),
|
|
1291
|
+
recentUserMessages: d.recentUserMessages.map(a),
|
|
1292
|
+
pinnedRules: d.pinnedRules.map((p) => ({ text: a(p.text), message: p.message })),
|
|
1293
|
+
moreRules: d.moreRules
|
|
1294
|
+
},
|
|
1295
|
+
state: {
|
|
1296
|
+
whereItStopped: d.whereItStopped === null ? null : a(d.whereItStopped),
|
|
1297
|
+
todos: d.todos.map((t) => ({ text: oneLine(a(t.text), 200), status: t.status }))
|
|
1298
|
+
},
|
|
1299
|
+
files: {
|
|
1300
|
+
touched: d.filesTouched.map(a),
|
|
1301
|
+
uncommitted: files.slice(0, MAX_UNCOMMITTED).map((f) => ({ path: a(f.path), status: f.status, added: f.added, removed: f.removed })),
|
|
1302
|
+
moreUncommitted: Math.max(0, files.length - MAX_UNCOMMITTED)
|
|
1303
|
+
},
|
|
1304
|
+
lastFailure: d.lastFailure ? { cmd: clipWords(a(d.lastFailure.cmd), 30), exitCode: d.lastFailure.exitCode, outputTail: a(d.lastFailure.output).split("\n").slice(-8).join("\n") } : null,
|
|
1305
|
+
details: { transcript: "transcript.md", diff: d.git ? "diff.patch" : null, lastError: d.lastFailure ? "last-error.txt" : null },
|
|
1306
|
+
previous: previousId ? { id: previousId } : null,
|
|
1307
|
+
unknownRecords: d.unknownRecords
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
var isStr = (v) => typeof v === "string";
|
|
1311
|
+
var isObj2 = (v) => typeof v === "object" && v !== null;
|
|
1312
|
+
function validateRecord(raw) {
|
|
1313
|
+
if (!isObj2(raw) || raw.schema !== RECORD_SCHEMA || !isStr(raw.id) || !isStr(raw.createdAt)) return null;
|
|
1314
|
+
const { task, from } = raw;
|
|
1315
|
+
if (!isObj2(from) || !isTool(from.tool)) return null;
|
|
1316
|
+
if (!isObj2(task) || !isStr(task.title) || !(task.originalGoal === null || isStr(task.originalGoal))) return null;
|
|
1317
|
+
if (!Array.isArray(task.recentUserMessages) || !task.recentUserMessages.every(isStr)) return null;
|
|
1318
|
+
if (!Array.isArray(task.pinnedRules) || !task.pinnedRules.every((r) => isObj2(r) && isStr(r.text) && typeof r.message === "number")) return null;
|
|
1319
|
+
return raw;
|
|
1125
1320
|
}
|
|
1126
1321
|
|
|
1127
1322
|
// ../core/src/brief/write.ts
|
|
1128
|
-
import { access, appendFile, mkdir, readFile as readFile4, writeFile } from "node:fs/promises";
|
|
1129
|
-
import { dirname, isAbsolute as isAbsolute2, join as join5 } from "node:path";
|
|
1130
1323
|
async function allocateHandoffId(handoffsDir, baseId) {
|
|
1131
1324
|
for (let i = 1; i < 1e3; i++) {
|
|
1132
1325
|
const id = i === 1 ? baseId : `${baseId}-${i}`;
|
|
1133
1326
|
try {
|
|
1134
|
-
await access(
|
|
1327
|
+
await access(join6(handoffsDir, id));
|
|
1135
1328
|
} catch {
|
|
1136
1329
|
return id;
|
|
1137
1330
|
}
|
|
@@ -1140,7 +1333,7 @@ async function allocateHandoffId(handoffsDir, baseId) {
|
|
|
1140
1333
|
}
|
|
1141
1334
|
async function ensureGitExclude(workdir) {
|
|
1142
1335
|
const p = (await git(workdir, ["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
1143
|
-
const file = isAbsolute2(p) ? p :
|
|
1336
|
+
const file = isAbsolute2(p) ? p : join6(workdir, p);
|
|
1144
1337
|
let current = "";
|
|
1145
1338
|
try {
|
|
1146
1339
|
current = await readFile4(file, "utf8");
|
|
@@ -1148,14 +1341,14 @@ async function ensureGitExclude(workdir) {
|
|
|
1148
1341
|
current = "";
|
|
1149
1342
|
}
|
|
1150
1343
|
if (current.split("\n").some((l) => l.trim() === ".harlay/" || l.trim() === ".harlay")) return;
|
|
1151
|
-
await
|
|
1344
|
+
await mkdir2(dirname2(file), { recursive: true });
|
|
1152
1345
|
await appendFile(file, `${current && !current.endsWith("\n") ? "\n" : ""}.harlay/
|
|
1153
1346
|
`);
|
|
1154
1347
|
}
|
|
1155
1348
|
async function readHandoffState(workdir) {
|
|
1156
1349
|
let raw;
|
|
1157
1350
|
try {
|
|
1158
|
-
raw = JSON.parse(await readFile4(
|
|
1351
|
+
raw = JSON.parse(await readFile4(join6(workdir, ".harlay", "state.json"), "utf8"));
|
|
1159
1352
|
} catch {
|
|
1160
1353
|
return null;
|
|
1161
1354
|
}
|
|
@@ -1172,113 +1365,56 @@ async function readIfExists(file) {
|
|
|
1172
1365
|
return null;
|
|
1173
1366
|
}
|
|
1174
1367
|
}
|
|
1175
|
-
async function
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
if (previous !== null) await writeFile(join5(dir, "previous-HANDOFF.md"), r.apply(previous));
|
|
1184
|
-
const brief = r.apply(renderBrief(d, id, { previousBrief: previous !== null }));
|
|
1185
|
-
const briefPath = join5(dir, "HANDOFF.md");
|
|
1186
|
-
await writeFile(briefPath, brief);
|
|
1187
|
-
await writeFile(join5(dir, "transcript.md"), r.apply(d.transcript));
|
|
1188
|
-
if (d.git) await writeFile(join5(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1189
|
-
if (d.blocker) await writeFile(join5(dir, "last-error.txt"), r.apply(d.blocker.output));
|
|
1190
|
-
const from = {
|
|
1191
|
-
...d.from,
|
|
1192
|
-
message: d.from.message === null ? null : r.apply(d.from.message),
|
|
1193
|
-
resetsAt: d.from.resetsAt ? d.from.resetsAt.toISOString() : null
|
|
1194
|
-
};
|
|
1195
|
-
const goal = d.goal === null ? null : r.apply(d.goal);
|
|
1196
|
-
const title = r.apply(d.title);
|
|
1197
|
-
const meta = {
|
|
1198
|
-
harlay: 1,
|
|
1199
|
-
id,
|
|
1200
|
-
createdAt: d.createdAt.toISOString(),
|
|
1201
|
-
title,
|
|
1202
|
-
goal,
|
|
1203
|
-
from,
|
|
1204
|
-
workdir: d.workdir,
|
|
1205
|
-
unknownRecords: d.unknownRecords,
|
|
1206
|
-
git: d.git ? { branch: d.git.branch, head: d.git.head, files: d.git.files.length, diffSha: d.git.diffSha } : null,
|
|
1207
|
-
redactions: { ...r.counts }
|
|
1208
|
-
};
|
|
1209
|
-
await writeFile(join5(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
1210
|
-
await writeFile(latestPath, brief);
|
|
1211
|
-
const state = { harlay: 1, lastId: id, goal, title };
|
|
1212
|
-
await writeFile(join5(harlayDir, "state.json"), JSON.stringify(state, null, 2) + "\n");
|
|
1213
|
-
if (d.git) await ensureGitExclude(d.workdir);
|
|
1214
|
-
return { id, dir, briefPath, latestPath, redactions: { ...r.counts } };
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// ../core/src/launch/launchers.ts
|
|
1218
|
-
import { execFile as execFile2 } from "node:child_process";
|
|
1219
|
-
import { chmod, mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
1220
|
-
import { dirname as dirname2, join as join6 } from "node:path";
|
|
1221
|
-
import { promisify as promisify2 } from "node:util";
|
|
1222
|
-
var pexec2 = promisify2(execFile2);
|
|
1223
|
-
var HANDOFF_PROMPT = "Read .harlay/HANDOFF.md and continue the task.";
|
|
1224
|
-
var DEFAULT_MODES = { "claude-code": "app", codex: "app", cursor: "app" };
|
|
1225
|
-
var defaultRunner = {
|
|
1226
|
-
async open(args) {
|
|
1227
|
-
await pexec2("/usr/bin/open", args);
|
|
1228
|
-
},
|
|
1229
|
-
async writeExecutable(path, content) {
|
|
1230
|
-
await mkdir2(dirname2(path), { recursive: true });
|
|
1231
|
-
await writeFile2(path, content, { mode: 493 });
|
|
1232
|
-
await chmod(path, 493);
|
|
1233
|
-
},
|
|
1234
|
-
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
|
|
1235
|
-
};
|
|
1236
|
-
function shq(s) {
|
|
1237
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1238
|
-
}
|
|
1239
|
-
var enc = encodeURIComponent;
|
|
1240
|
-
function planLaunch(o) {
|
|
1241
|
-
if (o.mode === "app") {
|
|
1242
|
-
switch (o.target) {
|
|
1243
|
-
case "claude-code":
|
|
1244
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`claude://code/new?folder=${enc(o.cwd)}&q=${enc(o.prompt)}`] }] };
|
|
1245
|
-
case "codex":
|
|
1246
|
-
return { target: o.target, mode: o.mode, steps: [{ kind: "open", args: [`codex://threads/new?path=${enc(o.cwd)}&prompt=${enc(o.prompt)}`] }] };
|
|
1247
|
-
case "cursor":
|
|
1248
|
-
return {
|
|
1249
|
-
target: o.target,
|
|
1250
|
-
mode: o.mode,
|
|
1251
|
-
steps: [
|
|
1252
|
-
{ kind: "open", args: ["-a", "Cursor", o.cwd] },
|
|
1253
|
-
{ kind: "wait", ms: 1500 },
|
|
1254
|
-
{ kind: "open", args: [`cursor://anysphere.cursor-deeplink/prompt?text=${enc(o.prompt)}`] }
|
|
1255
|
-
]
|
|
1256
|
-
};
|
|
1368
|
+
async function readPreviousHandoff(workdir) {
|
|
1369
|
+
const raw = await readIfExists(join6(workdir, ".harlay", "handoff.json"));
|
|
1370
|
+
if (raw !== null) {
|
|
1371
|
+
let rec;
|
|
1372
|
+
try {
|
|
1373
|
+
rec = validateRecord(JSON.parse(raw));
|
|
1374
|
+
} catch {
|
|
1375
|
+
return null;
|
|
1257
1376
|
}
|
|
1377
|
+
if (!rec) return null;
|
|
1378
|
+
const { task } = rec;
|
|
1379
|
+
return {
|
|
1380
|
+
id: rec.id,
|
|
1381
|
+
goal: task.originalGoal,
|
|
1382
|
+
title: task.title,
|
|
1383
|
+
recentUserMessages: task.recentUserMessages,
|
|
1384
|
+
pinnedRules: task.pinnedRules,
|
|
1385
|
+
moreRules: typeof task.moreRules === "number" ? task.moreRules : 0
|
|
1386
|
+
};
|
|
1258
1387
|
}
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
const argv = o.target === "codex" ? [bin, "-C", o.cwd, o.prompt] : o.target === "cursor" ? [bin, "--workspace", o.cwd, o.prompt] : [bin, o.prompt];
|
|
1262
|
-
const path = join6(o.scriptDir, `continue-${o.target}.command`);
|
|
1263
|
-
const content = `#!/bin/zsh -l
|
|
1264
|
-
cd -- ${shq(o.cwd)} || exit 1
|
|
1265
|
-
exec ${argv.map(shq).join(" ")}
|
|
1266
|
-
`;
|
|
1267
|
-
return {
|
|
1268
|
-
target: o.target,
|
|
1269
|
-
mode: o.mode,
|
|
1270
|
-
steps: [
|
|
1271
|
-
{ kind: "script", path, content },
|
|
1272
|
-
{ kind: "open", args: ["-a", o.terminalApp, path] }
|
|
1273
|
-
]
|
|
1274
|
-
};
|
|
1388
|
+
const state = await readHandoffState(workdir);
|
|
1389
|
+
return state ? { id: state.lastId, goal: state.goal, title: state.title, recentUserMessages: [], pinnedRules: [], moreRules: 0 } : null;
|
|
1275
1390
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1391
|
+
function addCounts(a, b) {
|
|
1392
|
+
const out = { ...a };
|
|
1393
|
+
for (const [k, n] of Object.entries(b)) out[k] = (out[k] ?? 0) + n;
|
|
1394
|
+
return out;
|
|
1395
|
+
}
|
|
1396
|
+
async function writeHandoff(d, o) {
|
|
1397
|
+
const harlayDir = join6(d.workdir, ".harlay");
|
|
1398
|
+
const latestPath = join6(harlayDir, "HANDOFF.md");
|
|
1399
|
+
const id = await allocateHandoffId(join6(harlayDir, "handoffs"), d.baseId);
|
|
1400
|
+
const dir = join6(harlayDir, "handoffs", id);
|
|
1401
|
+
await mkdir2(dir, { recursive: true });
|
|
1402
|
+
const r = new Redactor();
|
|
1403
|
+
const body = buildRecord(d, id, o.previousId, r);
|
|
1404
|
+
const chosen = choosePrompt(body, (p) => promptFits(o.target, o.mode, p));
|
|
1405
|
+
const record = { ...body, prompt: { target: o.target, tier: chosen.tier, chars: chosen.text.length } };
|
|
1406
|
+
const json = JSON.stringify(record, null, 2) + "\n";
|
|
1407
|
+
const recordPath = join6(dir, "handoff.json");
|
|
1408
|
+
await writeFile2(recordPath, json);
|
|
1409
|
+
await writeFile2(join6(dir, "prompt.md"), chosen.text + "\n");
|
|
1410
|
+
await writeFile2(join6(dir, "transcript.md"), r.apply(stripControl(d.transcript)));
|
|
1411
|
+
if (d.git) await writeFile2(join6(dir, "diff.patch"), r.apply(d.git.diff));
|
|
1412
|
+
if (d.lastFailure) await writeFile2(join6(dir, "last-error.txt"), r.apply(stripControl(d.lastFailure.output)));
|
|
1413
|
+
await writeFile2(latestPath, renderPrompt(body, "full") + "\n");
|
|
1414
|
+
await writeFile2(join6(harlayDir, "handoff.json"), json);
|
|
1415
|
+
if (d.git) await ensureGitExclude(d.workdir);
|
|
1416
|
+
const redactions = addCounts(d.redactions ?? {}, r.counts);
|
|
1417
|
+
return { id, dir, recordPath, latestPath, prompt: chosen.text, tier: chosen.tier, redactions };
|
|
1282
1418
|
}
|
|
1283
1419
|
|
|
1284
1420
|
// ../core/src/launch/resolve.ts
|
|
@@ -1404,10 +1540,9 @@ async function runHandoff(o) {
|
|
|
1404
1540
|
}
|
|
1405
1541
|
const session = await readerFor(o.readers, o.session.tool).readSession(o.session);
|
|
1406
1542
|
const git2 = await takeGitSnapshot(cwd);
|
|
1407
|
-
const
|
|
1408
|
-
const previous = state ? { id: state.lastId, goal: state.goal, title: state.title } : null;
|
|
1543
|
+
const previous = await readPreviousHandoff(git2?.root ?? cwd);
|
|
1409
1544
|
const data = extractHandoff(session, git2, { now: o.now ?? /* @__PURE__ */ new Date(), reason: o.reason, previous });
|
|
1410
|
-
const written = await writeHandoff(data);
|
|
1545
|
+
const written = await writeHandoff(data, { target: o.target, mode: o.mode, previousId: data.carriedFrom });
|
|
1411
1546
|
if (o.launch === false) return { written, plan: null, data };
|
|
1412
1547
|
let plan;
|
|
1413
1548
|
try {
|
|
@@ -1416,7 +1551,7 @@ async function runHandoff(o) {
|
|
|
1416
1551
|
target: o.target,
|
|
1417
1552
|
mode: o.mode,
|
|
1418
1553
|
cwd: data.workdir,
|
|
1419
|
-
prompt:
|
|
1554
|
+
prompt: written.prompt,
|
|
1420
1555
|
scriptDir: written.dir,
|
|
1421
1556
|
binaries,
|
|
1422
1557
|
terminalApp: o.terminalApp ?? "Terminal"
|
|
@@ -1628,11 +1763,11 @@ function claudeSettingsPath(home) {
|
|
|
1628
1763
|
}
|
|
1629
1764
|
var previousJson = (dataDir) => join9(dataDir, "claude-statusline.previous.json");
|
|
1630
1765
|
var previousCommand = (dataDir) => join9(dataDir, "claude-statusline.previous");
|
|
1631
|
-
function
|
|
1766
|
+
function isObj3(x) {
|
|
1632
1767
|
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
1633
1768
|
}
|
|
1634
1769
|
function isOurs(statusLine) {
|
|
1635
|
-
return
|
|
1770
|
+
return isObj3(statusLine) && typeof statusLine.command === "string" && statusLine.command.includes(BRIDGE_MARKER);
|
|
1636
1771
|
}
|
|
1637
1772
|
async function readSettings(path) {
|
|
1638
1773
|
let text;
|
|
@@ -1643,7 +1778,7 @@ async function readSettings(path) {
|
|
|
1643
1778
|
throw e;
|
|
1644
1779
|
}
|
|
1645
1780
|
const parsed = JSON.parse(text);
|
|
1646
|
-
if (!
|
|
1781
|
+
if (!isObj3(parsed)) throw new Error(`${path} is not a JSON object`);
|
|
1647
1782
|
return parsed;
|
|
1648
1783
|
}
|
|
1649
1784
|
async function writeAtomic(path, text) {
|
|
@@ -1784,7 +1919,7 @@ async function installApp(opts) {
|
|
|
1784
1919
|
}
|
|
1785
1920
|
|
|
1786
1921
|
// src/main.ts
|
|
1787
|
-
var VERSION = "0.1.
|
|
1922
|
+
var VERSION = "0.1.3";
|
|
1788
1923
|
var HELP = `harlay ${VERSION} \u2014 hand off a stalled AI coding session to another agent
|
|
1789
1924
|
|
|
1790
1925
|
Usage:
|
|
@@ -1870,6 +2005,10 @@ async function cmdHandoff(args, readers, ctx) {
|
|
|
1870
2005
|
ctx.out(`Brief: ${res.written.latestPath}
|
|
1871
2006
|
Archived: ${res.written.dir}
|
|
1872
2007
|
`);
|
|
2008
|
+
ctx.out(
|
|
2009
|
+
res.written.tier === "pointer" ? "Prompt: pointer to the brief (too long to send inline)\n" : `Prompt: ${res.written.tier} brief inline (${res.written.prompt.length} chars)
|
|
2010
|
+
`
|
|
2011
|
+
);
|
|
1873
2012
|
const kinds = Object.keys(res.written.redactions);
|
|
1874
2013
|
if (kinds.length) {
|
|
1875
2014
|
const total = Object.values(res.written.redactions).reduce((a, b) => a + b, 0);
|