castle-web-cli 0.4.124 → 0.4.126
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/dist/agent-prompts.d.ts +60 -0
- package/dist/agent-prompts.js +677 -11
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +384 -12
- package/dist/castleJson.d.ts +3 -0
- package/dist/headlessCover.js +55 -10
- package/dist/shell/assets/{index-BfOPkSej.css → index-0CshkxoU.css} +1 -1
- package/dist/shell/assets/{index-u0nYFqbF.js → index-CAzIvwhA.js} +66 -64
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/package-lock.json +1 -1
- package/package.json +1 -1
package/dist/agent.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ interface TaskRecord {
|
|
|
34
34
|
title: string;
|
|
35
35
|
prompt: string;
|
|
36
36
|
after: string[];
|
|
37
|
+
item?: string;
|
|
37
38
|
status: TaskStatus;
|
|
38
39
|
progress: number;
|
|
39
40
|
notes: string;
|
|
@@ -51,6 +52,11 @@ interface TaskRecord {
|
|
|
51
52
|
phase?: string;
|
|
52
53
|
acknowledged?: boolean;
|
|
53
54
|
rejected?: boolean;
|
|
55
|
+
durable?: string;
|
|
56
|
+
what?: string;
|
|
57
|
+
files?: string[];
|
|
58
|
+
knobs?: string[];
|
|
59
|
+
durablePending?: boolean;
|
|
54
60
|
blockedBy?: string[];
|
|
55
61
|
}
|
|
56
62
|
export declare function roleUsesOpenrouter(backend: AgentBackend, claudeModel: ClaudeModel): boolean;
|
package/dist/agent.js
CHANGED
|
@@ -22,7 +22,8 @@ import * as path from 'path';
|
|
|
22
22
|
import { nanoid } from 'nanoid';
|
|
23
23
|
import { WebSocketServer } from 'ws';
|
|
24
24
|
import { rawDataToString } from './ide.js';
|
|
25
|
-
import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
|
|
25
|
+
import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
|
|
26
|
+
import { readCastleJson } from './castleJson.js';
|
|
26
27
|
import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
|
|
27
28
|
import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
|
|
28
29
|
import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from './metering.js';
|
|
@@ -517,6 +518,17 @@ const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) |
|
|
|
517
518
|
const TASK_POLL_MS = 1_000;
|
|
518
519
|
const FENCE_HOLDBACK = '```castle-';
|
|
519
520
|
const RESULT_SUMMARY_CHARS = 600;
|
|
521
|
+
// The deck's plan file: the ops in the router's ```castle-plan fences are
|
|
522
|
+
// applied to it, the user edits it like any other deck file, and it is rendered
|
|
523
|
+
// into every router turn and task prompt. Exactly two hands write it -- the
|
|
524
|
+
// router and the user -- so the runtime never joins machine state (task status,
|
|
525
|
+
// what exists) into it; that rides in the rendered digest instead.
|
|
526
|
+
const PLAN_FILE = 'plan.md';
|
|
527
|
+
// Byte ceiling on the plan.md injected into those prompts. Nothing bounds the
|
|
528
|
+
// file itself -- ops touch one line each and the user pastes what they like --
|
|
529
|
+
// meanwhile the whole prompt rides inside one argv entry (see
|
|
530
|
+
// TRANSCRIPT_BYTE_BUDGET in agent-prompts.ts), so this term needs its own bound.
|
|
531
|
+
const PLAN_BYTE_BUDGET = 8 * 1024;
|
|
520
532
|
const MAX_ATTACHMENTS = 6;
|
|
521
533
|
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
522
534
|
const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
|
|
@@ -548,27 +560,37 @@ function visibleLength(raw) {
|
|
|
548
560
|
}
|
|
549
561
|
return raw.length;
|
|
550
562
|
}
|
|
551
|
-
// Parse one ```castle-task fence's body (title line, optional "after:"
|
|
552
|
-
// then the prompt) into a directive. Shared by
|
|
553
|
-
// extraction below and the mid-stream incremental
|
|
554
|
-
// so a fence spawned early behaves identically to
|
|
563
|
+
// Parse one ```castle-task fence's body (title line, optional "after:" and
|
|
564
|
+
// "item:" lines in either order, then the prompt) into a directive. Shared by
|
|
565
|
+
// the settle-time full-text extraction below and the mid-stream incremental
|
|
566
|
+
// scanner (runRouterTurnIn), so a fence spawned early behaves identically to
|
|
567
|
+
// one spawned at settle.
|
|
555
568
|
function parseTaskFenceBody(body) {
|
|
556
569
|
const lines = body.replace(/\r/g, '').split('\n');
|
|
557
570
|
const title = (lines.shift() ?? '').trim();
|
|
558
571
|
if (!title)
|
|
559
572
|
return null;
|
|
560
573
|
const after = [];
|
|
574
|
+
let item;
|
|
561
575
|
while (lines.length > 0) {
|
|
562
|
-
const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? '').trim());
|
|
576
|
+
const headerMatch = /^(after|item):\s*(.*)$/i.exec((lines[0] ?? '').trim());
|
|
563
577
|
if (!headerMatch)
|
|
564
578
|
break;
|
|
565
579
|
lines.shift();
|
|
580
|
+
if (headerMatch[1].toLowerCase() === 'item') {
|
|
581
|
+
const label = headerMatch[2].trim();
|
|
582
|
+
// `item: -` is the fence saying "chore, no plan item" -- an answer, not
|
|
583
|
+
// an omission, so it lands the same as no line at all.
|
|
584
|
+
if (label && label !== '-')
|
|
585
|
+
item = label;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
566
588
|
after.push(...headerMatch[2]
|
|
567
589
|
.split(',')
|
|
568
590
|
.map((s) => s.trim())
|
|
569
591
|
.filter(Boolean));
|
|
570
592
|
}
|
|
571
|
-
return { title, after, prompt: lines.join('\n').trim() };
|
|
593
|
+
return { title, after, item, prompt: lines.join('\n').trim() };
|
|
572
594
|
}
|
|
573
595
|
// A fresh RegExp per call -- this is matched with manual .exec() loops in
|
|
574
596
|
// TWO independent call sites (settle-time extractDirectives via .replace, and
|
|
@@ -577,6 +599,18 @@ function parseTaskFenceBody(body) {
|
|
|
577
599
|
function taskFenceRegex() {
|
|
578
600
|
return /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
|
|
579
601
|
}
|
|
602
|
+
// Take the body of a ```castle-plan fence out of a router reply: the ops it
|
|
603
|
+
// wants applied to plan.md. A reply emitting more than one is stating more ops,
|
|
604
|
+
// so the bodies concatenate in order. Returns the reply with every such fence
|
|
605
|
+
// removed.
|
|
606
|
+
function takePlanFence(source) {
|
|
607
|
+
const bodies = [];
|
|
608
|
+
const rest = source.replace(/```castle-plan[ \t]*\r?\n([\s\S]*?)```/g, (_match, body) => {
|
|
609
|
+
bodies.push(String(body));
|
|
610
|
+
return '';
|
|
611
|
+
});
|
|
612
|
+
return { plan: bodies.length > 0 ? bodies.join('\n') : undefined, rest };
|
|
613
|
+
}
|
|
580
614
|
// Pull ```castle-task fenced directives out of a finished router reply.
|
|
581
615
|
// Block format: title line, then an optional "after:" line, then the prompt.
|
|
582
616
|
function extractDirectives(full) {
|
|
@@ -594,7 +628,8 @@ function extractDirectives(full) {
|
|
|
594
628
|
return '';
|
|
595
629
|
});
|
|
596
630
|
};
|
|
597
|
-
const
|
|
631
|
+
const { plan, rest } = takePlanFence(full);
|
|
632
|
+
const withoutDone = listFence(listFence(rest, 'castle-done', checkoffs), 'castle-stop', stops);
|
|
598
633
|
const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
|
|
599
634
|
const directive = parseTaskFenceBody(String(body));
|
|
600
635
|
if (directive)
|
|
@@ -606,8 +641,166 @@ function extractDirectives(full) {
|
|
|
606
641
|
directives,
|
|
607
642
|
checkoffs,
|
|
608
643
|
stops,
|
|
644
|
+
plan,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const planSessions = new Map();
|
|
648
|
+
// What a deck that says nothing gets. Off unless the serve was told otherwise,
|
|
649
|
+
// so a serve nobody configured -- one started from a terminal, or by a caller
|
|
650
|
+
// that forgot the flag -- lands on the default rather than quietly handing the
|
|
651
|
+
// feature to someone outside the rollout. The cloud editor sets it per user
|
|
652
|
+
// from a feature gate; see serveOnPort in castle-www's cloudSandbox.ts.
|
|
653
|
+
function planDefault() {
|
|
654
|
+
return process.env.CASTLE_AGENT_PLAN === 'on';
|
|
655
|
+
}
|
|
656
|
+
function planSession(deckDir) {
|
|
657
|
+
const existing = planSessions.get(deckDir);
|
|
658
|
+
if (existing)
|
|
659
|
+
return existing;
|
|
660
|
+
const declared = readCastleJson(deckDir)?.agent?.plan;
|
|
661
|
+
const session = {
|
|
662
|
+
enabled: declared === 'on' ? true : declared === 'off' ? false : planDefault(),
|
|
663
|
+
notices: [],
|
|
664
|
+
asyncNotices: [],
|
|
665
|
+
quietBuildTurns: 0,
|
|
666
|
+
};
|
|
667
|
+
planSessions.set(deckDir, session);
|
|
668
|
+
return session;
|
|
669
|
+
}
|
|
670
|
+
function readPlanSnapshot(deckDir) {
|
|
671
|
+
let raw;
|
|
672
|
+
try {
|
|
673
|
+
raw = fs.readFileSync(path.join(deckDir, PLAN_FILE), 'utf8');
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return { raw: null };
|
|
677
|
+
}
|
|
678
|
+
if (!raw.trim())
|
|
679
|
+
return { raw };
|
|
680
|
+
if (Buffer.byteLength(raw, 'utf8') <= PLAN_BYTE_BUDGET)
|
|
681
|
+
return { raw, text: raw.trim() };
|
|
682
|
+
return {
|
|
683
|
+
raw,
|
|
684
|
+
text: `${truncateToBytes(raw, PLAN_BYTE_BUDGET).trim()}\n\n(trimmed -- plan.md is longer than this. Tell the user it has grown too long; it should stay a short working plan.)`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
// How many op notices ride into the next router turn. More than a handful is a
|
|
688
|
+
// fence that went wrong wholesale, and the first few say so.
|
|
689
|
+
const PLAN_NOTICE_LIMIT = 6;
|
|
690
|
+
// Apply a ```castle-plan fence's ops to <deck>/plan.md. The file is re-read
|
|
691
|
+
// HERE rather than passed down from prompt-build time: an op names the one line
|
|
692
|
+
// it touches, so a user editing another part of the plan while the turn ran
|
|
693
|
+
// keeps their edit and the op still lands. (Full-file fences could do neither,
|
|
694
|
+
// which is what the old stale-snapshot guard was for.)
|
|
695
|
+
function applyPlanFence(deckDir, body, sources) {
|
|
696
|
+
const session = planSession(deckDir);
|
|
697
|
+
// An opted-out deck is never told plan.md exists, so a fence here is a stray
|
|
698
|
+
// -- applying it would put a file on a deck that asked for none. The fence is
|
|
699
|
+
// still stripped from the reply upstream, same as any other.
|
|
700
|
+
if (!session.enabled)
|
|
701
|
+
return 'dropped';
|
|
702
|
+
const { ops, unparsed } = parsePlanOps(body);
|
|
703
|
+
const notices = unparsed.map((line) => `\`${line}\` is not an op, so nothing was applied for it`);
|
|
704
|
+
const current = readPlanSnapshot(deckDir).raw ?? '';
|
|
705
|
+
const result = ops.length > 0 ? applyPlanOps(current, ops, sources) : undefined;
|
|
706
|
+
notices.push(...(result?.notices ?? []));
|
|
707
|
+
for (const notice of notices)
|
|
708
|
+
console.error(`[router] plan fence: ${notice}`);
|
|
709
|
+
session.notices = notices.slice(0, PLAN_NOTICE_LIMIT);
|
|
710
|
+
if (!result || result.applied === 0)
|
|
711
|
+
return result ? 'unchanged' : 'dropped';
|
|
712
|
+
const text = `${result.text}\n`;
|
|
713
|
+
if (text === current)
|
|
714
|
+
return 'unchanged';
|
|
715
|
+
try {
|
|
716
|
+
fs.writeFileSync(path.join(deckDir, PLAN_FILE), text);
|
|
717
|
+
return 'written';
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
console.error(`[router] could not write ${PLAN_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
721
|
+
return 'dropped';
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// What a taste op's quote is checked against. The WHOLE message store, not the
|
|
725
|
+
// 40-message window the router was shown: the check is one substring test per
|
|
726
|
+
// message, and being permissive costs nothing here -- in practice the router
|
|
727
|
+
// quotes what is in front of it. Task reports reach back the same way, since a
|
|
728
|
+
// finished task's record outlives both its nomination and its board row. A user
|
|
729
|
+
// statement so old it has left the store is a memory the router can no longer
|
|
730
|
+
// evidence, and asking to confirm it is the right end of that.
|
|
731
|
+
//
|
|
732
|
+
// A task's quotable text is every field of it the ROUTER IS SHOWN -- the same
|
|
733
|
+
// notes and failure reason asPromptTask puts on the board row, not only the
|
|
734
|
+
// `durable:` nomination. Measured 26-08-15: with the nomination alone, 2 of 10
|
|
735
|
+
// taste ops in a t1 run were routed away for quoting a failed task's error line
|
|
736
|
+
// byte-for-byte out of the prompt in front of them. A check that rejects a
|
|
737
|
+
// verbatim quote of its own prompt teaches the router to stop quoting.
|
|
738
|
+
function planOpSources(taskStore, messages) {
|
|
739
|
+
const taskReports = new Map();
|
|
740
|
+
for (const task of taskStore.sorted()) {
|
|
741
|
+
const row = asPromptTask(task);
|
|
742
|
+
const text = [task.durable, row.error, row.notes].filter((s) => s?.trim()).join('\n');
|
|
743
|
+
if (text)
|
|
744
|
+
taskReports.set(task.id, text);
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
taskReports,
|
|
748
|
+
userMessages: messages.filter((m) => m.role === 'user').map((m) => m.text),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
// What the router's next turn is told about the plan, or undefined when the
|
|
752
|
+
// deck has it switched off -- see RouterPlanOpts. Reading the notices clears
|
|
753
|
+
// them: they are one-shots about a fence the router has since been shown the
|
|
754
|
+
// result of.
|
|
755
|
+
function routerPlanOpts(deckDir, snapshot, nominations, tasks) {
|
|
756
|
+
const session = planSession(deckDir);
|
|
757
|
+
if (!session.enabled)
|
|
758
|
+
return undefined;
|
|
759
|
+
const notices = [...session.notices, ...session.asyncNotices].slice(0, PLAN_NOTICE_LIMIT);
|
|
760
|
+
session.notices = [];
|
|
761
|
+
session.asyncNotices = [];
|
|
762
|
+
return {
|
|
763
|
+
fileText: snapshot.text,
|
|
764
|
+
pendingDurables: nominations.map((t) => ({
|
|
765
|
+
taskId: t.id,
|
|
766
|
+
taskTitle: t.title,
|
|
767
|
+
text: t.durable ?? '',
|
|
768
|
+
})),
|
|
769
|
+
finishedWork: planFinishedWork(tasks),
|
|
770
|
+
notices,
|
|
609
771
|
};
|
|
610
772
|
}
|
|
773
|
+
// The agenda nudge. A reply that spawned work, asked nothing (no probe chips,
|
|
774
|
+
// no question mark), while `## Open questions` holds a standing line, is a
|
|
775
|
+
// quiet build turn; the second in a row queues a notice naming the top line.
|
|
776
|
+
// Counting quiet turns and naming the line is exactly what five wordings of a
|
|
777
|
+
// prompt rule could not get the router to do for itself (0 across 14 samples,
|
|
778
|
+
// 26-08-18) and what the notice-shaped close rule showed it does do when told.
|
|
779
|
+
function trackStandingQuestions(ctx, cleaned, spawnedWork) {
|
|
780
|
+
const session = planSession(ctx.deckDir);
|
|
781
|
+
if (!session.enabled)
|
|
782
|
+
return;
|
|
783
|
+
const askedSomething = /\[\[[^\]\n]+\]\]/.test(cleaned) || cleaned.includes('?');
|
|
784
|
+
if (askedSomething || !spawnedWork) {
|
|
785
|
+
session.quietBuildTurns = 0;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const standing = planOpenQuestionLines(readPlanSnapshot(ctx.deckDir).raw ?? '');
|
|
789
|
+
if (standing.length === 0) {
|
|
790
|
+
session.quietBuildTurns = 0;
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
session.quietBuildTurns += 1;
|
|
794
|
+
if (session.quietBuildTurns < 2)
|
|
795
|
+
return;
|
|
796
|
+
session.quietBuildTurns = 0;
|
|
797
|
+
session.asyncNotices.push(`the plan's top open question has now sat through two build turns with nothing asked -- this is the turn to ask it, in its own words, while work spawns anyway: ${standing[0]}`);
|
|
798
|
+
}
|
|
799
|
+
function taskPlanOpts(deckDir, item, finishedWork) {
|
|
800
|
+
if (!planSession(deckDir).enabled)
|
|
801
|
+
return undefined;
|
|
802
|
+
return { fileText: readPlanSnapshot(deckDir).text, item, finishedWork };
|
|
803
|
+
}
|
|
611
804
|
// Scan `raw` for ```castle-task fences that have FULLY closed since
|
|
612
805
|
// `fromIndex` -- i.e. their closing ``` has already streamed in -- and parse
|
|
613
806
|
// each into a directive. Returns the index just past the last one consumed,
|
|
@@ -1898,6 +2091,58 @@ async function runAgentTurn(opts) {
|
|
|
1898
2091
|
function persistTaskFile(tasksDir, task) {
|
|
1899
2092
|
fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
|
|
1900
2093
|
}
|
|
2094
|
+
// The task index: one row per task this deck has ever spawned, addressed by the
|
|
2095
|
+
// plan item it advanced. It is the complete address book (step 2 searches it by
|
|
2096
|
+
// item label to find the handoffs worth reading) and, filtered to running rows,
|
|
2097
|
+
// the live claims board.
|
|
2098
|
+
//
|
|
2099
|
+
// Runtime-exclusive -- unlike plan.md, which has two writers, this has exactly
|
|
2100
|
+
// one, which is why the claim data lives here rather than in the plan. It is
|
|
2101
|
+
// written whether or not the deck has the plan doc switched on: it is a disk
|
|
2102
|
+
// receipt, never injected into a prompt, so it costs an off deck nothing.
|
|
2103
|
+
const TASK_INDEX_FILE = 'index.md';
|
|
2104
|
+
const TASK_INDEX_HEADER = '# tasks\n# <task-id> | <item> | <title> | <created> | <status> | <touching>\n';
|
|
2105
|
+
// A pipe-delimited row can't carry a pipe. Titles come from the router's fence
|
|
2106
|
+
// and can hold anything.
|
|
2107
|
+
function indexCell(value) {
|
|
2108
|
+
return value.replace(/\|/g, '/').replace(/\s+/g, ' ').trim();
|
|
2109
|
+
}
|
|
2110
|
+
function taskIndexLine(task) {
|
|
2111
|
+
return [
|
|
2112
|
+
task.id,
|
|
2113
|
+
indexCell(task.item ?? '-') || '-',
|
|
2114
|
+
indexCell(task.title),
|
|
2115
|
+
task.createdAt.slice(0, 10),
|
|
2116
|
+
task.status,
|
|
2117
|
+
// `touching` is in the grammar from the start though nothing writes it
|
|
2118
|
+
// until claims land (step 1.5), so today's index still parses then.
|
|
2119
|
+
'',
|
|
2120
|
+
].join(' | ');
|
|
2121
|
+
}
|
|
2122
|
+
function writeTaskIndex(tasksDir, tasks) {
|
|
2123
|
+
const rows = [...tasks.values()]
|
|
2124
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
2125
|
+
.map(taskIndexLine);
|
|
2126
|
+
const body = `${TASK_INDEX_HEADER}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}`;
|
|
2127
|
+
const file = path.join(tasksDir, TASK_INDEX_FILE);
|
|
2128
|
+
try {
|
|
2129
|
+
if (fs.readFileSync(file, 'utf8') === body)
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
catch {
|
|
2133
|
+
/* no index yet -- writing it below is the change */
|
|
2134
|
+
}
|
|
2135
|
+
const tmp = `${file}.tmp`;
|
|
2136
|
+
try {
|
|
2137
|
+
// Written aside and renamed: a running task agent may be reading this file
|
|
2138
|
+
// at any moment, and a torn read is a claims board with rows missing.
|
|
2139
|
+
fs.writeFileSync(tmp, body);
|
|
2140
|
+
fs.renameSync(tmp, file);
|
|
2141
|
+
}
|
|
2142
|
+
catch (err) {
|
|
2143
|
+
console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1901
2146
|
// Tasks left "running" by a dead serve are as finished as they will get. A
|
|
1902
2147
|
// persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
|
|
1903
2148
|
// never reconsiders it and it can't wedge or auto-start; it just sits on the
|
|
@@ -1917,8 +2162,84 @@ function loadTasks(tasksDir) {
|
|
|
1917
2162
|
}
|
|
1918
2163
|
return tasks;
|
|
1919
2164
|
}
|
|
2165
|
+
// A handoff line is one fact, and a long one is a summary rather than the
|
|
2166
|
+
// single fact the prompt asks for.
|
|
2167
|
+
const HANDOFF_MAX_CHARS = 200;
|
|
2168
|
+
const HANDOFF_MAX_LIST = 8;
|
|
2169
|
+
// One field of the handoff file -- the FIRST line carrying it, since the prompt
|
|
2170
|
+
// asks for one of each and a task that wrote several has already missed the
|
|
2171
|
+
// point.
|
|
2172
|
+
function handoffField(handoff, field) {
|
|
2173
|
+
const match = new RegExp(`^${field}:\\s*(.+)$`, 'i');
|
|
2174
|
+
for (const line of handoff.split('\n')) {
|
|
2175
|
+
const found = match.exec(line.trim());
|
|
2176
|
+
if (found)
|
|
2177
|
+
return found[1].trim().slice(0, HANDOFF_MAX_CHARS);
|
|
2178
|
+
}
|
|
2179
|
+
return undefined;
|
|
2180
|
+
}
|
|
2181
|
+
function handoffList(handoff, field) {
|
|
2182
|
+
const items = (handoffField(handoff, field) ?? '')
|
|
2183
|
+
.split(',')
|
|
2184
|
+
.map((s) => s.trim())
|
|
2185
|
+
.filter(Boolean)
|
|
2186
|
+
.slice(0, HANDOFF_MAX_LIST);
|
|
2187
|
+
return items.length > 0 ? items : undefined;
|
|
2188
|
+
}
|
|
2189
|
+
// Pull the handoff file into the record. `durable:` additionally goes PENDING
|
|
2190
|
+
// on arrival -- it is a proposal the router has to answer -- while the others
|
|
2191
|
+
// are just facts the digest derives `## Built` from.
|
|
2192
|
+
function refreshHandoff(dir, task) {
|
|
2193
|
+
let handoff;
|
|
2194
|
+
try {
|
|
2195
|
+
handoff = fs.readFileSync(path.join(dir, 'handoff'), 'utf8');
|
|
2196
|
+
}
|
|
2197
|
+
catch {
|
|
2198
|
+
return false;
|
|
2199
|
+
}
|
|
2200
|
+
let changed = false;
|
|
2201
|
+
const durable = handoffField(handoff, 'durable');
|
|
2202
|
+
if (durable && durable !== task.durable) {
|
|
2203
|
+
task.durable = durable;
|
|
2204
|
+
task.durablePending = true;
|
|
2205
|
+
changed = true;
|
|
2206
|
+
}
|
|
2207
|
+
const what = handoffField(handoff, 'what');
|
|
2208
|
+
if (what && what !== task.what) {
|
|
2209
|
+
task.what = what;
|
|
2210
|
+
changed = true;
|
|
2211
|
+
}
|
|
2212
|
+
for (const field of ['files', 'knobs']) {
|
|
2213
|
+
const list = handoffList(handoff, field);
|
|
2214
|
+
if (list && list.join(', ') !== (task[field] ?? []).join(', ')) {
|
|
2215
|
+
task[field] = list;
|
|
2216
|
+
changed = true;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
return changed;
|
|
2220
|
+
}
|
|
2221
|
+
// What the plan's derived `## Built` is made of: the work of tasks that
|
|
2222
|
+
// actually finished, addressed by the plan item they advanced. A task that
|
|
2223
|
+
// belongs to no item, failed, or never wrote a `what:` line has nothing to
|
|
2224
|
+
// contribute -- the section is what EXISTS, not what was attempted.
|
|
2225
|
+
function planFinishedWork(tasks) {
|
|
2226
|
+
const work = [];
|
|
2227
|
+
for (const task of tasks) {
|
|
2228
|
+
if (task.status !== 'done' || !task.item || task.item === '-' || !task.what)
|
|
2229
|
+
continue;
|
|
2230
|
+
work.push({
|
|
2231
|
+
item: task.item,
|
|
2232
|
+
what: task.what,
|
|
2233
|
+
files: task.files,
|
|
2234
|
+
knobs: task.knobs,
|
|
2235
|
+
at: task.finishedAt ?? task.updatedAt,
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
return work;
|
|
2239
|
+
}
|
|
1920
2240
|
// Task agents report through plain files: an integer in `progress`, free
|
|
1921
|
-
// text in `notes.md
|
|
2241
|
+
// text in `notes.md`, and a `handoff` file of one-line facts. Pull them into
|
|
2242
|
+
// the record; true when anything changed.
|
|
1922
2243
|
function refreshTaskFiles(tasksDir, task) {
|
|
1923
2244
|
const dir = path.join(tasksDir, task.id);
|
|
1924
2245
|
let changed = false;
|
|
@@ -1943,6 +2264,8 @@ function refreshTaskFiles(tasksDir, task) {
|
|
|
1943
2264
|
catch {
|
|
1944
2265
|
/* no notes file yet */
|
|
1945
2266
|
}
|
|
2267
|
+
if (refreshHandoff(dir, task))
|
|
2268
|
+
changed = true;
|
|
1946
2269
|
return changed;
|
|
1947
2270
|
}
|
|
1948
2271
|
// "after:" entries may reference task ids or titles -- including titles of
|
|
@@ -2050,6 +2373,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2050
2373
|
prompt: task.prompt,
|
|
2051
2374
|
progressPath: path.join(relDir, 'progress'),
|
|
2052
2375
|
notesPath: path.join(relDir, 'notes.md'),
|
|
2376
|
+
handoffPath: path.join(relDir, 'handoff'),
|
|
2053
2377
|
depsSummary: ctx.depsSummary,
|
|
2054
2378
|
backend: ctx.backend,
|
|
2055
2379
|
// Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
|
|
@@ -2058,6 +2382,7 @@ async function runTaskAgentIn(ctx, task) {
|
|
|
2058
2382
|
: undefined),
|
|
2059
2383
|
deckContents,
|
|
2060
2384
|
quickReference: ctx.quickReference,
|
|
2385
|
+
plan: taskPlanOpts(ctx.deckDir, task.item, ctx.finishedWork),
|
|
2061
2386
|
siblings: ctx.siblings,
|
|
2062
2387
|
});
|
|
2063
2388
|
// No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
|
|
@@ -2196,6 +2521,9 @@ function startTask(ctx, task) {
|
|
|
2196
2521
|
.sorted()
|
|
2197
2522
|
.filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
|
|
2198
2523
|
.map((t) => ({ title: t.title, status: t.status })),
|
|
2524
|
+
// Not filtered to the live board like the siblings above: a task the user
|
|
2525
|
+
// has already checked off is exactly the work most likely to be rebuilt.
|
|
2526
|
+
finishedWork: planFinishedWork(ctx.sorted()),
|
|
2199
2527
|
onFeed: (entry) => ctx.onFeed(task, entry),
|
|
2200
2528
|
onRetry: (attempt) => ctx.onRetry(task, attempt),
|
|
2201
2529
|
onSignal: (signal) => {
|
|
@@ -2323,6 +2651,9 @@ function haltTask(task, children, stopRequested, touch) {
|
|
|
2323
2651
|
function createTaskStore(opts) {
|
|
2324
2652
|
const { deckDir, deckLabel, tasksDir, children } = opts;
|
|
2325
2653
|
const tasks = loadTasks(tasksDir);
|
|
2654
|
+
// Covers both a deck whose tasks predate the index and the interrupted-status
|
|
2655
|
+
// rewrites loadTasks just did.
|
|
2656
|
+
writeTaskIndex(tasksDir, tasks);
|
|
2326
2657
|
// Tasks the router asked to stop: their killed process must not read as a
|
|
2327
2658
|
// crash (no retry) and they finalize as interrupted, not failed.
|
|
2328
2659
|
const stopRequested = new Set();
|
|
@@ -2332,6 +2663,7 @@ function createTaskStore(opts) {
|
|
|
2332
2663
|
function touch(task) {
|
|
2333
2664
|
task.updatedAt = nowIso();
|
|
2334
2665
|
persistTaskFile(tasksDir, task);
|
|
2666
|
+
writeTaskIndex(tasksDir, tasks);
|
|
2335
2667
|
opts.onUpdate(task);
|
|
2336
2668
|
}
|
|
2337
2669
|
function runningCount() {
|
|
@@ -2404,6 +2736,7 @@ function createTaskStore(opts) {
|
|
|
2404
2736
|
title: directive.title,
|
|
2405
2737
|
prompt: directive.prompt,
|
|
2406
2738
|
after: resolveDeps(tasks, directive.after),
|
|
2739
|
+
item: directive.item,
|
|
2407
2740
|
status: 'waiting',
|
|
2408
2741
|
progress: 0,
|
|
2409
2742
|
notes: '',
|
|
@@ -2414,6 +2747,7 @@ function createTaskStore(opts) {
|
|
|
2414
2747
|
fs.mkdirSync(path.join(tasksDir, task.id), { recursive: true });
|
|
2415
2748
|
tasks.set(task.id, task);
|
|
2416
2749
|
persistTaskFile(tasksDir, task);
|
|
2750
|
+
writeTaskIndex(tasksDir, tasks);
|
|
2417
2751
|
opts.onUpdate(task);
|
|
2418
2752
|
maybeStart(task);
|
|
2419
2753
|
return task.id;
|
|
@@ -2428,6 +2762,21 @@ function createTaskStore(opts) {
|
|
|
2428
2762
|
touch(task);
|
|
2429
2763
|
return task;
|
|
2430
2764
|
}
|
|
2765
|
+
function pendingDurables() {
|
|
2766
|
+
return sorted().filter((t) => t.durablePending && t.durable);
|
|
2767
|
+
}
|
|
2768
|
+
// Every pending nomination clears on the next fence, promoted or not: the
|
|
2769
|
+
// router saw them all and made one pass of taste over them, and re-offering
|
|
2770
|
+
// the ones it passed over would just relitigate the same call every turn.
|
|
2771
|
+
// Lossy by design -- the handoff files on disk stay the record.
|
|
2772
|
+
function clearPendingDurables() {
|
|
2773
|
+
for (const task of tasks.values()) {
|
|
2774
|
+
if (!task.durablePending)
|
|
2775
|
+
continue;
|
|
2776
|
+
task.durablePending = false;
|
|
2777
|
+
persistTaskFile(tasksDir, task);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2431
2780
|
const pollTimer = setInterval(() => {
|
|
2432
2781
|
for (const task of tasks.values()) {
|
|
2433
2782
|
if (task.status !== 'running')
|
|
@@ -2445,6 +2794,7 @@ function createTaskStore(opts) {
|
|
|
2445
2794
|
persistTaskFile(tasksDir, task);
|
|
2446
2795
|
}
|
|
2447
2796
|
}
|
|
2797
|
+
writeTaskIndex(tasksDir, tasks);
|
|
2448
2798
|
}
|
|
2449
2799
|
// True when a fence body is the special token "all" / "*" (clear/stop
|
|
2450
2800
|
// everything, no per-task enumeration).
|
|
@@ -2484,6 +2834,8 @@ function createTaskStore(opts) {
|
|
|
2484
2834
|
acknowledge,
|
|
2485
2835
|
checkOff,
|
|
2486
2836
|
stop,
|
|
2837
|
+
pendingDurables,
|
|
2838
|
+
clearPendingDurables,
|
|
2487
2839
|
shutdown,
|
|
2488
2840
|
};
|
|
2489
2841
|
}
|
|
@@ -2579,6 +2931,7 @@ function asPromptTask(task) {
|
|
|
2579
2931
|
status: task.rejected ? 'rejected by user' : task.status,
|
|
2580
2932
|
progress: task.progress,
|
|
2581
2933
|
notes: task.notes,
|
|
2934
|
+
item: task.item,
|
|
2582
2935
|
error: task.status === 'failed' ? firstErrorLine(task.resultSummary) : undefined,
|
|
2583
2936
|
blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
|
|
2584
2937
|
};
|
|
@@ -2747,7 +3100,7 @@ function resolveFailure(result) {
|
|
|
2747
3100
|
// Assemble the full stateless prompt for one router turn: rules + deck
|
|
2748
3101
|
// context + transcript replay (minus log lines and the in-flight reply) +
|
|
2749
3102
|
// the live board + this turn's instruction.
|
|
2750
|
-
function routerTurnPrompt(ctx, instruction, selfMessageId) {
|
|
3103
|
+
function routerTurnPrompt(ctx, instruction, selfMessageId, plan) {
|
|
2751
3104
|
// Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
|
|
2752
3105
|
// ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
|
|
2753
3106
|
// largest one this serve builds).
|
|
@@ -2775,6 +3128,7 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
|
|
|
2775
3128
|
.sorted()
|
|
2776
3129
|
.filter((t) => !(t.acknowledged && isTerminal(t.status)))
|
|
2777
3130
|
.map(asPromptTask),
|
|
3131
|
+
plan: routerPlanOpts(ctx.deckDir, plan, ctx.taskStore.pendingDurables(), ctx.taskStore.sorted()),
|
|
2778
3132
|
instruction,
|
|
2779
3133
|
});
|
|
2780
3134
|
}
|
|
@@ -2881,7 +3235,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2881
3235
|
// block later re-emits "thinking".
|
|
2882
3236
|
let lastActivity = 'Thinking';
|
|
2883
3237
|
ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
|
|
2884
|
-
const prompt = routerTurnPrompt(ctx, instruction, message.id);
|
|
3238
|
+
const prompt = routerTurnPrompt(ctx, instruction, message.id, readPlanSnapshot(ctx.deckDir));
|
|
2885
3239
|
const backend = ctx.backend();
|
|
2886
3240
|
void runAgentTurn({
|
|
2887
3241
|
backend,
|
|
@@ -2939,11 +3293,24 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2939
3293
|
settleInterruptedTurn(ctx, message);
|
|
2940
3294
|
return;
|
|
2941
3295
|
}
|
|
2942
|
-
const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
|
|
3296
|
+
const { cleaned, directives, checkoffs, stops, plan } = extractDirectives(result.finalText);
|
|
2943
3297
|
if (result.ok && checkoffs.length > 0)
|
|
2944
3298
|
ctx.taskStore.checkOff(checkoffs);
|
|
2945
3299
|
if (result.ok && stops.length > 0)
|
|
2946
3300
|
ctx.taskStore.stop(stops);
|
|
3301
|
+
// Settle-time only, unlike task fences: nothing is waiting on the file,
|
|
3302
|
+
// and a half-streamed op line is a line that says something else.
|
|
3303
|
+
if (result.ok && plan) {
|
|
3304
|
+
const written = applyPlanFence(ctx.deckDir, plan, planOpSources(ctx.taskStore, ctx.log.messages));
|
|
3305
|
+
if (written === 'written')
|
|
3306
|
+
message.planUpdated = true;
|
|
3307
|
+
// Any fence that became ops is the router's answer to the pending
|
|
3308
|
+
// nominations, including one whose ops all no-oped -- it looked and
|
|
3309
|
+
// passed. A fence that never parsed decided nothing, so they stay
|
|
3310
|
+
// pending for the turn that redoes it.
|
|
3311
|
+
if (written !== 'dropped')
|
|
3312
|
+
ctx.taskStore.clearPendingDurables();
|
|
3313
|
+
}
|
|
2947
3314
|
// Drop directives from stale turns, any whose title matches a task
|
|
2948
3315
|
// already in flight (two runs reacting to the same ask), and any
|
|
2949
3316
|
// already spawned mid-stream by spawnCompletedTaskFences above --
|
|
@@ -2963,6 +3330,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
2963
3330
|
if (result.ok) {
|
|
2964
3331
|
message.text = cleaned;
|
|
2965
3332
|
message.status = 'done';
|
|
3333
|
+
trackStandingQuestions(ctx, cleaned, taskIds.length > 0);
|
|
2966
3334
|
}
|
|
2967
3335
|
else {
|
|
2968
3336
|
const failure = resolveFailure(result);
|
|
@@ -3003,6 +3371,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
|
|
|
3003
3371
|
status: message.status,
|
|
3004
3372
|
taskIds: message.taskIds ?? [],
|
|
3005
3373
|
errorDetail: message.errorDetail,
|
|
3374
|
+
planUpdated: message.planUpdated,
|
|
3006
3375
|
// Broadcast-only, never persisted: the client console.errors this so
|
|
3007
3376
|
// the whole provider response is inspectable without leaving the
|
|
3008
3377
|
// browser. Keeping it out of MessageRecord is the same call
|
|
@@ -3598,6 +3967,9 @@ export function createAgentServer(opts) {
|
|
|
3598
3967
|
const attachmentsDir = path.join(agentDir, 'attachments');
|
|
3599
3968
|
const messagesPath = path.join(agentDir, 'messages.json');
|
|
3600
3969
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
3970
|
+
// Read the plan opt-out now so the whole serve shares one answer -- see
|
|
3971
|
+
// planSession.
|
|
3972
|
+
planSession(deckDir);
|
|
3601
3973
|
// Warm the OpenRouter catalog now so the first pre-flight and the first
|
|
3602
3974
|
// popover open read a cache instead of paying for the fetch. Fire-and-forget
|
|
3603
3975
|
// by design -- nothing here depends on it, and it falls open if it fails.
|
package/dist/castleJson.d.ts
CHANGED