jeo-code 0.7.30 → 0.7.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/package.json +3 -3
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +12 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/compaction.ts +6 -19
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +103 -18
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/memory-graph.ts +6 -1
- package/src/agent/memory.ts +73 -52
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +100 -5
- package/src/agent/ts-language-service.ts +173 -0
- package/src/ai/model-manager.ts +58 -26
- package/src/ai/sse.ts +22 -7
- package/src/ai/think-tags.ts +10 -1
- package/src/commands/deep-interview.ts +49 -1
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- package/src/commands/launch/tmux.ts +32 -0
- package/src/commands/launch.ts +227 -750
- package/src/tui/app.ts +32 -12
- package/src/tui/components/evolution.ts +23 -3
- package/src/tui/components/width.ts +11 -6
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `subagent` control tool (#9) — the parent's handle on DETACHED subagents launched
|
|
3
3
|
* via `task {detached:true}`. Mirrors gjc's `subagent`/`job` control surface, scoped
|
|
4
|
-
* to an in-process registry: list, inspect, await (optionally bounded), and
|
|
4
|
+
* to an in-process registry: list, inspect, await (optionally bounded), cancel, and
|
|
5
|
+
* steer (live peer messaging into a single running subagent).
|
|
5
6
|
*
|
|
6
|
-
* Out of scope here (separate
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Out of scope here (a separate subsystem, not stubbed): pause/resume — a step-budget
|
|
8
|
+
* loop has no safe mid-step checkpoint to resume from, so that remains intentionally
|
|
9
|
+
* absent rather than faked. Live peer messaging to/among ALL running subagents (not
|
|
10
|
+
* just one target id) is the `irc` tool, built on the same registry.steer().
|
|
9
11
|
*/
|
|
10
12
|
import type { ToolHandler } from "./engine";
|
|
11
13
|
import type { ToolResult } from "./tools";
|
|
@@ -13,9 +15,10 @@ import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
|
|
|
13
15
|
|
|
14
16
|
/** One-line protocol description appended to the launch system prompt. */
|
|
15
17
|
export const SUBAGENT_TOOL_PROTOCOL_LINE =
|
|
16
|
-
`subagent {action:"list"|"inspect"|"await"|"cancel", ids?, timeoutMs?} — control DETACHED ` +
|
|
18
|
+
`subagent {action:"list"|"inspect"|"await"|"cancel"|"steer", ids?, id?, message?, timeoutMs?} — control DETACHED ` +
|
|
17
19
|
`subagents started with task{detached:true}. 'await' blocks (optionally up to timeoutMs ms) and ` +
|
|
18
|
-
`returns their reports; 'inspect' shows status + result; 'cancel' aborts them
|
|
20
|
+
`returns their reports; 'inspect' shows status + result; 'cancel' aborts them; 'steer' sends {id, message} ` +
|
|
21
|
+
`to inject a live instruction into that ONE running subagent, picked up before its next step. Omit ids to target all running (list/inspect/await/cancel).`;
|
|
19
22
|
|
|
20
23
|
function elapsed(rec: SubagentRecord): string {
|
|
21
24
|
const end = rec.finishedAt ?? Date.now();
|
|
@@ -84,6 +87,23 @@ export function createSubagentTool(registry: SubagentRegistry): ToolHandler {
|
|
|
84
87
|
return { success: true, output: `Cancelled ${recs.length} subagent(s):\n${recs.map(rowLine).join("\n")}` };
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
|
|
90
|
+
if (action === "steer") {
|
|
91
|
+
const targetIds = Array.isArray(args.id) ? [] : args.id !== undefined ? [String(args.id)] : [];
|
|
92
|
+
const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
|
|
93
|
+
if (targetIds.length !== 1) {
|
|
94
|
+
return { success: false, output: "", error: `subagent 'steer' requires exactly one target 'id' (a single string, not 'ids'). Got: ${JSON.stringify(args.id)}.` };
|
|
95
|
+
}
|
|
96
|
+
const id = targetIds[0]!;
|
|
97
|
+
if (!message.trim()) {
|
|
98
|
+
return { success: false, output: "", error: "subagent 'steer' requires a non-empty 'message' (or 'text')." };
|
|
99
|
+
}
|
|
100
|
+
const ok = registry.steer(id, message);
|
|
101
|
+
if (!ok) {
|
|
102
|
+
return { success: false, output: "", error: `Cannot steer '${id}': no running subagent with that id.` };
|
|
103
|
+
}
|
|
104
|
+
return { success: true, output: `Steered ${id}: ${message.trim()}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { success: false, output: "", error: `Unknown subagent action '${action}'. Use list | inspect | await | cancel | steer.` };
|
|
88
108
|
};
|
|
89
109
|
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -252,7 +252,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
|
|
|
252
252
|
*/
|
|
253
253
|
export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
|
|
254
254
|
if (role.readOnly) {
|
|
255
|
-
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete"]);
|
|
255
|
+
const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug"]);
|
|
256
256
|
const ro: Record<string, ToolHandler> = {};
|
|
257
257
|
for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
|
|
258
258
|
if (MUTATING.has(name)) continue;
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -78,6 +78,27 @@ const MAX_FANOUT = 4;
|
|
|
78
78
|
* parent turn. Split larger efforts into sequential task calls. */
|
|
79
79
|
const MAX_SERIAL_EXECUTOR = 6;
|
|
80
80
|
|
|
81
|
+
/** Minimum distinct, non-filler words a spawn-gate justification must contain.
|
|
82
|
+
* A bare length check (`.length >= 20`) previously accepted any 20-char string
|
|
83
|
+
* ("xxxxxxxxxxxxxxxxxxxx", "asdf asdf asdf asdf"), which defeated the intent of
|
|
84
|
+
* the gate (force the model to actually justify the parallelism, not just pay a
|
|
85
|
+
* cheap toll). This does not attempt real NLP — it only rejects the trivially
|
|
86
|
+
* degenerate cases a model would use to rubber-stamp past the gate. */
|
|
87
|
+
const JUSTIFICATION_MIN_DISTINCT_WORDS = 4;
|
|
88
|
+
|
|
89
|
+
/** True when `text` reads as an actual justification rather than filler padding
|
|
90
|
+
* used only to clear the ≥20-char length bar. */
|
|
91
|
+
function isMeaningfulJustification(text: string): boolean {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (trimmed.length < 20) return false;
|
|
94
|
+
// Reject strings that are one character (or one repeated short token) padded out —
|
|
95
|
+
// e.g. "xxxxxxxxxxxxxxxxxxxx" or "aaaa aaaa aaaa aaaa".
|
|
96
|
+
if (/^(.)\1*$/.test(trimmed.replace(/\s+/g, ""))) return false;
|
|
97
|
+
const words = trimmed.toLowerCase().match(/[a-z0-9']+/g) ?? [];
|
|
98
|
+
const distinct = new Set(words);
|
|
99
|
+
return distinct.size >= JUSTIFICATION_MIN_DISTINCT_WORDS;
|
|
100
|
+
}
|
|
101
|
+
|
|
81
102
|
/** Broadcast steering hub for a fan-out batch. Each concurrent worker registers
|
|
82
103
|
* ONCE and then sees every parent steer message exactly once (append-only log +
|
|
83
104
|
* per-worker cursor), so a mid-batch redirect reaches all running subagents
|
|
@@ -320,13 +341,13 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
320
341
|
// stays bounded at MAX_FANOUT (read-only) or 1 (mutating) regardless.
|
|
321
342
|
if (items.length > MAX_FANOUT) {
|
|
322
343
|
const justification = typeof args.justification === "string" ? args.justification.trim() : "";
|
|
323
|
-
if (justification
|
|
344
|
+
if (!isMeaningfulJustification(justification)) {
|
|
324
345
|
return {
|
|
325
346
|
success: false,
|
|
326
347
|
output: "",
|
|
327
348
|
error:
|
|
328
349
|
`Fan-out of ${items.length} tasks exceeds the default gate of ${MAX_FANOUT}. ` +
|
|
329
|
-
`Either reduce the batch, or resend with a "justification" string (≥20 chars) explaining why these tasks are independent and must run in one batch.`,
|
|
350
|
+
`Either reduce the batch, or resend with a "justification" string (≥20 chars, ≥${JUSTIFICATION_MIN_DISTINCT_WORDS} distinct words, not filler) explaining why these tasks are independent and must run in one batch.`,
|
|
330
351
|
};
|
|
331
352
|
}
|
|
332
353
|
}
|
|
@@ -371,10 +392,11 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
371
392
|
}
|
|
372
393
|
// Detached form (#9): register a background run and return immediately so the
|
|
373
394
|
// parent can keep working, then list/inspect/await/cancel via the `subagent`
|
|
374
|
-
// tool.
|
|
395
|
+
// tool. Live peer messaging (steer/irc) DOES reach a detached run — it drains
|
|
396
|
+
// its own registry inbox (registry.steerDrainFor(id)) between its own steps.
|
|
375
397
|
if (args.detached === true && opts.registry) {
|
|
376
|
-
const rec = opts.registry.launch(role.id, taskText, signal =>
|
|
377
|
-
runOne(role, taskText, ctx(args.context), cwd, { signal }),
|
|
398
|
+
const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
|
|
399
|
+
runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
|
|
378
400
|
);
|
|
379
401
|
return {
|
|
380
402
|
success: true,
|
package/src/agent/tools.ts
CHANGED
|
@@ -226,7 +226,7 @@ function excerptForRecovery(content: string, centerLine?: number): string {
|
|
|
226
226
|
* Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
|
|
227
227
|
async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
|
|
228
228
|
const snap = lastReadSnapshots.get(absPath);
|
|
229
|
-
if (!snap) return null; // never read → no guard (
|
|
229
|
+
if (!snap) return null; // never read → no guard here (see the narrow blind-replace guard in editTool)
|
|
230
230
|
const st = await fs.stat(absPath).catch(() => null);
|
|
231
231
|
if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
|
|
232
232
|
if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
|
|
@@ -509,6 +509,24 @@ export async function editTool(
|
|
|
509
509
|
error: `Invalid edit range ${startLine}..${endLine}: out of bounds or reversed (file has ${lines.length} lines)`,
|
|
510
510
|
};
|
|
511
511
|
}
|
|
512
|
+
// Narrow blind-replace guard: a ≔A..B REPLACE with NO anchors on EITHER
|
|
513
|
+
// bound carries zero content verification (SEARCH/REPLACE self-verifies via
|
|
514
|
+
// the search text matching; an anchored ≔ self-verifies via the hash). If the
|
|
515
|
+
// agent never read this file THIS SESSION, it has nothing to base a blind
|
|
516
|
+
// line-range replace on — reject instead of silently overwriting unseen lines.
|
|
517
|
+
// (Insert/append directives above are unaffected: they don't destroy content.
|
|
518
|
+
// Checked AFTER the bounds check so an out-of-range request still reports as
|
|
519
|
+
// out-of-range rather than being masked by this guard.)
|
|
520
|
+
if (!aStart && !aEnd && !lastReadSnapshots.has(absPath)) {
|
|
521
|
+
return {
|
|
522
|
+
success: false,
|
|
523
|
+
output: "",
|
|
524
|
+
error:
|
|
525
|
+
`Edit rejected: ${filePath} — a line-range replace with no anchors and no prior read this session ` +
|
|
526
|
+
`has nothing to verify against. Read the file first, then retry (or include the hashline anchors ` +
|
|
527
|
+
`from read output, or use a <<<<<<< SEARCH block instead).`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
512
530
|
const mismatch = anchorMismatch(s, aStart) ?? anchorMismatch(e, aEnd);
|
|
513
531
|
if (mismatch) return mismatch;
|
|
514
532
|
lines.splice(s - 1, e - s + 1, payload);
|
|
@@ -599,26 +617,95 @@ export async function editTool(
|
|
|
599
617
|
* can cancel() to force the in-flight read to settle — essential when a SIGKILLed
|
|
600
618
|
* process leaves a backgrounded grandchild holding the pipe's write end open, so it
|
|
601
619
|
* never hits EOF (proven by scripts/subproc-probe.ts ABANDON mode). An optional
|
|
602
|
-
* (caller-throttled) onChunk drives the live output view.
|
|
620
|
+
* (caller-throttled) onChunk drives the live output view.
|
|
621
|
+
*
|
|
622
|
+
* Memory bound: `bashTool` truncates the final output to MAX_OUTPUT chars anyway, so
|
|
623
|
+
* accumulating past the cap only risked RAM (a chatty command could hold hundreds of
|
|
624
|
+
* MB for the whole timeout window). Once the head is full we stop growing it and keep
|
|
625
|
+
* a small rolling TAIL purely for the live view — the persisted result (head + cut
|
|
626
|
+
* notice) is byte-identical to the old behaviour. onChunk likewise receives a BOUNDED
|
|
627
|
+
* string (head while small, rolling tail once capped) instead of the full cumulative
|
|
628
|
+
* buffer, which made every downstream sanitize/wrap pass O(n²) over stream length. */
|
|
629
|
+
const DRAIN_HEAD_CAP = 100_000 + 4_096; // MAX_OUTPUT + slack so the final cut still fires
|
|
630
|
+
const DRAIN_LIVE_TAIL = 16 * 1024; // matches FRAME_WRAP_TAIL_CHARS — the TUI renders only this window
|
|
603
631
|
async function drainPipe(
|
|
604
632
|
r: ReadableStreamDefaultReader<Uint8Array>,
|
|
605
633
|
onChunk?: (partial: string) => void,
|
|
606
634
|
): Promise<string> {
|
|
607
635
|
const dec = new TextDecoder();
|
|
608
636
|
let out = "";
|
|
637
|
+
let tail = ""; // rolling live-view window once `out` is capped
|
|
638
|
+
let capped = false;
|
|
609
639
|
try {
|
|
610
640
|
for (;;) {
|
|
611
641
|
const { done, value } = await r.read();
|
|
612
642
|
if (done) break;
|
|
613
|
-
|
|
643
|
+
const text = dec.decode(value, { stream: true });
|
|
644
|
+
if (!capped) {
|
|
645
|
+
out += text;
|
|
646
|
+
if (out.length >= DRAIN_HEAD_CAP) {
|
|
647
|
+
capped = true;
|
|
648
|
+
tail = out.slice(-DRAIN_LIVE_TAIL);
|
|
649
|
+
}
|
|
650
|
+
onChunk?.(out);
|
|
651
|
+
} else {
|
|
652
|
+
tail = (tail + text).slice(-DRAIN_LIVE_TAIL);
|
|
653
|
+
onChunk?.(tail);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const last = dec.decode();
|
|
657
|
+
if (!capped) {
|
|
658
|
+
out += last;
|
|
614
659
|
onChunk?.(out);
|
|
660
|
+
} else if (last) {
|
|
661
|
+
tail = (tail + last).slice(-DRAIN_LIVE_TAIL);
|
|
662
|
+
onChunk?.(tail);
|
|
615
663
|
}
|
|
616
|
-
out += dec.decode();
|
|
617
|
-
onChunk?.(out);
|
|
618
664
|
} catch { /* cancelled reader surfaces here; return what we have */ }
|
|
619
665
|
return out;
|
|
620
666
|
}
|
|
621
667
|
|
|
668
|
+
// ── Destructive-bash hard block (applies to EVERY bash call, main agent and
|
|
669
|
+
// subagent alike — including the bundled `executor` role, whose bash is
|
|
670
|
+
// otherwise unconstrained since `bashAllowedPrefixes` is an opt-in allowlist
|
|
671
|
+
// no bundled role sets). This is a narrow denylist for unambiguously
|
|
672
|
+
// catastrophic, effectively-irreversible commands, not a general policy
|
|
673
|
+
// filter — everyday destructive-but-scoped commands (rm -rf node_modules,
|
|
674
|
+
// git reset --hard, force-push) stay unconstrained by design. ──
|
|
675
|
+
function stripLeadingSudo(tokens: string[]): string[] {
|
|
676
|
+
return tokens[0] === "sudo" ? tokens.slice(1) : tokens;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** True for `rm -rf /`, `rm -fr ~`, `sudo rm -Rf /`, etc. — a recursive,
|
|
680
|
+
* forced delete whose target normalizes to the filesystem root or the home
|
|
681
|
+
* directory. Scoped deletes (`rm -rf ./dist`, `rm -rf /tmp/x`) are untouched. */
|
|
682
|
+
function isRootOrHomeWipe(command: string): boolean {
|
|
683
|
+
for (const raw of command.split(/(?:&&|\|\||[;|])/)) {
|
|
684
|
+
const tokens = stripLeadingSudo(raw.trim().split(/\s+/).filter(Boolean));
|
|
685
|
+
if (tokens.length === 0) continue;
|
|
686
|
+
const cmd = tokens[0].replace(/^.*\//, "");
|
|
687
|
+
if (cmd !== "rm") continue;
|
|
688
|
+
const flagTokens = tokens.slice(1).filter(t => t.startsWith("-"));
|
|
689
|
+
const flags = flagTokens.join("");
|
|
690
|
+
if (!/r/i.test(flags) || !/f/i.test(flags)) continue;
|
|
691
|
+
const targets = tokens.slice(1).filter(t => !t.startsWith("-"));
|
|
692
|
+
for (const t of targets) {
|
|
693
|
+
const norm = t.replace(/\/+$/, "");
|
|
694
|
+
if (norm === "" || norm === "/" || norm === "~" || norm === "$HOME" || norm === "${HOME}") return true;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Returns a short human-readable reason when `command` matches a catastrophic,
|
|
701
|
+
* effectively-irreversible pattern; null when it does not. */
|
|
702
|
+
function bashDestructiveReason(command: string): string | null {
|
|
703
|
+
if (isRootOrHomeWipe(command)) return "a recursive force-delete of the filesystem root or home directory (rm -rf / or ~)";
|
|
704
|
+
if (/:\(\)\s*\{\s*:\s*\|\s*:&?\s*;?\s*\}\s*;\s*:/.test(command)) return "a fork-bomb pattern";
|
|
705
|
+
if (/\bdd\b[^\n]*\bof=\/dev\/(disk|[rs]?[hsv]d[a-z]?|nvme\w*|xvd\w*)/i.test(command)) return "a raw disk-device overwrite (dd ... of=/dev/...)";
|
|
706
|
+
if (/\bmkfs(?:\.\w+)?\s+.*\/dev\//i.test(command)) return "a filesystem format on a raw device (mkfs)";
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
622
709
|
export async function bashTool(
|
|
623
710
|
command: string,
|
|
624
711
|
cwd: string = process.cwd(),
|
|
@@ -632,6 +719,14 @@ export async function bashTool(
|
|
|
632
719
|
const fx = applyBashFixups(command);
|
|
633
720
|
command = fx.command;
|
|
634
721
|
}
|
|
722
|
+
const destructive = bashDestructiveReason(command);
|
|
723
|
+
if (destructive) {
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
output: "",
|
|
727
|
+
error: `bash rejected: this command looks like ${destructive}. If this is genuinely intended, ask the user to run it manually — it is not permitted from the agent.`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
635
730
|
try {
|
|
636
731
|
// The mutation lock is keyed on the PROJECT cwd, not the run subdir.
|
|
637
732
|
await assertBashAllowed(cwd);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process TypeScript "language service" host (gjc `lsp` parity for TS/JS/TSX/JSX
|
|
3
|
+
* ONLY — no external `typescript-language-server` process, no other languages).
|
|
4
|
+
* Built directly on the `typescript` compiler API's `ts.LanguageService`, which is
|
|
5
|
+
* the exact engine `tsserver`/most TS editor integrations run on top of — so
|
|
6
|
+
* definition/references/hover/rename here match what a real TS language server
|
|
7
|
+
* would report, just without the LSP wire protocol or multi-language support.
|
|
8
|
+
*
|
|
9
|
+
* One `ts.LanguageService` is cached per project root (`cwd`) and reused across
|
|
10
|
+
* calls within a turn. `getScriptVersion` stats the file on every call (no manual
|
|
11
|
+
* invalidation needed) so edits made by `write`/`edit`/`ast_edit` earlier in the
|
|
12
|
+
* same turn are picked up on the next `lsp` call. A file outside the project's
|
|
13
|
+
* `tsconfig.json` "include" set is added to the tracked root set on first use.
|
|
14
|
+
*/
|
|
15
|
+
import * as ts from "typescript";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { resolveTargetFiles } from "./ast-file-scan";
|
|
19
|
+
|
|
20
|
+
interface CachedService {
|
|
21
|
+
service: ts.LanguageService;
|
|
22
|
+
extraFiles: Set<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const serviceCache = new Map<string, CachedService>();
|
|
26
|
+
|
|
27
|
+
function fileVersion(absPath: string): string {
|
|
28
|
+
try {
|
|
29
|
+
const st = fs.statSync(absPath);
|
|
30
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
31
|
+
} catch {
|
|
32
|
+
return "missing";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
|
|
37
|
+
target: ts.ScriptTarget.Latest,
|
|
38
|
+
module: ts.ModuleKind.ESNext,
|
|
39
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
40
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
41
|
+
allowJs: true,
|
|
42
|
+
checkJs: false,
|
|
43
|
+
esModuleInterop: true,
|
|
44
|
+
resolveJsonModule: true,
|
|
45
|
+
strict: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function loadProjectFiles(cwd: string): { options: ts.CompilerOptions; fileNames: string[] } {
|
|
49
|
+
const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json");
|
|
50
|
+
if (configPath) {
|
|
51
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
52
|
+
if (!configFile.error) {
|
|
53
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
|
|
54
|
+
if (parsed.fileNames.length > 0) return { options: parsed.options, fileNames: parsed.fileNames };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { options: DEFAULT_COMPILER_OPTIONS, fileNames: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get (or lazily create) the cached `ts.LanguageService` for a project root. */
|
|
61
|
+
async function getCached(cwd: string): Promise<CachedService> {
|
|
62
|
+
const existing = serviceCache.get(cwd);
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
|
|
65
|
+
const fromConfig = loadProjectFiles(cwd);
|
|
66
|
+
// No tsconfig.json (or it named zero files) — scan the whole project tree for
|
|
67
|
+
// supported TS/JS files instead of starting with an EMPTY root set. Without this,
|
|
68
|
+
// "references"/"rename" starting from a declaration would only ever see the ONE
|
|
69
|
+
// file explicitly queried: TS's own module resolution pulls in files a root file
|
|
70
|
+
// IMPORTS, never the reverse (files that import a root file), so a project with
|
|
71
|
+
// no tsconfig would silently miss every cross-file usage.
|
|
72
|
+
const fileNames = fromConfig.fileNames.length > 0 ? fromConfig.fileNames : await resolveTargetFiles(["**/*"], cwd);
|
|
73
|
+
const options = fromConfig.options;
|
|
74
|
+
const rootFiles = new Set(fileNames);
|
|
75
|
+
const extraFiles = new Set<string>();
|
|
76
|
+
|
|
77
|
+
const host: ts.LanguageServiceHost = {
|
|
78
|
+
getScriptFileNames: () => [...rootFiles, ...extraFiles],
|
|
79
|
+
getScriptVersion: fileVersion,
|
|
80
|
+
getScriptSnapshot: (fileName) => {
|
|
81
|
+
try {
|
|
82
|
+
return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, "utf-8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
getCurrentDirectory: () => cwd,
|
|
88
|
+
getCompilationSettings: () => options,
|
|
89
|
+
getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
|
|
90
|
+
fileExists: ts.sys.fileExists,
|
|
91
|
+
readFile: ts.sys.readFile,
|
|
92
|
+
readDirectory: ts.sys.readDirectory,
|
|
93
|
+
directoryExists: ts.sys.directoryExists,
|
|
94
|
+
getDirectories: ts.sys.getDirectories,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
98
|
+
const cached: CachedService = { service, extraFiles };
|
|
99
|
+
serviceCache.set(cwd, cached);
|
|
100
|
+
return cached;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Get the language service for `cwd`, ensuring `absPath` is tracked (added to the
|
|
104
|
+
* root set if the project's tsconfig didn't already include it). */
|
|
105
|
+
export async function languageServiceFor(cwd: string, absPath: string): Promise<ts.LanguageService> {
|
|
106
|
+
const cached = await getCached(cwd);
|
|
107
|
+
if (!cached.extraFiles.has(absPath)) cached.extraFiles.add(absPath);
|
|
108
|
+
return cached.service;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Drop a cached language service (tests / long-running sessions that need a
|
|
112
|
+
* clean project graph after large structural changes). */
|
|
113
|
+
export function resetLanguageServiceCache(cwd?: string): void {
|
|
114
|
+
if (cwd) serviceCache.delete(cwd);
|
|
115
|
+
else serviceCache.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SymbolSelector { text: string; occurrence: number }
|
|
119
|
+
|
|
120
|
+
/** Parse a loose `"name"` or `"name#N"` selector (1-indexed occurrence, default 1). */
|
|
121
|
+
export function parseSymbolSelector(input: string | undefined): SymbolSelector | undefined {
|
|
122
|
+
if (!input) return undefined;
|
|
123
|
+
const m = /^(.*)#(\d+)$/.exec(input);
|
|
124
|
+
if (m) return { text: m[1]!, occurrence: Math.max(1, parseInt(m[2]!, 10)) };
|
|
125
|
+
return { text: input, occurrence: 1 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type PositionResult = { position: number } | { error: string };
|
|
129
|
+
|
|
130
|
+
/** Resolve a 1-indexed `line` (+ optional symbol substring selector) to an absolute
|
|
131
|
+
* character offset in `fileText`, using the TS compiler's own line map (so CRLF
|
|
132
|
+
* files are handled correctly, unlike naive `split("\n")` offset arithmetic). */
|
|
133
|
+
export function resolvePosition(fileText: string, line1: number, symbol?: SymbolSelector): PositionResult {
|
|
134
|
+
const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
|
|
135
|
+
const lineCount = sf.getLineStarts().length;
|
|
136
|
+
if (line1 < 1 || line1 > lineCount) {
|
|
137
|
+
return { error: `line ${line1} is out of range (file has ${lineCount} lines)` };
|
|
138
|
+
}
|
|
139
|
+
const lineStart = sf.getPositionOfLineAndCharacter(line1 - 1, 0);
|
|
140
|
+
const lineEnd = line1 < lineCount ? sf.getPositionOfLineAndCharacter(line1, 0) : fileText.length;
|
|
141
|
+
const lineText = fileText.slice(lineStart, lineEnd).replace(/\r?\n$/, "");
|
|
142
|
+
|
|
143
|
+
if (!symbol) return { position: lineStart };
|
|
144
|
+
|
|
145
|
+
let idx = -1;
|
|
146
|
+
let from = 0;
|
|
147
|
+
for (let i = 0; i < symbol.occurrence; i++) {
|
|
148
|
+
idx = lineText.indexOf(symbol.text, from);
|
|
149
|
+
if (idx === -1) break;
|
|
150
|
+
from = idx + 1;
|
|
151
|
+
}
|
|
152
|
+
if (idx === -1) {
|
|
153
|
+
return { error: `symbol '${symbol.text}' (occurrence ${symbol.occurrence}) not found on line ${line1}: ${lineText.trim().slice(0, 120)}` };
|
|
154
|
+
}
|
|
155
|
+
return { position: lineStart + idx + Math.max(0, Math.floor(symbol.text.length / 2)) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One line of a file, 1-indexed, trimmed for display. */
|
|
159
|
+
export function lineTextAt(fileText: string, line1: number): string {
|
|
160
|
+
const lines = fileText.split(/\r?\n/);
|
|
161
|
+
return (lines[line1 - 1] ?? "").trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Convert a 0-indexed TS `position` in `fileText` to a 1-indexed line number. */
|
|
165
|
+
export function lineOfPosition(fileText: string, position: number): number {
|
|
166
|
+
const sf = ts.createSourceFile("__pos__.tsx", fileText, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
|
|
167
|
+
return sf.getLineAndCharacterOfPosition(position).line + 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A short, uniform `path:line: text` result line. */
|
|
171
|
+
export function formatLocation(relPath: string, line1: number, text: string): string {
|
|
172
|
+
return `${relPath}:${line1}: ${text.length > 160 ? text.slice(0, 159) + "…" : text}`;
|
|
173
|
+
}
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -521,18 +521,29 @@ export interface StreamIdleOptions {
|
|
|
521
521
|
onIdle?: () => void;
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
-
/**
|
|
525
|
-
*
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
524
|
+
/** Handle returned by {@link idleWatchdog}: the racing promise, its cleanup, and a
|
|
525
|
+
* `touch()` to call after each successfully received chunk. */
|
|
526
|
+
export interface StreamWatchdogHandle {
|
|
527
|
+
promise: Promise<never>;
|
|
528
|
+
cleanup: () => void;
|
|
529
|
+
touch: () => void;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** One watchdog per stream attempt. Reused across `iter.next()` calls so a busy
|
|
533
|
+
* stream does not allocate a Promise+timer pair per yielded chunk.
|
|
534
|
+
*
|
|
535
|
+
* Idle measurement fallback: when the caller has no `lastActivityAt` (the common
|
|
536
|
+
* case — most providers never wire reasoning-progress tracking), idle time MUST be
|
|
537
|
+
* measured against the last moment a chunk actually arrived, not against `Date.now()`
|
|
538
|
+
* re-sampled on every internal re-arm — the latter makes `now - lastAct` collapse to
|
|
539
|
+
* ~0 forever, so the idle timer can never fire and a genuinely stalled stream hangs
|
|
540
|
+
* forever instead of timing out. `touch()` (called by the consumer after each
|
|
541
|
+
* received chunk) advances this fallback baseline; `lastActivityAt`, when provided,
|
|
542
|
+
* always takes precedence (reasoning-progress tracking is more precise). */
|
|
543
|
+
function idleWatchdog(idle: StreamIdleOptions): StreamWatchdogHandle {
|
|
534
544
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
535
|
-
|
|
545
|
+
let lastChunkAt = Date.now();
|
|
546
|
+
const promise = new Promise<never>((_, reject) => {
|
|
536
547
|
const arm = () => {
|
|
537
548
|
const now = Date.now();
|
|
538
549
|
const overallRemaining = idle.deadlineAt !== undefined ? idle.deadlineAt - now : Infinity;
|
|
@@ -541,10 +552,10 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
|
|
|
541
552
|
reject(new Error(`stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`));
|
|
542
553
|
return;
|
|
543
554
|
}
|
|
544
|
-
const
|
|
555
|
+
const lastAct = idle.lastActivityAt ? idle.lastActivityAt() : lastChunkAt;
|
|
556
|
+
const idleRemaining = idle.idleMs - (now - lastAct);
|
|
545
557
|
const wait = Math.min(idleRemaining, overallRemaining);
|
|
546
558
|
if (wait <= 0) {
|
|
547
|
-
// overallRemaining is > 0 here (pre-checked), so wait<=0 ⟺ the idle window lapsed.
|
|
548
559
|
idle.onIdle?.();
|
|
549
560
|
reject(new Error(`stream idle for ${idle.idleMs}ms (no chunk) — provider sent no token within the idle window (load or long thinking); retrying. Raise JEO_STREAM_IDLE_MS or lower the thinking level if this persists.`));
|
|
550
561
|
return;
|
|
@@ -553,11 +564,16 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
|
|
|
553
564
|
};
|
|
554
565
|
arm();
|
|
555
566
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
567
|
+
return { promise, cleanup: () => clearTimeout(timer), touch: () => { lastChunkAt = Date.now(); } };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** `iter.next()`, racing the stream-attempt watchdog AND (when set) the overall deadline.
|
|
571
|
+
* The watchdog re-arms while reasoning activity (idle.lastActivityAt) keeps advancing,
|
|
572
|
+
* so a model that streams thinking tokens for longer than idleMs before emitting visible
|
|
573
|
+
* text is NOT mistaken for a stalled stream — only a genuinely silent stream aborts. */
|
|
574
|
+
async function nextMaybeIdle(iter: AsyncIterator<string>, watchdog?: Promise<never>): Promise<IteratorResult<string>> {
|
|
575
|
+
if (!watchdog) return iter.next();
|
|
576
|
+
return Promise.race([iter.next(), watchdog]);
|
|
561
577
|
}
|
|
562
578
|
|
|
563
579
|
/** Opt-in overall stream wall-clock from the environment; undefined = off (default). */
|
|
@@ -590,14 +606,30 @@ export async function* retryableStream(
|
|
|
590
606
|
retry: RetryOptions,
|
|
591
607
|
idle?: StreamIdleOptions,
|
|
592
608
|
): AsyncGenerator<string> {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
609
|
+
let cleanupWatchdog = () => {};
|
|
610
|
+
let touchWatchdog = () => {};
|
|
611
|
+
try {
|
|
612
|
+
const { iter, first, watchdog } = await withRetry(async () => {
|
|
613
|
+
cleanupWatchdog();
|
|
614
|
+
const it = makeIter();
|
|
615
|
+
const w = idle ? idleWatchdog(idle) : undefined;
|
|
616
|
+
cleanupWatchdog = w?.cleanup ?? (() => {});
|
|
617
|
+
touchWatchdog = w?.touch ?? (() => {});
|
|
618
|
+
const f = await nextMaybeIdle(it, w?.promise);
|
|
619
|
+
if (!f.done) touchWatchdog();
|
|
620
|
+
return { iter: it, first: f, watchdog: w?.promise };
|
|
621
|
+
}, retry);
|
|
622
|
+
if (!first.done) {
|
|
623
|
+
yield first.value;
|
|
624
|
+
for (;;) {
|
|
625
|
+
const n = await nextMaybeIdle(iter, watchdog);
|
|
626
|
+
if (n.done) break;
|
|
627
|
+
touchWatchdog();
|
|
628
|
+
yield n.value;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} finally {
|
|
632
|
+
cleanupWatchdog();
|
|
601
633
|
}
|
|
602
634
|
}
|
|
603
635
|
|
package/src/ai/sse.ts
CHANGED
|
@@ -5,6 +5,7 @@ export async function* readLines(
|
|
|
5
5
|
const reader = stream.getReader();
|
|
6
6
|
const decoder = new TextDecoder("utf-8");
|
|
7
7
|
let buffer = "";
|
|
8
|
+
let searchFrom = 0;
|
|
8
9
|
try {
|
|
9
10
|
while (true) {
|
|
10
11
|
const { done, value } = await reader.read();
|
|
@@ -19,19 +20,33 @@ export async function* readLines(
|
|
|
19
20
|
onActivity?.();
|
|
20
21
|
}
|
|
21
22
|
buffer += decoder.decode(value, { stream: true });
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const line =
|
|
23
|
+
let idx;
|
|
24
|
+
while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
|
|
25
|
+
const lineWithCr = buffer.slice(searchFrom, idx);
|
|
26
|
+
const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
|
|
26
27
|
if (line !== "") {
|
|
27
28
|
yield line;
|
|
28
29
|
}
|
|
30
|
+
searchFrom = idx + 1;
|
|
31
|
+
}
|
|
32
|
+
if (searchFrom > 0) {
|
|
33
|
+
buffer = buffer.slice(searchFrom);
|
|
34
|
+
searchFrom = 0;
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
buffer += decoder.decode();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
38
|
+
let idx;
|
|
39
|
+
while ((idx = buffer.indexOf("\n", searchFrom)) !== -1) {
|
|
40
|
+
const lineWithCr = buffer.slice(searchFrom, idx);
|
|
41
|
+
const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
|
|
42
|
+
if (line !== "") {
|
|
43
|
+
yield line;
|
|
44
|
+
}
|
|
45
|
+
searchFrom = idx + 1;
|
|
46
|
+
}
|
|
47
|
+
if (searchFrom < buffer.length) {
|
|
48
|
+
const lineWithCr = buffer.slice(searchFrom);
|
|
49
|
+
const line = lineWithCr.endsWith("\r") ? lineWithCr.slice(0, -1) : lineWithCr;
|
|
35
50
|
if (line !== "") {
|
|
36
51
|
yield line;
|
|
37
52
|
}
|
package/src/ai/think-tags.ts
CHANGED
|
@@ -19,7 +19,16 @@ const CLOSE = "</think>";
|
|
|
19
19
|
function partialTail(s: string, tag: string): number {
|
|
20
20
|
const max = Math.min(s.length, tag.length - 1);
|
|
21
21
|
for (let k = max; k > 0; k--) {
|
|
22
|
-
if
|
|
22
|
+
// Check if s.endsWith(tag.slice(0, k)) without allocating substrings.
|
|
23
|
+
let match = true;
|
|
24
|
+
const sStart = s.length - k;
|
|
25
|
+
for (let i = 0; i < k; i++) {
|
|
26
|
+
if (s.charCodeAt(sStart + i) !== tag.charCodeAt(i)) {
|
|
27
|
+
match = false;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (match) return k;
|
|
23
32
|
}
|
|
24
33
|
return 0;
|
|
25
34
|
}
|