@worca/ui 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/main.bundle.js +2625 -1971
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +2 -0
- package/app/styles.css +1356 -6
- package/package.json +2 -2
- package/server/app.js +90 -0
- package/server/dispatch-defaults.js +5 -5
- package/server/dispatch-events-aggregator.js +27 -13
- package/server/dispatch-migration.js +35 -1
- package/server/events-jsonl-reader.js +93 -0
- package/server/file-access-aggregator.js +553 -0
- package/server/graph-query-aggregator.js +165 -0
- package/server/integrations/renderers.js +11 -0
- package/server/process-manager.js +86 -0
- package/server/project-routes.js +16 -3
- package/server/schemas/keys.json +5 -0
- package/server/template-prompts.js +136 -0
- package/server/templates-routes.js +303 -49
- package/server/watcher.js +122 -40
- package/server/ws-broadcaster.js +5 -4
- package/server/ws-message-router.js +23 -2
- package/server/ws-status-watcher.js +5 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph-query aggregator — reads pipeline.hook.graph_query events from a run's
|
|
3
|
+
* events.jsonl and turns them into live per-iteration query counts
|
|
4
|
+
* (graphify_invocations / crg_invocations / crg_tool_counts), assigned to the
|
|
5
|
+
* matching iteration in status.json by timestamp range.
|
|
6
|
+
*
|
|
7
|
+
* This is the live counterpart to the runner's completion-time tally: the
|
|
8
|
+
* runner writes the authoritative graphify_invocations / crg_invocations onto
|
|
9
|
+
* each iteration when the stage *completes*. For a still-running iteration that
|
|
10
|
+
* count is absent, so the graphify/CRG badges would otherwise stay blank until
|
|
11
|
+
* completion. This aggregator fills the gap by counting the live hook events —
|
|
12
|
+
* exactly like dispatch-events-aggregator does for skills/subagents — and only
|
|
13
|
+
* for iterations that don't already carry the runner's number (so the
|
|
14
|
+
* authoritative completion count is never clobbered).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
const GRAPH_QUERY_EVENT_TYPE = 'pipeline.hook.graph_query';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Classify a single parsed events.jsonl line. Returns the normalised graph-query
|
|
23
|
+
* entry if the line is a graph-query event, or null otherwise. Extracted so a
|
|
24
|
+
* combined single-pass reader (events-jsonl-reader.js) can share the exact same
|
|
25
|
+
* normalisation as the standalone reader below.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} e — a parsed events.jsonl object
|
|
28
|
+
* @returns {{engine, op, timestamp}|null}
|
|
29
|
+
*/
|
|
30
|
+
export function parseGraphQueryEventLine(e) {
|
|
31
|
+
if (!e || e.event_type !== GRAPH_QUERY_EVENT_TYPE) return null;
|
|
32
|
+
const payload = e.payload || {};
|
|
33
|
+
const engine = payload.engine;
|
|
34
|
+
if (engine !== 'graphify' && engine !== 'crg') return null;
|
|
35
|
+
return { engine, op: payload.op || '', timestamp: e.timestamp };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse events.jsonl and return only the graph-query events.
|
|
40
|
+
* Malformed lines are skipped so a corrupt event doesn't break the run view.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} eventsPath — absolute path to events.jsonl
|
|
43
|
+
* @returns {Array<{engine, op, timestamp}>}
|
|
44
|
+
*/
|
|
45
|
+
export function readGraphQueryEventsFromJsonl(eventsPath) {
|
|
46
|
+
if (!eventsPath || !existsSync(eventsPath)) return [];
|
|
47
|
+
let content;
|
|
48
|
+
try {
|
|
49
|
+
content = readFileSync(eventsPath, 'utf8');
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const line of content.split('\n')) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
let e;
|
|
57
|
+
try {
|
|
58
|
+
e = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const entry = parseGraphQueryEventLine(e);
|
|
63
|
+
if (entry) out.push(entry);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Given a list of graph-query events and a stages map from status.json, return
|
|
70
|
+
* a new stages map where each iteration that overlaps an event's timestamp gets
|
|
71
|
+
* live query counts — but ONLY when the iteration does not already carry the
|
|
72
|
+
* runner's authoritative count (i.e. the still-running iteration). Completed
|
|
73
|
+
* iterations are left exactly as the runner wrote them.
|
|
74
|
+
*
|
|
75
|
+
* Counts added per matching running iteration:
|
|
76
|
+
* graphify_invocations: <number> (graphify-engine events)
|
|
77
|
+
* crg_invocations: <number> (crg-engine events)
|
|
78
|
+
* crg_tool_counts: { <op>: <number> } (crg-engine, by tool — tooltip)
|
|
79
|
+
*
|
|
80
|
+
* Non-destructive: input stages object is shallow-copied; touched iterations
|
|
81
|
+
* get new objects. Existing iteration fields are preserved.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<{engine, op, timestamp}>} events
|
|
84
|
+
* @param {object} stages — status.stages
|
|
85
|
+
* @returns {object} enriched stages
|
|
86
|
+
*/
|
|
87
|
+
export function assignGraphQueryCountsToIterations(events, stages) {
|
|
88
|
+
if (!stages || typeof stages !== 'object') return stages;
|
|
89
|
+
if (!events || events.length === 0) return stages;
|
|
90
|
+
|
|
91
|
+
// Bucket events into iterations by timestamp. Bucket key: `${stageKey}|${num}`.
|
|
92
|
+
const buckets = new Map();
|
|
93
|
+
for (const ev of events) {
|
|
94
|
+
if (!ev.timestamp) continue;
|
|
95
|
+
const eventTime = Date.parse(ev.timestamp);
|
|
96
|
+
if (Number.isNaN(eventTime)) continue;
|
|
97
|
+
|
|
98
|
+
let matched = false;
|
|
99
|
+
for (const [stageKey, stage] of Object.entries(stages)) {
|
|
100
|
+
const iterations = stage?.iterations;
|
|
101
|
+
if (!Array.isArray(iterations)) continue;
|
|
102
|
+
for (const iter of iterations) {
|
|
103
|
+
// Only fill counts for iterations the runner hasn't tallied yet —
|
|
104
|
+
// a present number means the stage completed and is authoritative.
|
|
105
|
+
if (typeof iter.graphify_invocations === 'number') continue;
|
|
106
|
+
if (typeof iter.crg_invocations === 'number') continue;
|
|
107
|
+
const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
|
|
108
|
+
if (Number.isNaN(start)) continue;
|
|
109
|
+
const end = iter.completed_at
|
|
110
|
+
? Date.parse(iter.completed_at)
|
|
111
|
+
: Number.POSITIVE_INFINITY;
|
|
112
|
+
if (eventTime >= start && eventTime <= end) {
|
|
113
|
+
const key = `${stageKey}|${iter.number}`;
|
|
114
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
115
|
+
buckets.get(key).push(ev);
|
|
116
|
+
matched = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (matched) break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (buckets.size === 0) return stages;
|
|
125
|
+
|
|
126
|
+
const enrichedStages = { ...stages };
|
|
127
|
+
for (const [key, bucketEvents] of buckets) {
|
|
128
|
+
const [stageKey, iterNumStr] = key.split('|');
|
|
129
|
+
const iterNum = Number(iterNumStr);
|
|
130
|
+
const stage = enrichedStages[stageKey];
|
|
131
|
+
if (!stage) continue;
|
|
132
|
+
const counts = tally(bucketEvents);
|
|
133
|
+
const newIterations = stage.iterations.map((iter) =>
|
|
134
|
+
iter.number === iterNum ? { ...iter, ...counts } : iter,
|
|
135
|
+
);
|
|
136
|
+
enrichedStages[stageKey] = { ...stage, iterations: newIterations };
|
|
137
|
+
}
|
|
138
|
+
return enrichedStages;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Tally graph-query events into the count shape the badges read.
|
|
143
|
+
*
|
|
144
|
+
* @param {Array<{engine, op}>} events
|
|
145
|
+
* @returns {{graphify_invocations, crg_invocations, crg_tool_counts}}
|
|
146
|
+
*/
|
|
147
|
+
function tally(events) {
|
|
148
|
+
let graphify = 0;
|
|
149
|
+
let crg = 0;
|
|
150
|
+
const crgToolCounts = {};
|
|
151
|
+
for (const ev of events) {
|
|
152
|
+
if (ev.engine === 'graphify') {
|
|
153
|
+
graphify += 1;
|
|
154
|
+
} else if (ev.engine === 'crg') {
|
|
155
|
+
crg += 1;
|
|
156
|
+
const op = ev.op || 'unknown';
|
|
157
|
+
crgToolCounts[op] = (crgToolCounts[op] || 0) + 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
graphify_invocations: graphify,
|
|
162
|
+
crg_invocations: crg,
|
|
163
|
+
crg_tool_counts: crgToolCounts,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -134,6 +134,16 @@ function renderGitPrCreated(envelope) {
|
|
|
134
134
|
return mdMsg(parts.join('\n'), 'info');
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
function renderGitPrDeferred(envelope) {
|
|
138
|
+
const p = envelope.payload;
|
|
139
|
+
const parts = [`\u{1F7E1} **Run:** \`${runId(envelope)}\``];
|
|
140
|
+
parts.push(
|
|
141
|
+
` **PR deferred:** branch \`${p.head_branch}\` pushed — open the run and click **Create PR** to publish.`,
|
|
142
|
+
);
|
|
143
|
+
if (p.pr_title) parts.push(` **Title:** ${p.pr_title}`);
|
|
144
|
+
return mdMsg(parts.join('\n'), 'warning');
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
function renderGitPrMerged(envelope) {
|
|
138
148
|
const p = envelope.payload;
|
|
139
149
|
const parts = [`\u2705 **PR merged:** [#${p.pr_number}](${p.pr_url})`];
|
|
@@ -560,6 +570,7 @@ const EVENT_RENDERERS = {
|
|
|
560
570
|
'pipeline.stage.completed': renderStageCompleted,
|
|
561
571
|
'pipeline.stage.interrupted': renderStageInterrupted,
|
|
562
572
|
'pipeline.git.pr_created': renderGitPrCreated,
|
|
573
|
+
'pipeline.git.pr_deferred': renderGitPrDeferred,
|
|
563
574
|
'pipeline.git.pr_merged': renderGitPrMerged,
|
|
564
575
|
'pipeline.circuit_breaker.tripped': renderCbTripped,
|
|
565
576
|
'pipeline.cost.budget_warning': renderCostBudgetWarning,
|
|
@@ -541,6 +541,9 @@ export class ProcessManager {
|
|
|
541
541
|
if (opts.mloops && opts.mloops > 1) {
|
|
542
542
|
args.push('--mloops', String(opts.mloops));
|
|
543
543
|
}
|
|
544
|
+
if (opts.maxBeads != null) {
|
|
545
|
+
args.push('--max-beads', String(opts.maxBeads));
|
|
546
|
+
}
|
|
544
547
|
if (opts.planFile) {
|
|
545
548
|
args.push('--plan', opts.planFile);
|
|
546
549
|
}
|
|
@@ -554,6 +557,89 @@ export class ProcessManager {
|
|
|
554
557
|
const env = { ...process.env };
|
|
555
558
|
delete env.CLAUDECODE;
|
|
556
559
|
|
|
560
|
+
// run_worktree.py is a *launcher*: it performs all setup (PR-metadata
|
|
561
|
+
// fetch, worktree checkout, registry write) and only exits 0 after the
|
|
562
|
+
// real pipeline wrote its status.json (_await_pipeline_startup), printing
|
|
563
|
+
// diagnostics to stderr and exiting non-zero on any failure. Its detached
|
|
564
|
+
// grandchild redirects its own stdio to a log file, so it never holds our
|
|
565
|
+
// pipes open. That makes the launcher's exit code an authoritative
|
|
566
|
+
// success/failure signal — wait for it instead of guessing with a fixed
|
|
567
|
+
// timer. The old 2s timer resolved "started" before slow failures (e.g. a
|
|
568
|
+
// PR fetch followed by a worktree collision) surfaced, so the UI reported
|
|
569
|
+
// success while nothing ran. run_pipeline.py (in-place / resume) is NOT a
|
|
570
|
+
// launcher — it *is* the long-lived pipeline — so it keeps the timer path.
|
|
571
|
+
const isFireAndForget = scriptRel === worktreeScriptRel;
|
|
572
|
+
|
|
573
|
+
if (isFireAndForget) {
|
|
574
|
+
return new Promise((resolve, reject) => {
|
|
575
|
+
const child = spawn('python3', args, {
|
|
576
|
+
detached: true,
|
|
577
|
+
// Capture stderr to surface the launcher's error; ignore stdin/stdout
|
|
578
|
+
// (stdout carries only run_id+path and is not needed).
|
|
579
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
580
|
+
cwd,
|
|
581
|
+
env,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
let settled = false;
|
|
585
|
+
let stderr = '';
|
|
586
|
+
const STDERR_CAP = 8192;
|
|
587
|
+
// Generous safety net: the launcher normally exits within seconds, but
|
|
588
|
+
// a hung gh/network call shouldn't block the launch request forever.
|
|
589
|
+
const hardCap = setTimeout(() => {
|
|
590
|
+
if (settled) return;
|
|
591
|
+
settled = true;
|
|
592
|
+
child.removeAllListeners('error');
|
|
593
|
+
child.removeAllListeners('exit');
|
|
594
|
+
cleanupPromptFile(promptFilePath);
|
|
595
|
+
const err = new Error(
|
|
596
|
+
'Pipeline launcher did not finish within 180s — aborting launch',
|
|
597
|
+
);
|
|
598
|
+
err.code = 'spawn_timeout';
|
|
599
|
+
reject(err);
|
|
600
|
+
}, 180000);
|
|
601
|
+
hardCap.unref?.();
|
|
602
|
+
|
|
603
|
+
if (child.stderr) {
|
|
604
|
+
child.stderr.on('data', (d) => {
|
|
605
|
+
if (stderr.length < STDERR_CAP) stderr += d.toString();
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
child.on('error', (spawnErr) => {
|
|
610
|
+
if (settled) return;
|
|
611
|
+
settled = true;
|
|
612
|
+
clearTimeout(hardCap);
|
|
613
|
+
cleanupPromptFile(promptFilePath);
|
|
614
|
+
const err = new Error(
|
|
615
|
+
`Failed to start pipeline: ${spawnErr.message}`,
|
|
616
|
+
);
|
|
617
|
+
err.code = 'spawn_error';
|
|
618
|
+
reject(err);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
child.on('exit', (code, signal) => {
|
|
622
|
+
if (settled) return;
|
|
623
|
+
settled = true;
|
|
624
|
+
clearTimeout(hardCap);
|
|
625
|
+
cleanupPromptFile(promptFilePath);
|
|
626
|
+
if (code === 0) {
|
|
627
|
+
child.unref();
|
|
628
|
+
resolve({ pid: child.pid });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const detail = stderr.trim().split('\n').slice(-6).join('\n').trim();
|
|
632
|
+
const reason =
|
|
633
|
+
code !== null ? `exit code ${code}` : `signal ${signal}`;
|
|
634
|
+
const err = new Error(
|
|
635
|
+
`Pipeline failed to start (${reason})${detail ? `:\n${detail}` : ''}`,
|
|
636
|
+
);
|
|
637
|
+
err.code = 'spawn_error';
|
|
638
|
+
reject(err);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
557
643
|
return new Promise((resolve, reject) => {
|
|
558
644
|
const child = spawn('python3', args, {
|
|
559
645
|
detached: true,
|
package/server/project-routes.js
CHANGED
|
@@ -53,7 +53,7 @@ import { validateSettingsPayload } from './settings-validator.js';
|
|
|
53
53
|
import { createTemplatesRoutes } from './templates-routes.js';
|
|
54
54
|
import { isVersionBehind } from './version-check.js';
|
|
55
55
|
import { getVersionInfo } from './versions.js';
|
|
56
|
-
import { discoverRuns } from './watcher.js';
|
|
56
|
+
import { discoverRuns, discoverRunsAsync } from './watcher.js';
|
|
57
57
|
import {
|
|
58
58
|
checkWorcaInstalled,
|
|
59
59
|
readProjectWorcaVersion,
|
|
@@ -365,7 +365,12 @@ export function createProjectScopedRoutes({
|
|
|
365
365
|
// GET /api/projects/:projectId/runs — list runs for this project
|
|
366
366
|
router.get('/runs', requireWorcaDir, async (req, res) => {
|
|
367
367
|
try {
|
|
368
|
-
|
|
368
|
+
// List/sidebar path: scan off the event loop (async) and skip
|
|
369
|
+
// events.jsonl enrichment entirely — neither the run list nor the sidebar
|
|
370
|
+
// render dispatch_events / graph-query counts (issue #296).
|
|
371
|
+
const runs = await discoverRunsAsync(req.project.worcaDir, {
|
|
372
|
+
enrich: false,
|
|
373
|
+
});
|
|
369
374
|
const default_branch = getDefaultBranch(req.project.projectRoot);
|
|
370
375
|
|
|
371
376
|
const { getBeadsCounts } = req.app.locals;
|
|
@@ -924,6 +929,7 @@ export function createProjectScopedRoutes({
|
|
|
924
929
|
planFile,
|
|
925
930
|
msize,
|
|
926
931
|
mloops,
|
|
932
|
+
maxBeads,
|
|
927
933
|
branch,
|
|
928
934
|
template,
|
|
929
935
|
} = body;
|
|
@@ -1013,6 +1019,10 @@ export function createProjectScopedRoutes({
|
|
|
1013
1019
|
mloops != null
|
|
1014
1020
|
? Math.max(1, Math.min(10, Math.round(Number(mloops))))
|
|
1015
1021
|
: 1;
|
|
1022
|
+
const maxBeadsVal =
|
|
1023
|
+
maxBeads != null
|
|
1024
|
+
? Math.max(0, Math.min(50, Math.round(Number(maxBeads))))
|
|
1025
|
+
: undefined;
|
|
1016
1026
|
|
|
1017
1027
|
// Atomically check global cap and start pipeline under lock
|
|
1018
1028
|
await launchLock.withLock(async () => {
|
|
@@ -1040,6 +1050,7 @@ export function createProjectScopedRoutes({
|
|
|
1040
1050
|
prompt: hasPrompt ? prompt : undefined,
|
|
1041
1051
|
msize: msizeVal,
|
|
1042
1052
|
mloops: mloopsVal,
|
|
1053
|
+
maxBeads: maxBeadsVal,
|
|
1043
1054
|
planFile: hasPlan ? planFile.trim() : undefined,
|
|
1044
1055
|
branch: branch || undefined,
|
|
1045
1056
|
template: template || undefined,
|
|
@@ -1709,7 +1720,9 @@ export function createProjectScopedRoutes({
|
|
|
1709
1720
|
// Reads per-iteration token_usage from each run's status.json.
|
|
1710
1721
|
router.get('/costs', requireWorcaDir, (req, res) => {
|
|
1711
1722
|
const { worcaDir } = req.project;
|
|
1712
|
-
|
|
1723
|
+
// Costs read only per-iteration token_usage from status.json — no
|
|
1724
|
+
// events.jsonl enrichment needed (issue #296).
|
|
1725
|
+
const runs = discoverRuns(worcaDir, { enrich: false });
|
|
1713
1726
|
const tokenData = {};
|
|
1714
1727
|
|
|
1715
1728
|
for (const run of runs) {
|
package/server/schemas/keys.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective prompt model for the Pipelines editor "Prompts" tab.
|
|
3
|
+
*
|
|
4
|
+
* For each stage prompt file (agent `*.md` and user-prompt `*.block.md`) this
|
|
5
|
+
* resolves what the pipeline actually runs, classifying each file as one of:
|
|
6
|
+
*
|
|
7
|
+
* - 'builtin' — the template has no overlay; the built-in core prompt is used
|
|
8
|
+
* unchanged (a fallback).
|
|
9
|
+
* - 'pipeline' — the template overlay replaces the built-in prompt entirely
|
|
10
|
+
* (default mode, or an explicit `<!-- replace -->`).
|
|
11
|
+
* - 'extends' — the overlay is `<!-- append -->`; it merges into the built-in
|
|
12
|
+
* via `## Override: <Section>` blocks (each appending, or
|
|
13
|
+
* overwriting when the block opens with `<!-- replace -->`), or
|
|
14
|
+
* a raw trailing append when there are no override blocks.
|
|
15
|
+
*
|
|
16
|
+
* Mode/override parsing mirrors src/worca/orchestrator/overlay.py so the editor
|
|
17
|
+
* preview matches what the runtime actually assembles.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const OVERLAY_NAME_RE = /^[a-z0-9._-]{1,64}\.(md|block\.md)$/;
|
|
24
|
+
const APPEND_TAG = '<!-- append -->';
|
|
25
|
+
const REPLACE_TAG = '<!-- replace -->';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split an `<!-- append -->` overlay body into `## Override: <Section>` blocks.
|
|
29
|
+
* Mirrors overlay.py:_parse_overrides. A block whose first non-blank line is
|
|
30
|
+
* `<!-- replace -->` overwrites the matching built-in section; otherwise it
|
|
31
|
+
* appends. Returns `[{ section, mode: 'append'|'overwrite', body }]`.
|
|
32
|
+
*/
|
|
33
|
+
export function parseOverrides(content) {
|
|
34
|
+
const parts = content.split(/^(## Override:\s*.+)$/m);
|
|
35
|
+
const overrides = [];
|
|
36
|
+
for (let i = 1; i < parts.length - 1; i += 2) {
|
|
37
|
+
const headingLine = parts[i];
|
|
38
|
+
const section = headingLine.replace(/^##\s*Override:\s*/, '').trim();
|
|
39
|
+
const lines = parts[i + 1].split('\n');
|
|
40
|
+
const kept = [];
|
|
41
|
+
let foundReplace = false;
|
|
42
|
+
let replace = false;
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!foundReplace && line.trim() === REPLACE_TAG) {
|
|
45
|
+
replace = true;
|
|
46
|
+
foundReplace = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
kept.push(line);
|
|
50
|
+
}
|
|
51
|
+
overrides.push({
|
|
52
|
+
section,
|
|
53
|
+
mode: replace ? 'overwrite' : 'append',
|
|
54
|
+
body: kept.join('\n').trim(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return overrides;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a single file given its built-in (core) and overlay contents.
|
|
62
|
+
* Either may be null. Returns the per-file model the editor renders.
|
|
63
|
+
*/
|
|
64
|
+
export function classifyPromptFile(name, coreContent, overlayContent) {
|
|
65
|
+
const role = name.endsWith('.block.md') ? 'block' : 'agent';
|
|
66
|
+
const base = { name, role };
|
|
67
|
+
|
|
68
|
+
if (overlayContent == null) {
|
|
69
|
+
return { ...base, source: 'builtin', content: coreContent ?? '' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stripped = overlayContent.replace(/^\s+/, '');
|
|
73
|
+
|
|
74
|
+
if (stripped.startsWith(APPEND_TAG)) {
|
|
75
|
+
const body = stripped.slice(APPEND_TAG.length);
|
|
76
|
+
const overrides = parseOverrides(body);
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
source: 'extends',
|
|
80
|
+
builtin: coreContent ?? '',
|
|
81
|
+
contributions: overrides,
|
|
82
|
+
rawAppend: overrides.length === 0 ? body.trim() : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const content = stripped.startsWith(REPLACE_TAG)
|
|
87
|
+
? stripped.slice(REPLACE_TAG.length).trim()
|
|
88
|
+
: overlayContent.trim();
|
|
89
|
+
return { ...base, source: 'pipeline', content };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read every prompt file under a directory (filtered to overlay-name shape),
|
|
94
|
+
* returning `{ filename: content }`. Missing dir → empty object.
|
|
95
|
+
*/
|
|
96
|
+
function readPromptDir(dir) {
|
|
97
|
+
const out = {};
|
|
98
|
+
if (!dir || !existsSync(dir)) return out;
|
|
99
|
+
let names;
|
|
100
|
+
try {
|
|
101
|
+
names = readdirSync(dir);
|
|
102
|
+
} catch {
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
for (const f of names) {
|
|
106
|
+
if (!OVERLAY_NAME_RE.test(f)) continue;
|
|
107
|
+
try {
|
|
108
|
+
out[f] = readFileSync(join(dir, f), 'utf8');
|
|
109
|
+
} catch {
|
|
110
|
+
/* skip unreadable files */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the prompts model for a template.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} coreDir - built-in core prompts dir (.../agents/core)
|
|
120
|
+
* @param {string} overlayDir - template overlay dir (.../<template>/agents); may not exist
|
|
121
|
+
* @returns {object} `{ filename: model }` over the union of core+overlay files
|
|
122
|
+
*/
|
|
123
|
+
export function buildPromptsModel(coreDir, overlayDir) {
|
|
124
|
+
const core = readPromptDir(coreDir);
|
|
125
|
+
const overlay = readPromptDir(overlayDir);
|
|
126
|
+
const names = new Set([...Object.keys(core), ...Object.keys(overlay)]);
|
|
127
|
+
const model = {};
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
model[name] = classifyPromptFile(
|
|
130
|
+
name,
|
|
131
|
+
Object.hasOwn(core, name) ? core[name] : null,
|
|
132
|
+
Object.hasOwn(overlay, name) ? overlay[name] : null,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return model;
|
|
136
|
+
}
|