@neotx/core 0.1.0-alpha.0 → 0.1.0-alpha.10
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/README.md +567 -0
- package/dist/index.d.ts +390 -121
- package/dist/index.js +2520 -733
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
package/dist/index.js
CHANGED
|
@@ -74,51 +74,37 @@ import path2 from "path";
|
|
|
74
74
|
// src/agents/resolver.ts
|
|
75
75
|
function resolveAgent(config, builtIns) {
|
|
76
76
|
const extendsName = config.extends ?? (builtIns.has(config.name) && config.extends === void 0 ? config.name : void 0);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const baseTols = base.tools ?? [];
|
|
89
|
-
const newTools = config.tools.filter((t) => t !== "$inherited");
|
|
90
|
-
tools2 = [...baseTols, ...newTools];
|
|
91
|
-
} else {
|
|
92
|
-
tools2 = config.tools;
|
|
93
|
-
}
|
|
94
|
-
} else {
|
|
95
|
-
tools2 = base.tools ?? [];
|
|
96
|
-
}
|
|
97
|
-
let prompt2;
|
|
98
|
-
if (config.prompt) {
|
|
99
|
-
prompt2 = config.prompt;
|
|
100
|
-
} else {
|
|
101
|
-
prompt2 = base.prompt ?? "";
|
|
102
|
-
}
|
|
103
|
-
if (config.promptAppend) {
|
|
104
|
-
prompt2 = `${prompt2}
|
|
105
|
-
|
|
106
|
-
${config.promptAppend}`;
|
|
107
|
-
}
|
|
108
|
-
const definition2 = {
|
|
109
|
-
description: config.description ?? base.description ?? "",
|
|
110
|
-
prompt: prompt2,
|
|
111
|
-
tools: tools2,
|
|
112
|
-
model: config.model ?? base.model ?? "sonnet"
|
|
113
|
-
};
|
|
114
|
-
return {
|
|
115
|
-
name: config.name,
|
|
116
|
-
definition: definition2,
|
|
117
|
-
sandbox: config.sandbox ?? base.sandbox ?? "readonly",
|
|
118
|
-
...config.maxTurns !== void 0 ? { maxTurns: config.maxTurns } : base.maxTurns !== void 0 ? { maxTurns: base.maxTurns } : {},
|
|
119
|
-
source: config.name === extendsName && !config.extends ? "built-in" : "extended"
|
|
120
|
-
};
|
|
77
|
+
if (extendsName !== void 0) {
|
|
78
|
+
return resolveExtendedAgent(config, extendsName, builtIns);
|
|
79
|
+
}
|
|
80
|
+
return resolveCustomAgent(config);
|
|
81
|
+
}
|
|
82
|
+
function resolveExtendedAgent(config, extendsName, builtIns) {
|
|
83
|
+
const base = builtIns.get(extendsName);
|
|
84
|
+
if (!base) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Agent "${config.name}" extends "${extendsName}", but no built-in agent with that name exists.`
|
|
87
|
+
);
|
|
121
88
|
}
|
|
89
|
+
const tools = mergeTools(config.tools, base.tools);
|
|
90
|
+
const prompt = mergePrompt(config.prompt, config.promptAppend, base.prompt);
|
|
91
|
+
const mcpServers = mergeMcpServerNames(base.mcpServers, config.mcpServers);
|
|
92
|
+
const definition = {
|
|
93
|
+
description: config.description ?? base.description ?? "",
|
|
94
|
+
prompt,
|
|
95
|
+
tools,
|
|
96
|
+
model: config.model ?? base.model ?? "sonnet",
|
|
97
|
+
...mcpServers.length > 0 ? { mcpServers } : {}
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
name: config.name,
|
|
101
|
+
definition,
|
|
102
|
+
sandbox: config.sandbox ?? base.sandbox ?? "readonly",
|
|
103
|
+
...config.maxTurns !== void 0 ? { maxTurns: config.maxTurns } : base.maxTurns !== void 0 ? { maxTurns: base.maxTurns } : {},
|
|
104
|
+
source: config.name === extendsName && !config.extends ? "built-in" : "extended"
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function resolveCustomAgent(config) {
|
|
122
108
|
if (!config.description) {
|
|
123
109
|
throw new Error(
|
|
124
110
|
`Agent "${config.name}" has no "extends" and no "description". Add a 'description' field to the agent YAML.`
|
|
@@ -155,7 +141,8 @@ ${config.promptAppend}`;
|
|
|
155
141
|
description: config.description,
|
|
156
142
|
prompt,
|
|
157
143
|
tools,
|
|
158
|
-
model: config.model
|
|
144
|
+
model: config.model,
|
|
145
|
+
...config.mcpServers?.length ? { mcpServers: config.mcpServers } : {}
|
|
159
146
|
};
|
|
160
147
|
return {
|
|
161
148
|
name: config.name,
|
|
@@ -165,6 +152,27 @@ ${config.promptAppend}`;
|
|
|
165
152
|
source: "custom"
|
|
166
153
|
};
|
|
167
154
|
}
|
|
155
|
+
function mergeTools(configTools, baseTools) {
|
|
156
|
+
if (!configTools) return baseTools ?? [];
|
|
157
|
+
if (configTools.includes("$inherited")) {
|
|
158
|
+
const newTools = configTools.filter((t) => t !== "$inherited");
|
|
159
|
+
return [...baseTools ?? [], ...newTools];
|
|
160
|
+
}
|
|
161
|
+
return configTools;
|
|
162
|
+
}
|
|
163
|
+
function mergePrompt(configPrompt, promptAppend, basePrompt) {
|
|
164
|
+
let prompt = configPrompt ?? basePrompt ?? "";
|
|
165
|
+
if (promptAppend) {
|
|
166
|
+
prompt = `${prompt}
|
|
167
|
+
|
|
168
|
+
${promptAppend}`;
|
|
169
|
+
}
|
|
170
|
+
return prompt;
|
|
171
|
+
}
|
|
172
|
+
function mergeMcpServerNames(base, override) {
|
|
173
|
+
if (!base?.length && !override?.length) return [];
|
|
174
|
+
return [.../* @__PURE__ */ new Set([...base ?? [], ...override ?? []])];
|
|
175
|
+
}
|
|
168
176
|
|
|
169
177
|
// src/agents/registry.ts
|
|
170
178
|
var AgentRegistry = class {
|
|
@@ -438,9 +446,6 @@ function getSupervisorDir(name) {
|
|
|
438
446
|
function getSupervisorStatePath(name) {
|
|
439
447
|
return path3.join(getSupervisorDir(name), "state.json");
|
|
440
448
|
}
|
|
441
|
-
function getSupervisorMemoryPath(name) {
|
|
442
|
-
return path3.join(getSupervisorDir(name), "memory.md");
|
|
443
|
-
}
|
|
444
449
|
function getSupervisorActivityPath(name) {
|
|
445
450
|
return path3.join(getSupervisorDir(name), "activity.jsonl");
|
|
446
451
|
}
|
|
@@ -470,22 +475,22 @@ var mcpServerConfigSchema = z2.discriminatedUnion("type", [
|
|
|
470
475
|
httpMcpServerSchema,
|
|
471
476
|
stdioMcpServerSchema
|
|
472
477
|
]);
|
|
478
|
+
var gitStrategySchema = z2.enum(["pr", "branch"]).default("branch");
|
|
473
479
|
var repoConfigSchema = z2.object({
|
|
474
480
|
path: z2.string(),
|
|
475
481
|
name: z2.string().optional(),
|
|
476
482
|
defaultBranch: z2.string().default("main"),
|
|
477
483
|
branchPrefix: z2.string().default("feat"),
|
|
478
484
|
pushRemote: z2.string().default("origin"),
|
|
479
|
-
|
|
480
|
-
prBaseBranch: z2.string().optional()
|
|
485
|
+
gitStrategy: gitStrategySchema
|
|
481
486
|
});
|
|
482
487
|
var globalConfigSchema = z2.object({
|
|
483
488
|
repos: z2.array(repoConfigSchema).default([]),
|
|
484
489
|
concurrency: z2.object({
|
|
485
490
|
maxSessions: z2.number().default(5),
|
|
486
|
-
maxPerRepo: z2.number().default(
|
|
491
|
+
maxPerRepo: z2.number().default(4),
|
|
487
492
|
queueMax: z2.number().default(50)
|
|
488
|
-
}).default({ maxSessions: 5, maxPerRepo:
|
|
493
|
+
}).default({ maxSessions: 5, maxPerRepo: 4, queueMax: 50 }),
|
|
489
494
|
budget: z2.object({
|
|
490
495
|
dailyCapUsd: z2.number().default(500),
|
|
491
496
|
alertThresholdPct: z2.number().default(80)
|
|
@@ -496,8 +501,9 @@ var globalConfigSchema = z2.object({
|
|
|
496
501
|
}).default({ maxRetries: 3, backoffBaseMs: 3e4 }),
|
|
497
502
|
sessions: z2.object({
|
|
498
503
|
initTimeoutMs: z2.number().default(12e4),
|
|
499
|
-
maxDurationMs: z2.number().default(36e5)
|
|
500
|
-
|
|
504
|
+
maxDurationMs: z2.number().default(36e5),
|
|
505
|
+
dir: z2.string().default("/tmp/neo-sessions")
|
|
506
|
+
}).default({ initTimeoutMs: 12e4, maxDurationMs: 36e5, dir: "/tmp/neo-sessions" }),
|
|
501
507
|
webhooks: z2.array(
|
|
502
508
|
z2.object({
|
|
503
509
|
url: z2.string().url(),
|
|
@@ -509,20 +515,30 @@ var globalConfigSchema = z2.object({
|
|
|
509
515
|
supervisor: z2.object({
|
|
510
516
|
port: z2.number().default(7777),
|
|
511
517
|
secret: z2.string().optional(),
|
|
512
|
-
idleIntervalMs: z2.number().default(6e4),
|
|
513
518
|
heartbeatTimeoutMs: z2.number().default(3e5),
|
|
514
519
|
maxConsecutiveFailures: z2.number().default(3),
|
|
515
520
|
maxEventsPerSec: z2.number().default(10),
|
|
516
521
|
dailyCapUsd: z2.number().default(50),
|
|
522
|
+
/** How often consolidation runs (ms) */
|
|
523
|
+
consolidationIntervalMs: z2.number().default(3e5),
|
|
524
|
+
/** How often compaction runs (ms) */
|
|
525
|
+
compactionIntervalMs: z2.number().default(36e5),
|
|
526
|
+
/** Safety timeout for waitForWork (ms) */
|
|
527
|
+
eventTimeoutMs: z2.number().default(3e5),
|
|
517
528
|
instructions: z2.string().optional()
|
|
518
529
|
}).default({
|
|
519
530
|
port: 7777,
|
|
520
|
-
idleIntervalMs: 6e4,
|
|
521
531
|
heartbeatTimeoutMs: 3e5,
|
|
522
532
|
maxConsecutiveFailures: 3,
|
|
523
533
|
maxEventsPerSec: 10,
|
|
524
|
-
dailyCapUsd: 50
|
|
534
|
+
dailyCapUsd: 50,
|
|
535
|
+
consolidationIntervalMs: 3e5,
|
|
536
|
+
compactionIntervalMs: 36e5,
|
|
537
|
+
eventTimeoutMs: 3e5
|
|
525
538
|
}),
|
|
539
|
+
memory: z2.object({
|
|
540
|
+
embeddings: z2.boolean().default(true)
|
|
541
|
+
}).default({ embeddings: true }),
|
|
526
542
|
mcpServers: z2.record(z2.string(), mcpServerConfigSchema).optional(),
|
|
527
543
|
claudeCodePath: z2.string().optional(),
|
|
528
544
|
idempotency: z2.object({
|
|
@@ -536,7 +552,7 @@ var DEFAULT_GLOBAL_CONFIG = {
|
|
|
536
552
|
repos: [],
|
|
537
553
|
concurrency: {
|
|
538
554
|
maxSessions: 5,
|
|
539
|
-
maxPerRepo:
|
|
555
|
+
maxPerRepo: 4,
|
|
540
556
|
queueMax: 50
|
|
541
557
|
},
|
|
542
558
|
budget: {
|
|
@@ -618,18 +634,40 @@ async function listReposFromGlobalConfig() {
|
|
|
618
634
|
}
|
|
619
635
|
|
|
620
636
|
// src/cost/journal.ts
|
|
621
|
-
import { appendFile,
|
|
637
|
+
import { appendFile, readFile as readFile3 } from "fs/promises";
|
|
638
|
+
|
|
639
|
+
// src/shared/date.ts
|
|
622
640
|
import path5 from "path";
|
|
641
|
+
function toDateKey(date) {
|
|
642
|
+
return date.toISOString().slice(0, 10);
|
|
643
|
+
}
|
|
644
|
+
function fileForDate(date, prefix, dir) {
|
|
645
|
+
const yyyy = date.getUTCFullYear();
|
|
646
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
647
|
+
return path5.join(dir, `${prefix}-${yyyy}-${mm}.jsonl`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/shared/fs.ts
|
|
651
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
652
|
+
async function ensureDir(dirPath, cache) {
|
|
653
|
+
if (cache?.has(dirPath)) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
await mkdir2(dirPath, { recursive: true });
|
|
657
|
+
cache?.add(dirPath);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/cost/journal.ts
|
|
623
661
|
var CostJournal = class {
|
|
624
662
|
dir;
|
|
625
|
-
|
|
663
|
+
dirCache = /* @__PURE__ */ new Set();
|
|
626
664
|
dayCache = null;
|
|
627
665
|
constructor(options) {
|
|
628
666
|
this.dir = options.dir;
|
|
629
667
|
}
|
|
630
668
|
async append(entry) {
|
|
631
|
-
await this.
|
|
632
|
-
const file =
|
|
669
|
+
await ensureDir(this.dir, this.dirCache);
|
|
670
|
+
const file = fileForDate(new Date(entry.timestamp), "cost", this.dir);
|
|
633
671
|
await appendFile(file, `${JSON.stringify(entry)}
|
|
634
672
|
`, "utf-8");
|
|
635
673
|
this.dayCache = null;
|
|
@@ -640,7 +678,7 @@ var CostJournal = class {
|
|
|
640
678
|
if (this.dayCache?.key === dayKey) {
|
|
641
679
|
return this.dayCache.total;
|
|
642
680
|
}
|
|
643
|
-
const file =
|
|
681
|
+
const file = fileForDate(d, "cost", this.dir);
|
|
644
682
|
let total = 0;
|
|
645
683
|
try {
|
|
646
684
|
const content = await readFile3(file, "utf-8");
|
|
@@ -657,20 +695,7 @@ var CostJournal = class {
|
|
|
657
695
|
this.dayCache = { key: dayKey, total };
|
|
658
696
|
return total;
|
|
659
697
|
}
|
|
660
|
-
fileForDate(date) {
|
|
661
|
-
const yyyy = date.getUTCFullYear();
|
|
662
|
-
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
663
|
-
return path5.join(this.dir, `cost-${yyyy}-${mm}.jsonl`);
|
|
664
|
-
}
|
|
665
|
-
async ensureDir() {
|
|
666
|
-
if (this.dirCreated) return;
|
|
667
|
-
await mkdir2(this.dir, { recursive: true });
|
|
668
|
-
this.dirCreated = true;
|
|
669
|
-
}
|
|
670
698
|
};
|
|
671
|
-
function toDateKey(date) {
|
|
672
|
-
return date.toISOString().slice(0, 10);
|
|
673
|
-
}
|
|
674
699
|
|
|
675
700
|
// src/events/emitter.ts
|
|
676
701
|
import { EventEmitter } from "events";
|
|
@@ -711,36 +736,29 @@ var NeoEventEmitter = class {
|
|
|
711
736
|
};
|
|
712
737
|
|
|
713
738
|
// src/events/journal.ts
|
|
714
|
-
import { appendFile as appendFile2
|
|
715
|
-
import path6 from "path";
|
|
739
|
+
import { appendFile as appendFile2 } from "fs/promises";
|
|
716
740
|
var EventJournal = class {
|
|
717
741
|
dir;
|
|
718
|
-
|
|
742
|
+
dirCache = /* @__PURE__ */ new Set();
|
|
719
743
|
constructor(options) {
|
|
720
744
|
this.dir = options.dir;
|
|
721
745
|
}
|
|
722
746
|
async append(event) {
|
|
723
|
-
await this.
|
|
724
|
-
const file =
|
|
747
|
+
await ensureDir(this.dir, this.dirCache);
|
|
748
|
+
const file = fileForDate(new Date(event.timestamp), "events", this.dir);
|
|
725
749
|
await appendFile2(file, `${JSON.stringify(event)}
|
|
726
750
|
`, "utf-8");
|
|
727
751
|
}
|
|
728
|
-
fileForDate(date) {
|
|
729
|
-
const yyyy = date.getUTCFullYear();
|
|
730
|
-
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
731
|
-
return path6.join(this.dir, `events-${yyyy}-${mm}.jsonl`);
|
|
732
|
-
}
|
|
733
|
-
async ensureDir() {
|
|
734
|
-
if (this.dirCreated) return;
|
|
735
|
-
await mkdir3(this.dir, { recursive: true });
|
|
736
|
-
this.dirCreated = true;
|
|
737
|
-
}
|
|
738
752
|
};
|
|
739
753
|
|
|
740
754
|
// src/events/webhook.ts
|
|
741
|
-
import { createHmac } from "crypto";
|
|
755
|
+
import { createHmac, randomUUID } from "crypto";
|
|
756
|
+
var RETRY_EVENT_TYPES = /* @__PURE__ */ new Set(["session:complete", "session:fail", "budget:alert"]);
|
|
757
|
+
var RETRY_MAX_ATTEMPTS = 3;
|
|
758
|
+
var RETRY_BASE_DELAY_MS = 500;
|
|
742
759
|
var WebhookDispatcher = class {
|
|
743
760
|
webhooks;
|
|
761
|
+
pending = /* @__PURE__ */ new Set();
|
|
744
762
|
constructor(webhooks) {
|
|
745
763
|
this.webhooks = webhooks;
|
|
746
764
|
}
|
|
@@ -749,8 +767,10 @@ var WebhookDispatcher = class {
|
|
|
749
767
|
for (const webhook of this.webhooks) {
|
|
750
768
|
if (!matchesFilter(event.type, webhook.events)) continue;
|
|
751
769
|
const payload = {
|
|
770
|
+
id: randomUUID(),
|
|
752
771
|
version: 1,
|
|
753
|
-
event:
|
|
772
|
+
event: event.type,
|
|
773
|
+
payload: toSerializable(event),
|
|
754
774
|
source: "neo",
|
|
755
775
|
deliveredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
756
776
|
};
|
|
@@ -761,16 +781,45 @@ var WebhookDispatcher = class {
|
|
|
761
781
|
if (webhook.secret) {
|
|
762
782
|
headers["X-Neo-Signature"] = sign(body, webhook.secret);
|
|
763
783
|
}
|
|
764
|
-
|
|
784
|
+
if (RETRY_EVENT_TYPES.has(event.type)) {
|
|
785
|
+
const p = sendWithRetry(webhook.url, headers, body, webhook.timeoutMs).catch(() => {
|
|
786
|
+
}).finally(() => this.pending.delete(p));
|
|
787
|
+
this.pending.add(p);
|
|
788
|
+
} else {
|
|
789
|
+
fetch(webhook.url, {
|
|
790
|
+
method: "POST",
|
|
791
|
+
headers,
|
|
792
|
+
body,
|
|
793
|
+
signal: AbortSignal.timeout(webhook.timeoutMs)
|
|
794
|
+
}).catch(() => {
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** Wait for all pending terminal webhook deliveries to complete. */
|
|
800
|
+
async flush() {
|
|
801
|
+
if (this.pending.size === 0) return;
|
|
802
|
+
await Promise.allSettled([...this.pending]);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
async function sendWithRetry(url, headers, body, timeoutMs) {
|
|
806
|
+
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
|
807
|
+
try {
|
|
808
|
+
const res = await fetch(url, {
|
|
765
809
|
method: "POST",
|
|
766
810
|
headers,
|
|
767
811
|
body,
|
|
768
|
-
signal: AbortSignal.timeout(
|
|
769
|
-
}).catch(() => {
|
|
812
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
770
813
|
});
|
|
814
|
+
if (res.ok) return;
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
if (attempt < RETRY_MAX_ATTEMPTS) {
|
|
818
|
+
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
819
|
+
await new Promise((resolve4) => setTimeout(resolve4, delay));
|
|
771
820
|
}
|
|
772
821
|
}
|
|
773
|
-
}
|
|
822
|
+
}
|
|
774
823
|
function matchesFilter(eventType, filters) {
|
|
775
824
|
if (!filters || filters.length === 0) return true;
|
|
776
825
|
return filters.some((f) => {
|
|
@@ -791,71 +840,145 @@ function toSerializable(event) {
|
|
|
791
840
|
return obj;
|
|
792
841
|
}
|
|
793
842
|
|
|
794
|
-
// src/isolation/
|
|
843
|
+
// src/isolation/clone.ts
|
|
795
844
|
import { execFile } from "child_process";
|
|
796
|
-
import {
|
|
845
|
+
import { existsSync as existsSync2 } from "fs";
|
|
846
|
+
import { mkdir as mkdir3, readdir as readdir2, rm } from "fs/promises";
|
|
847
|
+
import { dirname, resolve } from "path";
|
|
797
848
|
import { promisify } from "util";
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
849
|
+
var execFileAsync = promisify(execFile);
|
|
850
|
+
var GIT_TIMEOUT = 6e4;
|
|
851
|
+
async function createSessionClone(options) {
|
|
852
|
+
const repoPath = resolve(options.repoPath);
|
|
853
|
+
const sessionDir = resolve(options.sessionDir);
|
|
854
|
+
await mkdir3(dirname(sessionDir), { recursive: true });
|
|
855
|
+
const remoteUrl = await execFileAsync("git", ["config", "--get", "remote.origin.url"], {
|
|
856
|
+
cwd: repoPath,
|
|
857
|
+
timeout: GIT_TIMEOUT
|
|
858
|
+
}).then(({ stdout }) => stdout.trim()).catch(() => "");
|
|
859
|
+
const cloneSource = remoteUrl || repoPath;
|
|
860
|
+
await execFileAsync("git", ["clone", "--branch", options.baseBranch, cloneSource, sessionDir], {
|
|
861
|
+
timeout: GIT_TIMEOUT
|
|
806
862
|
});
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (
|
|
814
|
-
|
|
863
|
+
if (options.branch !== options.baseBranch) {
|
|
864
|
+
const branchExists = await execFileAsync(
|
|
865
|
+
"git",
|
|
866
|
+
["ls-remote", "--heads", "origin", options.branch],
|
|
867
|
+
{ cwd: sessionDir, timeout: GIT_TIMEOUT }
|
|
868
|
+
).then(({ stdout }) => stdout.trim().length > 0).catch(() => false);
|
|
869
|
+
if (branchExists) {
|
|
870
|
+
await execFileAsync("git", ["fetch", "origin", options.branch], {
|
|
871
|
+
cwd: sessionDir,
|
|
872
|
+
timeout: GIT_TIMEOUT
|
|
873
|
+
});
|
|
874
|
+
await execFileAsync("git", ["checkout", "-b", options.branch, `origin/${options.branch}`], {
|
|
875
|
+
cwd: sessionDir,
|
|
876
|
+
timeout: GIT_TIMEOUT
|
|
877
|
+
});
|
|
878
|
+
} else {
|
|
879
|
+
await execFileAsync("git", ["checkout", "-b", options.branch], {
|
|
880
|
+
cwd: sessionDir,
|
|
881
|
+
timeout: GIT_TIMEOUT
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return { path: sessionDir, branch: options.branch, repoPath };
|
|
886
|
+
}
|
|
887
|
+
async function removeSessionClone(sessionPath) {
|
|
888
|
+
const absPath = resolve(sessionPath);
|
|
889
|
+
if (!existsSync2(absPath)) {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
await rm(absPath, { recursive: true, force: true });
|
|
893
|
+
}
|
|
894
|
+
async function listSessionClones(sessionsBaseDir) {
|
|
895
|
+
const absBase = resolve(sessionsBaseDir);
|
|
896
|
+
if (!existsSync2(absBase)) {
|
|
897
|
+
return [];
|
|
898
|
+
}
|
|
899
|
+
const entries = await readdir2(absBase, { withFileTypes: true });
|
|
900
|
+
const clones = [];
|
|
901
|
+
for (const entry of entries) {
|
|
902
|
+
if (!entry.isDirectory()) continue;
|
|
903
|
+
const clonePath = resolve(absBase, entry.name);
|
|
904
|
+
try {
|
|
905
|
+
const { stdout: branchOut } = await execFileAsync(
|
|
906
|
+
"git",
|
|
907
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
908
|
+
{
|
|
909
|
+
cwd: clonePath,
|
|
910
|
+
timeout: GIT_TIMEOUT
|
|
911
|
+
}
|
|
912
|
+
);
|
|
913
|
+
let repoPath = clonePath;
|
|
914
|
+
try {
|
|
915
|
+
const { stdout: originUrl } = await execFileAsync(
|
|
916
|
+
"git",
|
|
917
|
+
["config", "--get", "remote.origin.url"],
|
|
918
|
+
{ cwd: clonePath, timeout: GIT_TIMEOUT }
|
|
919
|
+
);
|
|
920
|
+
const url = originUrl.trim();
|
|
921
|
+
if (url) repoPath = resolve(clonePath, url);
|
|
922
|
+
} catch {
|
|
923
|
+
}
|
|
924
|
+
clones.push({
|
|
925
|
+
path: clonePath,
|
|
926
|
+
branch: branchOut.trim(),
|
|
927
|
+
repoPath
|
|
928
|
+
});
|
|
929
|
+
} catch {
|
|
815
930
|
}
|
|
816
931
|
}
|
|
932
|
+
return clones;
|
|
817
933
|
}
|
|
818
934
|
|
|
819
935
|
// src/isolation/git.ts
|
|
820
|
-
|
|
821
|
-
|
|
936
|
+
import { execFile as execFile2 } from "child_process";
|
|
937
|
+
import { resolve as resolve2 } from "path";
|
|
938
|
+
import { promisify as promisify2 } from "util";
|
|
939
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
940
|
+
var GIT_TIMEOUT2 = 6e4;
|
|
822
941
|
async function git(repoPath, args) {
|
|
823
|
-
const { stdout } = await
|
|
824
|
-
cwd:
|
|
825
|
-
timeout:
|
|
942
|
+
const { stdout } = await execFileAsync2("git", args, {
|
|
943
|
+
cwd: resolve2(repoPath),
|
|
944
|
+
timeout: GIT_TIMEOUT2
|
|
826
945
|
});
|
|
827
946
|
return stdout.trim();
|
|
828
947
|
}
|
|
829
948
|
async function createBranch(repoPath, branch, baseBranch) {
|
|
830
|
-
await
|
|
949
|
+
await git(repoPath, ["branch", branch, baseBranch]);
|
|
831
950
|
}
|
|
832
951
|
async function pushBranch(repoPath, branch, remote) {
|
|
833
|
-
await
|
|
952
|
+
await git(repoPath, ["push", remote, branch]);
|
|
834
953
|
}
|
|
835
954
|
async function fetchRemote(repoPath, remote) {
|
|
836
|
-
await
|
|
955
|
+
await git(repoPath, ["fetch", remote]);
|
|
837
956
|
}
|
|
838
957
|
async function deleteBranch(repoPath, branch) {
|
|
839
|
-
await
|
|
958
|
+
await git(repoPath, ["branch", "-D", branch]);
|
|
840
959
|
}
|
|
841
960
|
async function getCurrentBranch(repoPath) {
|
|
842
|
-
return
|
|
961
|
+
return git(repoPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
843
962
|
}
|
|
844
|
-
function getBranchName(config, runId) {
|
|
963
|
+
function getBranchName(config, runId, branch) {
|
|
964
|
+
if (branch) return branch;
|
|
845
965
|
const prefix = config.branchPrefix ?? "feat";
|
|
846
966
|
const sanitized = runId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
847
967
|
return `${prefix}/run-${sanitized}`;
|
|
848
968
|
}
|
|
969
|
+
async function pushSessionBranch(sessionPath, branch, remote) {
|
|
970
|
+
await git(sessionPath, ["push", "-u", remote, branch]);
|
|
971
|
+
}
|
|
849
972
|
|
|
850
973
|
// src/isolation/sandbox.ts
|
|
851
|
-
import { resolve as
|
|
974
|
+
import { resolve as resolve3 } from "path";
|
|
852
975
|
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "NotebookEdit"]);
|
|
853
|
-
function buildSandboxConfig(agent,
|
|
976
|
+
function buildSandboxConfig(agent, sessionPath) {
|
|
854
977
|
const isWritable = agent.sandbox === "writable";
|
|
855
|
-
const
|
|
978
|
+
const absSession = sessionPath ? resolve3(sessionPath) : void 0;
|
|
856
979
|
const allowedTools = isWritable ? agent.definition.tools : agent.definition.tools.filter((t) => !WRITE_TOOLS.has(t));
|
|
857
|
-
const readablePaths =
|
|
858
|
-
const writablePaths = isWritable &&
|
|
980
|
+
const readablePaths = absSession ? [absSession] : [];
|
|
981
|
+
const writablePaths = isWritable && absSession ? [absSession] : [];
|
|
859
982
|
return {
|
|
860
983
|
allowedTools,
|
|
861
984
|
readablePaths,
|
|
@@ -864,112 +987,9 @@ function buildSandboxConfig(agent, worktreePath) {
|
|
|
864
987
|
};
|
|
865
988
|
}
|
|
866
989
|
|
|
867
|
-
// src/isolation/worktree.ts
|
|
868
|
-
import { execFile as execFile2 } from "child_process";
|
|
869
|
-
import { existsSync as existsSync2 } from "fs";
|
|
870
|
-
import { readdir as readdir2, rm } from "fs/promises";
|
|
871
|
-
import { resolve as resolve3 } from "path";
|
|
872
|
-
import { promisify as promisify2 } from "util";
|
|
873
|
-
var execFileAsync2 = promisify2(execFile2);
|
|
874
|
-
var GIT_TIMEOUT2 = 6e4;
|
|
875
|
-
async function createWorktree(options) {
|
|
876
|
-
const repoPath = resolve3(options.repoPath);
|
|
877
|
-
const worktreeDir = resolve3(options.worktreeDir);
|
|
878
|
-
await withGitLock(repoPath, async () => {
|
|
879
|
-
await execFileAsync2(
|
|
880
|
-
"git",
|
|
881
|
-
["worktree", "add", "-b", options.branch, worktreeDir, options.baseBranch],
|
|
882
|
-
{ cwd: repoPath, timeout: GIT_TIMEOUT2 }
|
|
883
|
-
);
|
|
884
|
-
});
|
|
885
|
-
await execFileAsync2("git", ["config", "core.hooksPath", "/dev/null"], {
|
|
886
|
-
cwd: worktreeDir,
|
|
887
|
-
timeout: GIT_TIMEOUT2
|
|
888
|
-
});
|
|
889
|
-
return { path: worktreeDir, branch: options.branch, repoPath };
|
|
890
|
-
}
|
|
891
|
-
async function removeWorktree(worktreePath) {
|
|
892
|
-
const absPath = resolve3(worktreePath);
|
|
893
|
-
if (!existsSync2(absPath)) {
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
const repoPath = await findRepoForWorktree(absPath);
|
|
897
|
-
if (repoPath) {
|
|
898
|
-
await withGitLock(repoPath, async () => {
|
|
899
|
-
try {
|
|
900
|
-
await execFileAsync2("git", ["worktree", "remove", absPath, "--force"], {
|
|
901
|
-
cwd: repoPath,
|
|
902
|
-
timeout: GIT_TIMEOUT2
|
|
903
|
-
});
|
|
904
|
-
} catch {
|
|
905
|
-
await rm(absPath, { recursive: true, force: true });
|
|
906
|
-
await execFileAsync2("git", ["worktree", "prune"], {
|
|
907
|
-
cwd: repoPath,
|
|
908
|
-
timeout: GIT_TIMEOUT2
|
|
909
|
-
}).catch(() => {
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
await execFileAsync2("git", ["update-index", "--refresh"], {
|
|
913
|
-
cwd: repoPath,
|
|
914
|
-
timeout: GIT_TIMEOUT2
|
|
915
|
-
}).catch(() => {
|
|
916
|
-
});
|
|
917
|
-
});
|
|
918
|
-
} else {
|
|
919
|
-
await rm(absPath, { recursive: true, force: true });
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
async function listWorktrees(repoPath) {
|
|
923
|
-
const absRepoPath = resolve3(repoPath);
|
|
924
|
-
const { stdout } = await execFileAsync2("git", ["worktree", "list", "--porcelain"], {
|
|
925
|
-
cwd: absRepoPath,
|
|
926
|
-
timeout: GIT_TIMEOUT2
|
|
927
|
-
});
|
|
928
|
-
const worktrees = [];
|
|
929
|
-
let current;
|
|
930
|
-
for (const line of stdout.split("\n")) {
|
|
931
|
-
if (line.startsWith("worktree ")) {
|
|
932
|
-
if (current) {
|
|
933
|
-
worktrees.push({ ...current, repoPath: absRepoPath });
|
|
934
|
-
}
|
|
935
|
-
current = { path: line.slice(9), branch: "" };
|
|
936
|
-
} else if (line.startsWith("branch ") && current) {
|
|
937
|
-
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
if (current) {
|
|
941
|
-
worktrees.push({ ...current, repoPath: absRepoPath });
|
|
942
|
-
}
|
|
943
|
-
return worktrees;
|
|
944
|
-
}
|
|
945
|
-
async function cleanupOrphanedWorktrees(worktreeBaseDir) {
|
|
946
|
-
const absBase = resolve3(worktreeBaseDir);
|
|
947
|
-
if (!existsSync2(absBase)) {
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
const entries = await readdir2(absBase, { withFileTypes: true });
|
|
951
|
-
for (const entry of entries) {
|
|
952
|
-
if (!entry.isDirectory()) continue;
|
|
953
|
-
const worktreePath = resolve3(absBase, entry.name);
|
|
954
|
-
await removeWorktree(worktreePath);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
async function findRepoForWorktree(worktreePath) {
|
|
958
|
-
try {
|
|
959
|
-
const { stdout } = await execFileAsync2("git", ["rev-parse", "--git-common-dir"], {
|
|
960
|
-
cwd: worktreePath,
|
|
961
|
-
timeout: GIT_TIMEOUT2
|
|
962
|
-
});
|
|
963
|
-
const gitCommonDir = resolve3(worktreePath, stdout.trim());
|
|
964
|
-
return resolve3(gitCommonDir, "..");
|
|
965
|
-
} catch {
|
|
966
|
-
return void 0;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
|
|
970
990
|
// src/middleware/audit-log.ts
|
|
971
991
|
import { appendFile as appendFile3, mkdir as mkdir4 } from "fs/promises";
|
|
972
|
-
import
|
|
992
|
+
import path6 from "path";
|
|
973
993
|
var DEFAULT_FLUSH_INTERVAL_MS = 500;
|
|
974
994
|
var DEFAULT_FLUSH_SIZE = 20;
|
|
975
995
|
function auditLog(options) {
|
|
@@ -983,7 +1003,7 @@ function auditLog(options) {
|
|
|
983
1003
|
let dirCreated = false;
|
|
984
1004
|
const buffers = /* @__PURE__ */ new Map();
|
|
985
1005
|
let flushTimer;
|
|
986
|
-
async function
|
|
1006
|
+
async function ensureDir2() {
|
|
987
1007
|
if (!dirCreated) {
|
|
988
1008
|
await mkdir4(dir, { recursive: true });
|
|
989
1009
|
dirCreated = true;
|
|
@@ -991,10 +1011,10 @@ function auditLog(options) {
|
|
|
991
1011
|
}
|
|
992
1012
|
async function flushAll() {
|
|
993
1013
|
if (buffers.size === 0) return;
|
|
994
|
-
await
|
|
1014
|
+
await ensureDir2();
|
|
995
1015
|
const writes = [];
|
|
996
1016
|
for (const [sessionId, lines] of buffers) {
|
|
997
|
-
const filePath =
|
|
1017
|
+
const filePath = path6.join(dir, `${sessionId}.jsonl`);
|
|
998
1018
|
writes.push(appendFile3(filePath, lines.join(""), "utf-8"));
|
|
999
1019
|
}
|
|
1000
1020
|
buffers.clear();
|
|
@@ -1003,8 +1023,8 @@ function auditLog(options) {
|
|
|
1003
1023
|
async function flushSession(sessionId) {
|
|
1004
1024
|
const lines = buffers.get(sessionId);
|
|
1005
1025
|
if (!lines || lines.length === 0) return;
|
|
1006
|
-
await
|
|
1007
|
-
const filePath =
|
|
1026
|
+
await ensureDir2();
|
|
1027
|
+
const filePath = path6.join(dir, `${sessionId}.jsonl`);
|
|
1008
1028
|
await appendFile3(filePath, lines.join(""), "utf-8");
|
|
1009
1029
|
buffers.delete(sessionId);
|
|
1010
1030
|
}
|
|
@@ -1169,10 +1189,116 @@ function loopDetection(options) {
|
|
|
1169
1189
|
}
|
|
1170
1190
|
|
|
1171
1191
|
// src/orchestrator.ts
|
|
1172
|
-
import { randomUUID } from "crypto";
|
|
1173
|
-
import { existsSync as
|
|
1174
|
-
import { mkdir as
|
|
1175
|
-
import
|
|
1192
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1193
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1194
|
+
import { mkdir as mkdir6, readFile as readFile7 } from "fs/promises";
|
|
1195
|
+
import path11 from "path";
|
|
1196
|
+
|
|
1197
|
+
// src/orchestrator/run-store.ts
|
|
1198
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1199
|
+
import { mkdir as mkdir5, readdir as readdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
|
|
1200
|
+
import path7 from "path";
|
|
1201
|
+
|
|
1202
|
+
// src/shared/process.ts
|
|
1203
|
+
function isProcessAlive(pid) {
|
|
1204
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
process.kill(pid, 0);
|
|
1209
|
+
return true;
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
if (error instanceof Error && "code" in error && error.code === "EPERM") {
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/orchestrator/run-store.ts
|
|
1219
|
+
var ORPHAN_GRACE_PERIOD_MS = 3e4;
|
|
1220
|
+
var RunStore = class {
|
|
1221
|
+
runsDir;
|
|
1222
|
+
createdDirs = /* @__PURE__ */ new Set();
|
|
1223
|
+
constructor(options = {}) {
|
|
1224
|
+
this.runsDir = options.runsDir ?? getRunsDir();
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Persist a run to disk. Creates the repo subdirectory if needed.
|
|
1228
|
+
* Fails silently — run persistence is non-critical.
|
|
1229
|
+
*/
|
|
1230
|
+
async persistRun(run) {
|
|
1231
|
+
try {
|
|
1232
|
+
const slug = toRepoSlug({ path: run.repo });
|
|
1233
|
+
const repoDir = getRepoRunsDir(slug);
|
|
1234
|
+
if (!this.createdDirs.has(repoDir)) {
|
|
1235
|
+
await mkdir5(repoDir, { recursive: true });
|
|
1236
|
+
this.createdDirs.add(repoDir);
|
|
1237
|
+
}
|
|
1238
|
+
const filePath = path7.join(repoDir, `${run.runId}.json`);
|
|
1239
|
+
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
1240
|
+
} catch {
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Find all runs that were left in "running" state but whose process died.
|
|
1245
|
+
* Returns them so the caller can emit failure events and update status.
|
|
1246
|
+
*/
|
|
1247
|
+
async recoverOrphanedRuns() {
|
|
1248
|
+
if (!existsSync3(this.runsDir)) return [];
|
|
1249
|
+
const orphaned = [];
|
|
1250
|
+
try {
|
|
1251
|
+
const jsonFiles = await this.collectRunFiles();
|
|
1252
|
+
for (const filePath of jsonFiles) {
|
|
1253
|
+
const run = await this.recoverRunIfOrphaned(filePath);
|
|
1254
|
+
if (run) orphaned.push(run);
|
|
1255
|
+
}
|
|
1256
|
+
} catch {
|
|
1257
|
+
}
|
|
1258
|
+
return orphaned;
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Collect all .json run files from the runs directory tree.
|
|
1262
|
+
* Searches both top-level and repo subdirectories.
|
|
1263
|
+
*/
|
|
1264
|
+
async collectRunFiles() {
|
|
1265
|
+
const entries = await readdir3(this.runsDir, { withFileTypes: true });
|
|
1266
|
+
const jsonFiles = [];
|
|
1267
|
+
for (const entry of entries) {
|
|
1268
|
+
if (entry.isDirectory()) {
|
|
1269
|
+
const subDir = path7.join(this.runsDir, entry.name);
|
|
1270
|
+
const subFiles = await readdir3(subDir);
|
|
1271
|
+
for (const f of subFiles) {
|
|
1272
|
+
if (f.endsWith(".json")) jsonFiles.push(path7.join(subDir, f));
|
|
1273
|
+
}
|
|
1274
|
+
} else if (entry.name.endsWith(".json")) {
|
|
1275
|
+
jsonFiles.push(path7.join(this.runsDir, entry.name));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return jsonFiles;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Check if a run file represents an orphaned run.
|
|
1282
|
+
* If so, update its status to "failed" and return it.
|
|
1283
|
+
*/
|
|
1284
|
+
async recoverRunIfOrphaned(filePath) {
|
|
1285
|
+
const content = await readFile4(filePath, "utf-8");
|
|
1286
|
+
const run = JSON.parse(content);
|
|
1287
|
+
if (run.status !== "running") return null;
|
|
1288
|
+
if (run.pid && run.pid === process.pid) return null;
|
|
1289
|
+
if (run.pid && isProcessAlive(run.pid)) return null;
|
|
1290
|
+
const ageMs = Date.now() - new Date(run.createdAt).getTime();
|
|
1291
|
+
if (ageMs < ORPHAN_GRACE_PERIOD_MS) return null;
|
|
1292
|
+
run.status = "failed";
|
|
1293
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1294
|
+
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
1295
|
+
return run;
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
// src/runner/session-executor.ts
|
|
1300
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1301
|
+
import path8 from "path";
|
|
1176
1302
|
|
|
1177
1303
|
// src/runner/output-parser.ts
|
|
1178
1304
|
function extractJson(raw) {
|
|
@@ -1190,37 +1316,61 @@ function extractJson(raw) {
|
|
|
1190
1316
|
}
|
|
1191
1317
|
return void 0;
|
|
1192
1318
|
}
|
|
1319
|
+
var PR_URL_REGEX = /^PR_URL:\s*(https?:\/\/\S+)/m;
|
|
1320
|
+
function extractPrUrl(raw) {
|
|
1321
|
+
const match = raw.match(PR_URL_REGEX);
|
|
1322
|
+
if (!match?.[1]) return void 0;
|
|
1323
|
+
const prUrl = match[1];
|
|
1324
|
+
const numberMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
1325
|
+
if (numberMatch?.[1]) {
|
|
1326
|
+
return { prUrl, prNumber: Number.parseInt(numberMatch[1], 10) };
|
|
1327
|
+
}
|
|
1328
|
+
return { prUrl };
|
|
1329
|
+
}
|
|
1193
1330
|
function parseOutput(raw, schema) {
|
|
1331
|
+
const prInfo = extractPrUrl(raw);
|
|
1332
|
+
const base = { rawOutput: raw };
|
|
1333
|
+
if (prInfo) {
|
|
1334
|
+
base.prUrl = prInfo.prUrl;
|
|
1335
|
+
if (prInfo.prNumber !== void 0) {
|
|
1336
|
+
base.prNumber = prInfo.prNumber;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1194
1339
|
if (!schema) {
|
|
1195
|
-
return
|
|
1340
|
+
return base;
|
|
1196
1341
|
}
|
|
1197
1342
|
const extracted = extractJson(raw);
|
|
1198
1343
|
if (extracted === void 0) {
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
parseError: "Failed to extract JSON from output"
|
|
1202
|
-
};
|
|
1344
|
+
base.parseError = "Failed to extract JSON from output";
|
|
1345
|
+
return base;
|
|
1203
1346
|
}
|
|
1204
1347
|
const result = schema.safeParse(extracted);
|
|
1205
1348
|
if (!result.success) {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
parseError: `Schema validation failed: ${result.error.message}`
|
|
1209
|
-
};
|
|
1349
|
+
base.parseError = `Schema validation failed: ${result.error.message}`;
|
|
1350
|
+
return base;
|
|
1210
1351
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
output: result.data
|
|
1214
|
-
};
|
|
1352
|
+
base.output = result.data;
|
|
1353
|
+
return base;
|
|
1215
1354
|
}
|
|
1216
1355
|
|
|
1217
|
-
// src/
|
|
1356
|
+
// src/sdk-types.ts
|
|
1218
1357
|
function isInitMessage(msg) {
|
|
1219
1358
|
return msg.type === "system" && msg.subtype === "init";
|
|
1220
1359
|
}
|
|
1221
1360
|
function isResultMessage(msg) {
|
|
1222
1361
|
return msg.type === "result";
|
|
1223
1362
|
}
|
|
1363
|
+
function isAssistantMessage(msg) {
|
|
1364
|
+
return msg.type === "assistant" && !msg.subtype;
|
|
1365
|
+
}
|
|
1366
|
+
function isToolUseMessage(msg) {
|
|
1367
|
+
return msg.type === "assistant" && msg.subtype === "tool_use";
|
|
1368
|
+
}
|
|
1369
|
+
function isToolResultMessage(msg) {
|
|
1370
|
+
return msg.type === "assistant" && msg.subtype === "tool_result";
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/runner/session.ts
|
|
1224
1374
|
function checkAborted(signal) {
|
|
1225
1375
|
if (signal.aborted) {
|
|
1226
1376
|
const reason = signal.reason;
|
|
@@ -1232,8 +1382,36 @@ function toSessionError(error, isTimeout, sessionId) {
|
|
|
1232
1382
|
const message = error instanceof Error ? error.message : String(error);
|
|
1233
1383
|
return new SessionError(message, isTimeout ? "timeout" : "unknown", sessionId);
|
|
1234
1384
|
}
|
|
1385
|
+
function buildQueryOptions(options) {
|
|
1386
|
+
const { sessionPath, sandboxConfig } = options;
|
|
1387
|
+
const queryOptions = {
|
|
1388
|
+
// Always pass cwd: session clone for writable agents, repo root for readonly.
|
|
1389
|
+
// Without this, readonly agents default to process.cwd() and may write to main tree.
|
|
1390
|
+
cwd: sessionPath ?? options.repoPath,
|
|
1391
|
+
// maxTurns: agent.maxTurns,
|
|
1392
|
+
allowedTools: sandboxConfig.allowedTools,
|
|
1393
|
+
// Workers run detached without a TTY — bypass interactive permission prompts.
|
|
1394
|
+
// Required pair: permissionMode alone is not enough, SDK also needs the flag.
|
|
1395
|
+
permissionMode: "bypassPermissions",
|
|
1396
|
+
allowDangerouslySkipPermissions: true,
|
|
1397
|
+
// Load project-level CLAUDE.md so agents inherit project rules and conventions.
|
|
1398
|
+
settingSources: ["user", "project", "local"],
|
|
1399
|
+
// Don't persist agent sessions — they are ephemeral clones.
|
|
1400
|
+
persistSession: false
|
|
1401
|
+
};
|
|
1402
|
+
if (options.resumeSessionId) {
|
|
1403
|
+
queryOptions.resume = options.resumeSessionId;
|
|
1404
|
+
}
|
|
1405
|
+
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
|
|
1406
|
+
queryOptions.mcpServers = options.mcpServers;
|
|
1407
|
+
}
|
|
1408
|
+
if (options.env && Object.keys(options.env).length > 0) {
|
|
1409
|
+
queryOptions.env = { ...process.env, ...options.env };
|
|
1410
|
+
}
|
|
1411
|
+
return queryOptions;
|
|
1412
|
+
}
|
|
1235
1413
|
async function runSession(options) {
|
|
1236
|
-
const {
|
|
1414
|
+
const { prompt, initTimeoutMs, maxDurationMs, onEvent } = options;
|
|
1237
1415
|
const startTime = Date.now();
|
|
1238
1416
|
let sessionId = "";
|
|
1239
1417
|
const abortController = new AbortController();
|
|
@@ -1245,19 +1423,7 @@ async function runSession(options) {
|
|
|
1245
1423
|
}, maxDurationMs);
|
|
1246
1424
|
try {
|
|
1247
1425
|
const sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
1248
|
-
const queryOptions =
|
|
1249
|
-
// Always pass cwd: worktree for writable agents, repo root for readonly.
|
|
1250
|
-
// Without this, readonly agents default to process.cwd() and may write to main tree.
|
|
1251
|
-
cwd: worktreePath ?? options.repoPath,
|
|
1252
|
-
maxTurns: agent.maxTurns,
|
|
1253
|
-
allowedTools: sandboxConfig.allowedTools
|
|
1254
|
-
};
|
|
1255
|
-
if (options.resumeSessionId) {
|
|
1256
|
-
queryOptions.resume = options.resumeSessionId;
|
|
1257
|
-
}
|
|
1258
|
-
if (options.mcpServers?.length) {
|
|
1259
|
-
queryOptions.mcpServers = options.mcpServers;
|
|
1260
|
-
}
|
|
1426
|
+
const queryOptions = buildQueryOptions(options);
|
|
1261
1427
|
let output = "";
|
|
1262
1428
|
let costUsd = 0;
|
|
1263
1429
|
let turnCount = 0;
|
|
@@ -1375,47 +1541,728 @@ async function runWithRecovery(options) {
|
|
|
1375
1541
|
throw new Error("Recovery failed: unreachable");
|
|
1376
1542
|
}
|
|
1377
1543
|
|
|
1378
|
-
// src/
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
})
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1544
|
+
// src/runner/session-executor.ts
|
|
1545
|
+
var INSTRUCTIONS_PATH = ".neo/INSTRUCTIONS.md";
|
|
1546
|
+
async function loadRepoInstructions(repoPath) {
|
|
1547
|
+
const filePath = path8.join(repoPath, INSTRUCTIONS_PATH);
|
|
1548
|
+
try {
|
|
1549
|
+
return await readFile5(filePath, "utf-8");
|
|
1550
|
+
} catch {
|
|
1551
|
+
return void 0;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function buildGitStrategyInstructions(strategy, agent, branch, baseBranch, remote, metadata) {
|
|
1555
|
+
const prNumber = metadata?.prNumber;
|
|
1556
|
+
if (agent.sandbox !== "writable") {
|
|
1557
|
+
if (prNumber) {
|
|
1558
|
+
return `## Pull Request
|
|
1559
|
+
|
|
1560
|
+
PR #${String(prNumber)} is open for this task. After your review, leave your findings as a comment: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
|
|
1561
|
+
}
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
if (strategy === "pr") {
|
|
1565
|
+
if (prNumber) {
|
|
1566
|
+
return `## Git workflow
|
|
1567
|
+
|
|
1568
|
+
You are on branch \`${branch}\`.
|
|
1569
|
+
An open PR exists: #${String(prNumber)}.
|
|
1570
|
+
After committing, push your changes to the branch. The PR will be updated automatically.
|
|
1571
|
+
Leave a review comment on the PR summarizing what you did: \`gh pr comment ${String(prNumber)} --body "..."\`.`;
|
|
1572
|
+
}
|
|
1573
|
+
return `## Git workflow
|
|
1574
|
+
|
|
1575
|
+
You are on branch \`${branch}\` (base: \`${baseBranch}\`).
|
|
1576
|
+
After committing:
|
|
1577
|
+
1. Push: \`git push -u ${remote} ${branch}\`
|
|
1578
|
+
2. Create a PR against \`${baseBranch}\` \u2014 choose a title and description that reflect the work you completed. End the PR body with: \`\u{1F916} Generated with [neo](https://neotx.dev)\`
|
|
1579
|
+
3. Output the PR URL on a dedicated line: \`PR_URL: <url>\``;
|
|
1580
|
+
}
|
|
1581
|
+
return `## Git workflow
|
|
1582
|
+
|
|
1583
|
+
You are on branch \`${branch}\` (base: \`${baseBranch}\`).
|
|
1584
|
+
Commit your changes. The branch will be pushed automatically.`;
|
|
1585
|
+
}
|
|
1586
|
+
function buildReportingInstructions(_runId) {
|
|
1587
|
+
return `## Reporting & Memory
|
|
1588
|
+
|
|
1589
|
+
### Progress reporting (real-time, visible in TUI)
|
|
1590
|
+
Chain \`neo log\` with the command that triggered it \u2014 never standalone:
|
|
1591
|
+
\`\`\`bash
|
|
1592
|
+
pnpm test && neo log milestone "all tests passing" || neo log blocker "tests failing"
|
|
1593
|
+
git push origin HEAD && neo log action "pushed to branch"
|
|
1594
|
+
neo log decision "chose JWT over sessions \u2014 simpler for MVP"
|
|
1595
|
+
\`\`\`
|
|
1596
|
+
|
|
1597
|
+
### Memory (persistent, injected into future agent prompts)
|
|
1598
|
+
Write discoveries so the next agent on this repo starts smarter.
|
|
1599
|
+
|
|
1600
|
+
**Be selective** \u2014 only write a memory if it would change HOW you or future agents approach work:
|
|
1601
|
+
\`\`\`bash
|
|
1602
|
+
# GOOD: affects workflow decisions
|
|
1603
|
+
neo memory write --type fact --scope $NEO_REPOSITORY "CI requires pnpm build before push \u2014 no auto-rebuild in pipeline"
|
|
1604
|
+
neo memory write --type fact --scope $NEO_REPOSITORY "Biome enforces complexity max 20 \u2014 extract helpers for large functions"
|
|
1605
|
+
neo memory write --type procedure --scope $NEO_REPOSITORY "Integration tests require DATABASE_URL env var \u2014 set before running"
|
|
1606
|
+
|
|
1607
|
+
# BAD: trivial or derivable \u2014 do NOT write these
|
|
1608
|
+
# "packages/core has 71 files" \u2014 derivable from ls
|
|
1609
|
+
# "Uses React 19" \u2014 visible in package.json
|
|
1610
|
+
# "apps/web has no test framework" \u2014 derivable from ls/cat
|
|
1611
|
+
\`\`\`
|
|
1612
|
+
|
|
1613
|
+
**The test**: if \`cat package.json\`, \`ls\`, or reading the README can answer it, do NOT memorize it. Only memorize truths that affect decisions or non-obvious workflows learned from failure.
|
|
1614
|
+
|
|
1615
|
+
Write at key moments: after resolving a non-obvious issue, after discovering a build/CI quirk, before finishing.`;
|
|
1616
|
+
}
|
|
1617
|
+
function buildFullPrompt(agentPrompt, repoInstructions, gitInstructions, taskPrompt, memoryContext, cwdInstructions, reportingInstructions) {
|
|
1618
|
+
const sections = [];
|
|
1619
|
+
if (agentPrompt) sections.push(agentPrompt);
|
|
1620
|
+
if (cwdInstructions) sections.push(cwdInstructions);
|
|
1621
|
+
if (memoryContext) sections.push(memoryContext);
|
|
1622
|
+
if (repoInstructions) sections.push(`## Repository instructions
|
|
1623
|
+
|
|
1624
|
+
${repoInstructions}`);
|
|
1625
|
+
if (gitInstructions) sections.push(gitInstructions);
|
|
1626
|
+
if (reportingInstructions) sections.push(reportingInstructions);
|
|
1627
|
+
sections.push(`## Task
|
|
1628
|
+
|
|
1629
|
+
${taskPrompt}`);
|
|
1630
|
+
return sections.join("\n\n---\n\n");
|
|
1631
|
+
}
|
|
1632
|
+
function buildMiddlewareContext(runId, workflow, step, agent, repo, getContextValue) {
|
|
1633
|
+
const store = /* @__PURE__ */ new Map();
|
|
1634
|
+
return {
|
|
1635
|
+
runId,
|
|
1636
|
+
workflow,
|
|
1637
|
+
step,
|
|
1638
|
+
agent,
|
|
1639
|
+
repo,
|
|
1640
|
+
get: ((key) => {
|
|
1641
|
+
const value = getContextValue(key);
|
|
1642
|
+
if (value !== void 0) return value;
|
|
1643
|
+
return store.get(key);
|
|
1644
|
+
}),
|
|
1645
|
+
set: ((key, value) => {
|
|
1646
|
+
store.set(key, value);
|
|
1647
|
+
})
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
var SessionExecutor = class {
|
|
1651
|
+
constructor(config, getContextValue) {
|
|
1652
|
+
this.config = config;
|
|
1653
|
+
this.getContextValue = getContextValue;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Execute an agent session with the given input and dependencies.
|
|
1657
|
+
* Handles prompt building, SDK invocation via recovery wrapper, and output parsing.
|
|
1658
|
+
*/
|
|
1659
|
+
async execute(input, deps) {
|
|
1660
|
+
const {
|
|
1661
|
+
runId,
|
|
1662
|
+
agent,
|
|
1663
|
+
stepDef,
|
|
1664
|
+
repoConfig,
|
|
1665
|
+
repoPath,
|
|
1666
|
+
prompt: taskPrompt,
|
|
1667
|
+
branch,
|
|
1668
|
+
gitStrategy,
|
|
1669
|
+
sessionPath,
|
|
1670
|
+
metadata,
|
|
1671
|
+
startedAt
|
|
1672
|
+
} = input;
|
|
1673
|
+
const { middleware, mcpServers, memoryContext, onAttempt } = deps;
|
|
1674
|
+
if (agent.sandbox === "writable" && !branch) {
|
|
1675
|
+
throw new Error(
|
|
1676
|
+
"Validation error: --branch is required for writable agents. Provide an explicit branch name (e.g. --branch feat/PROJ-42-description)."
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
const branchName = agent.sandbox === "writable" ? branch : "";
|
|
1680
|
+
const sandboxConfig = buildSandboxConfig(agent, sessionPath);
|
|
1681
|
+
const chain = buildMiddlewareChain(middleware);
|
|
1682
|
+
const middlewareContext = buildMiddlewareContext(
|
|
1683
|
+
runId,
|
|
1684
|
+
stepDef.prompt ? "workflow" : "direct",
|
|
1685
|
+
"execute",
|
|
1686
|
+
agent.name,
|
|
1687
|
+
repoPath,
|
|
1688
|
+
this.getContextValue
|
|
1689
|
+
);
|
|
1690
|
+
const hooks = buildSDKHooks(chain, middlewareContext, middleware);
|
|
1691
|
+
const repoInstructions = await loadRepoInstructions(repoPath);
|
|
1692
|
+
const gitInstructions = buildGitStrategyInstructions(
|
|
1693
|
+
gitStrategy,
|
|
1694
|
+
agent,
|
|
1695
|
+
branchName,
|
|
1696
|
+
repoConfig.defaultBranch,
|
|
1697
|
+
repoConfig.pushRemote ?? "origin",
|
|
1698
|
+
metadata
|
|
1699
|
+
);
|
|
1700
|
+
const cwdInstructions = sessionPath ? `## Working directory
|
|
1701
|
+
|
|
1702
|
+
You are working in an isolated clone at: \`${sessionPath}\`
|
|
1703
|
+
ALWAYS run commands from this directory. NEVER cd to or operate on any other repository.` : void 0;
|
|
1704
|
+
const reportingInstructions = buildReportingInstructions(runId);
|
|
1705
|
+
const fullPrompt = buildFullPrompt(
|
|
1706
|
+
agent.definition.prompt,
|
|
1707
|
+
repoInstructions,
|
|
1708
|
+
gitInstructions,
|
|
1709
|
+
stepDef.prompt ?? taskPrompt,
|
|
1710
|
+
memoryContext,
|
|
1711
|
+
cwdInstructions,
|
|
1712
|
+
reportingInstructions
|
|
1713
|
+
);
|
|
1714
|
+
const recoveryOpts = stepDef.recovery;
|
|
1715
|
+
const agentEnv = {
|
|
1716
|
+
NEO_RUN_ID: runId,
|
|
1717
|
+
NEO_AGENT_NAME: agent.name,
|
|
1718
|
+
NEO_REPOSITORY: repoPath
|
|
1719
|
+
};
|
|
1720
|
+
const sessionResult = await runWithRecovery({
|
|
1721
|
+
agent,
|
|
1722
|
+
prompt: fullPrompt,
|
|
1723
|
+
repoPath,
|
|
1724
|
+
sandboxConfig,
|
|
1725
|
+
hooks,
|
|
1726
|
+
env: agentEnv,
|
|
1727
|
+
initTimeoutMs: this.config.initTimeoutMs,
|
|
1728
|
+
maxDurationMs: this.config.maxDurationMs,
|
|
1729
|
+
maxRetries: recoveryOpts?.maxRetries ?? this.config.maxRetries,
|
|
1730
|
+
backoffBaseMs: this.config.backoffBaseMs,
|
|
1731
|
+
...sessionPath ? { sessionPath } : {},
|
|
1732
|
+
...mcpServers ? { mcpServers } : {},
|
|
1733
|
+
...recoveryOpts?.nonRetryable ? { nonRetryable: recoveryOpts.nonRetryable } : {},
|
|
1734
|
+
...onAttempt ? { onAttempt } : {}
|
|
1735
|
+
});
|
|
1736
|
+
const parsed = parseOutput(sessionResult.output);
|
|
1737
|
+
const result = {
|
|
1738
|
+
status: "success",
|
|
1739
|
+
sessionId: sessionResult.sessionId,
|
|
1740
|
+
output: parsed.output ?? parsed.rawOutput,
|
|
1741
|
+
rawOutput: sessionResult.output,
|
|
1742
|
+
costUsd: sessionResult.costUsd,
|
|
1743
|
+
durationMs: sessionResult.durationMs,
|
|
1744
|
+
agent: agent.name,
|
|
1745
|
+
startedAt,
|
|
1746
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1747
|
+
attempt: 1,
|
|
1748
|
+
parsed
|
|
1749
|
+
};
|
|
1750
|
+
if (parsed.prUrl) {
|
|
1751
|
+
result.prUrl = parsed.prUrl;
|
|
1752
|
+
}
|
|
1753
|
+
if (parsed.prNumber !== void 0) {
|
|
1754
|
+
result.prNumber = parsed.prNumber;
|
|
1755
|
+
}
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
// src/supervisor/memory/embedder.ts
|
|
1761
|
+
var extractorPromise = null;
|
|
1762
|
+
function getExtractor() {
|
|
1763
|
+
if (!extractorPromise) {
|
|
1764
|
+
extractorPromise = (async () => {
|
|
1765
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
1766
|
+
return pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
|
|
1767
|
+
dtype: "fp32"
|
|
1768
|
+
});
|
|
1769
|
+
})();
|
|
1770
|
+
}
|
|
1771
|
+
return extractorPromise;
|
|
1772
|
+
}
|
|
1773
|
+
var LocalEmbedder = class {
|
|
1774
|
+
dimensions = 384;
|
|
1775
|
+
async embed(texts) {
|
|
1776
|
+
const extractor = await getExtractor();
|
|
1777
|
+
const output = await extractor(texts, { pooling: "mean", normalize: true });
|
|
1778
|
+
return output.tolist();
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
// src/supervisor/memory/entry.ts
|
|
1783
|
+
import { z as z3 } from "zod";
|
|
1784
|
+
var memoryTypeSchema = z3.enum([
|
|
1785
|
+
"fact",
|
|
1786
|
+
"procedure",
|
|
1787
|
+
"episode",
|
|
1788
|
+
"focus",
|
|
1789
|
+
"feedback",
|
|
1790
|
+
"task"
|
|
1791
|
+
]);
|
|
1792
|
+
var memoryEntrySchema = z3.object({
|
|
1793
|
+
id: z3.string(),
|
|
1794
|
+
type: memoryTypeSchema,
|
|
1795
|
+
scope: z3.string(),
|
|
1796
|
+
// "global" | repo path
|
|
1797
|
+
content: z3.string(),
|
|
1798
|
+
source: z3.string(),
|
|
1799
|
+
// "developer" | "reviewer" | "supervisor" | "user"
|
|
1800
|
+
tags: z3.array(z3.string()).default([]),
|
|
1801
|
+
// Lifecycle
|
|
1802
|
+
createdAt: z3.string(),
|
|
1803
|
+
lastAccessedAt: z3.string(),
|
|
1804
|
+
accessCount: z3.number().default(0),
|
|
1805
|
+
// Optional per-type fields
|
|
1806
|
+
expiresAt: z3.string().optional(),
|
|
1807
|
+
// focus TTL
|
|
1808
|
+
outcome: z3.string().optional(),
|
|
1809
|
+
// episode: success/failure/blocked
|
|
1810
|
+
runId: z3.string().optional(),
|
|
1811
|
+
category: z3.string().optional(),
|
|
1812
|
+
// feedback: reviewer issue category
|
|
1813
|
+
severity: z3.string().optional(),
|
|
1814
|
+
supersedes: z3.string().optional()
|
|
1815
|
+
// contradiction resolution
|
|
1816
|
+
});
|
|
1817
|
+
var memoryWriteInputSchema = z3.object({
|
|
1818
|
+
type: memoryTypeSchema,
|
|
1819
|
+
scope: z3.string().default("global"),
|
|
1820
|
+
content: z3.string(),
|
|
1821
|
+
source: z3.string().default("user"),
|
|
1822
|
+
tags: z3.array(z3.string()).default([]),
|
|
1823
|
+
expiresAt: z3.string().optional(),
|
|
1824
|
+
outcome: z3.string().optional(),
|
|
1825
|
+
runId: z3.string().optional(),
|
|
1826
|
+
category: z3.string().optional(),
|
|
1827
|
+
severity: z3.string().optional(),
|
|
1828
|
+
supersedes: z3.string().optional()
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
// src/supervisor/memory/format.ts
|
|
1832
|
+
var TYPE_LABELS = {
|
|
1833
|
+
fact: "Fact",
|
|
1834
|
+
procedure: "How-to",
|
|
1835
|
+
episode: "Past run",
|
|
1836
|
+
focus: "Current focus",
|
|
1837
|
+
feedback: "Recurring issue"
|
|
1838
|
+
};
|
|
1839
|
+
var TYPE_ICONS = {
|
|
1840
|
+
fact: "\xB7",
|
|
1841
|
+
procedure: "\u2192",
|
|
1842
|
+
episode: "\u25C7",
|
|
1843
|
+
focus: "\u2605",
|
|
1844
|
+
feedback: "\u26A0"
|
|
1845
|
+
};
|
|
1846
|
+
function formatMemoriesForPrompt(memories) {
|
|
1847
|
+
if (memories.length === 0) return "";
|
|
1848
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1849
|
+
for (const m of memories) {
|
|
1850
|
+
const group = grouped.get(m.type) ?? [];
|
|
1851
|
+
group.push(m);
|
|
1852
|
+
grouped.set(m.type, group);
|
|
1853
|
+
}
|
|
1854
|
+
const sections = [];
|
|
1855
|
+
for (const [type, entries] of grouped) {
|
|
1856
|
+
const label = TYPE_LABELS[type] ?? type;
|
|
1857
|
+
const icon = TYPE_ICONS[type] ?? "\xB7";
|
|
1858
|
+
const lines = entries.map((e) => {
|
|
1859
|
+
const confidence = e.accessCount >= 3 ? "" : " (unconfirmed)";
|
|
1860
|
+
return `${icon} ${e.content}${confidence}`;
|
|
1861
|
+
});
|
|
1862
|
+
sections.push(`### ${label}s
|
|
1863
|
+
${lines.join("\n")}`);
|
|
1864
|
+
}
|
|
1865
|
+
return `## Known context for this repository
|
|
1866
|
+
|
|
1867
|
+
${sections.join("\n\n")}`;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/supervisor/memory/store.ts
|
|
1871
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1872
|
+
import { existsSync as existsSync4, mkdirSync } from "fs";
|
|
1873
|
+
import { createRequire } from "module";
|
|
1874
|
+
import path9 from "path";
|
|
1875
|
+
var esmRequire = createRequire(import.meta.url);
|
|
1876
|
+
var MemoryStore = class {
|
|
1877
|
+
db;
|
|
1878
|
+
embedder;
|
|
1879
|
+
hasVec;
|
|
1880
|
+
constructor(dbPath, embedder) {
|
|
1881
|
+
const dir = path9.dirname(dbPath);
|
|
1882
|
+
if (!existsSync4(dir)) {
|
|
1883
|
+
mkdirSync(dir, { recursive: true });
|
|
1884
|
+
}
|
|
1885
|
+
const Database = esmRequire("better-sqlite3");
|
|
1886
|
+
this.db = new Database(dbPath);
|
|
1887
|
+
this.db.pragma("journal_mode = WAL");
|
|
1888
|
+
this.db.pragma("foreign_keys = ON");
|
|
1889
|
+
this.embedder = embedder ?? null;
|
|
1890
|
+
this.hasVec = false;
|
|
1891
|
+
this.initSchema();
|
|
1892
|
+
}
|
|
1893
|
+
// ─── Schema initialization ───────────────────────────
|
|
1894
|
+
initSchema() {
|
|
1895
|
+
this.db.exec(`
|
|
1896
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
1897
|
+
id TEXT PRIMARY KEY,
|
|
1898
|
+
type TEXT NOT NULL CHECK(type IN ('fact','procedure','episode','focus','feedback','task')),
|
|
1899
|
+
scope TEXT NOT NULL,
|
|
1900
|
+
content TEXT NOT NULL,
|
|
1901
|
+
source TEXT NOT NULL,
|
|
1902
|
+
tags TEXT DEFAULT '[]',
|
|
1903
|
+
created_at TEXT NOT NULL,
|
|
1904
|
+
last_accessed_at TEXT NOT NULL,
|
|
1905
|
+
access_count INTEGER DEFAULT 0,
|
|
1906
|
+
expires_at TEXT,
|
|
1907
|
+
outcome TEXT,
|
|
1908
|
+
run_id TEXT,
|
|
1909
|
+
category TEXT,
|
|
1910
|
+
severity TEXT,
|
|
1911
|
+
supersedes TEXT
|
|
1912
|
+
);
|
|
1913
|
+
|
|
1914
|
+
CREATE INDEX IF NOT EXISTS idx_mem_type_scope ON memories(type, scope);
|
|
1915
|
+
CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
|
|
1916
|
+
`);
|
|
1917
|
+
this.migrateCheckConstraint();
|
|
1918
|
+
this.db.exec(`
|
|
1919
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
1920
|
+
content,
|
|
1921
|
+
content='memories',
|
|
1922
|
+
content_rowid='rowid',
|
|
1923
|
+
tokenize='porter'
|
|
1924
|
+
);
|
|
1925
|
+
`);
|
|
1926
|
+
this.db.exec(`
|
|
1927
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
1928
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
1929
|
+
END;
|
|
1930
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
1931
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
1932
|
+
END;
|
|
1933
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
1934
|
+
INSERT INTO memories_fts(memories_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
1935
|
+
INSERT INTO memories_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
1936
|
+
END;
|
|
1937
|
+
`);
|
|
1938
|
+
if (this.embedder) {
|
|
1939
|
+
try {
|
|
1940
|
+
const sqliteVec = esmRequire("sqlite-vec");
|
|
1941
|
+
sqliteVec.load(this.db);
|
|
1942
|
+
this.db.exec(`
|
|
1943
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
|
|
1944
|
+
memory_id TEXT,
|
|
1945
|
+
embedding float[${this.embedder.dimensions}]
|
|
1946
|
+
);
|
|
1947
|
+
`);
|
|
1948
|
+
this.hasVec = true;
|
|
1949
|
+
} catch {
|
|
1950
|
+
this.hasVec = false;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Migrate existing tables whose CHECK constraint predates the 'task' type.
|
|
1956
|
+
* SQLite doesn't allow ALTER CHECK, so we recreate the table if needed.
|
|
1957
|
+
*/
|
|
1958
|
+
migrateCheckConstraint() {
|
|
1959
|
+
const tableInfo = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='memories'").get();
|
|
1960
|
+
if (!tableInfo || tableInfo.sql.includes("'task'")) return;
|
|
1961
|
+
this.db.exec(`
|
|
1962
|
+
ALTER TABLE memories RENAME TO memories_old;
|
|
1963
|
+
|
|
1964
|
+
CREATE TABLE memories (
|
|
1965
|
+
id TEXT PRIMARY KEY,
|
|
1966
|
+
type TEXT NOT NULL CHECK(type IN ('fact','procedure','episode','focus','feedback','task')),
|
|
1967
|
+
scope TEXT NOT NULL,
|
|
1968
|
+
content TEXT NOT NULL,
|
|
1969
|
+
source TEXT NOT NULL,
|
|
1970
|
+
tags TEXT DEFAULT '[]',
|
|
1971
|
+
created_at TEXT NOT NULL,
|
|
1972
|
+
last_accessed_at TEXT NOT NULL,
|
|
1973
|
+
access_count INTEGER DEFAULT 0,
|
|
1974
|
+
expires_at TEXT,
|
|
1975
|
+
outcome TEXT,
|
|
1976
|
+
run_id TEXT,
|
|
1977
|
+
category TEXT,
|
|
1978
|
+
severity TEXT,
|
|
1979
|
+
supersedes TEXT
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
INSERT INTO memories SELECT * FROM memories_old;
|
|
1983
|
+
DROP TABLE memories_old;
|
|
1984
|
+
`);
|
|
1985
|
+
}
|
|
1986
|
+
// ─── Write ───────────────────────────────────────────
|
|
1987
|
+
async write(input) {
|
|
1988
|
+
const id = `mem_${randomUUID2().slice(0, 12)}`;
|
|
1989
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1990
|
+
this.db.prepare(
|
|
1991
|
+
`INSERT INTO memories (id, type, scope, content, source, tags, created_at, last_accessed_at, access_count, expires_at, outcome, run_id, category, severity, supersedes)
|
|
1992
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`
|
|
1993
|
+
).run(
|
|
1994
|
+
id,
|
|
1995
|
+
input.type,
|
|
1996
|
+
input.scope ?? "global",
|
|
1997
|
+
input.content,
|
|
1998
|
+
input.source ?? "user",
|
|
1999
|
+
JSON.stringify(input.tags ?? []),
|
|
2000
|
+
now,
|
|
2001
|
+
now,
|
|
2002
|
+
input.expiresAt ?? null,
|
|
2003
|
+
input.outcome ?? null,
|
|
2004
|
+
input.runId ?? null,
|
|
2005
|
+
input.category ?? null,
|
|
2006
|
+
input.severity ?? null,
|
|
2007
|
+
input.supersedes ?? null
|
|
2008
|
+
);
|
|
2009
|
+
if (this.embedder && this.hasVec) {
|
|
2010
|
+
try {
|
|
2011
|
+
const [vector] = await this.embedder.embed([input.content]);
|
|
2012
|
+
const rowid = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id);
|
|
2013
|
+
if (rowid && vector) {
|
|
2014
|
+
this.db.prepare("INSERT INTO memories_vec (rowid, memory_id, embedding) VALUES (?, ?, ?)").run(rowid.rowid, id, new Float32Array(vector));
|
|
2015
|
+
}
|
|
2016
|
+
} catch {
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
return id;
|
|
2020
|
+
}
|
|
2021
|
+
// ─── Update ──────────────────────────────────────────
|
|
2022
|
+
update(id, content) {
|
|
2023
|
+
this.db.prepare("UPDATE memories SET content = ? WHERE id = ?").run(content, id);
|
|
2024
|
+
if (this.hasVec) {
|
|
2025
|
+
const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id);
|
|
2026
|
+
if (row) {
|
|
2027
|
+
this.db.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(row.rowid);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// ─── Update fields ───────────────────────────────────
|
|
2032
|
+
updateFields(id, fields) {
|
|
2033
|
+
const sets = [];
|
|
2034
|
+
const params = [];
|
|
2035
|
+
if (fields.content !== void 0) {
|
|
2036
|
+
sets.push("content = ?");
|
|
2037
|
+
params.push(fields.content);
|
|
2038
|
+
}
|
|
2039
|
+
if (fields.outcome !== void 0) {
|
|
2040
|
+
sets.push("outcome = ?");
|
|
2041
|
+
params.push(fields.outcome);
|
|
2042
|
+
}
|
|
2043
|
+
if (fields.runId !== void 0) {
|
|
2044
|
+
sets.push("run_id = ?");
|
|
2045
|
+
params.push(fields.runId);
|
|
2046
|
+
}
|
|
2047
|
+
if (sets.length === 0) return;
|
|
2048
|
+
params.push(id);
|
|
2049
|
+
this.db.prepare(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
2050
|
+
}
|
|
2051
|
+
// ─── Forget ──────────────────────────────────────────
|
|
2052
|
+
forget(id) {
|
|
2053
|
+
const row = this.db.prepare("SELECT rowid FROM memories WHERE id = ?").get(id);
|
|
2054
|
+
if (row && this.hasVec) {
|
|
2055
|
+
this.db.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(row.rowid);
|
|
2056
|
+
}
|
|
2057
|
+
this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
2058
|
+
}
|
|
2059
|
+
// ─── Query (synchronous — structured filters) ───────
|
|
2060
|
+
query(opts = {}) {
|
|
2061
|
+
const conditions = [];
|
|
2062
|
+
const params = [];
|
|
2063
|
+
if (opts.scope) {
|
|
2064
|
+
conditions.push("(scope = ? OR scope = 'global')");
|
|
2065
|
+
params.push(opts.scope);
|
|
2066
|
+
}
|
|
2067
|
+
if (opts.types && opts.types.length > 0) {
|
|
2068
|
+
const placeholders = opts.types.map(() => "?").join(",");
|
|
2069
|
+
conditions.push(`type IN (${placeholders})`);
|
|
2070
|
+
params.push(...opts.types);
|
|
2071
|
+
}
|
|
2072
|
+
if (opts.since) {
|
|
2073
|
+
conditions.push("created_at > ?");
|
|
2074
|
+
params.push(opts.since);
|
|
2075
|
+
}
|
|
2076
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2077
|
+
let orderBy;
|
|
2078
|
+
switch (opts.sortBy) {
|
|
2079
|
+
case "accessCount":
|
|
2080
|
+
orderBy = "ORDER BY access_count DESC";
|
|
2081
|
+
break;
|
|
2082
|
+
case "createdAt":
|
|
2083
|
+
orderBy = "ORDER BY created_at DESC";
|
|
2084
|
+
break;
|
|
2085
|
+
case "relevance":
|
|
2086
|
+
default:
|
|
2087
|
+
orderBy = "ORDER BY (access_count * MAX(0, 1.0 - (julianday('now') - julianday(last_accessed_at)) / 60.0)) DESC";
|
|
2088
|
+
break;
|
|
2089
|
+
}
|
|
2090
|
+
const limit = opts.limit ? `LIMIT ${opts.limit}` : "LIMIT 50";
|
|
2091
|
+
const rows = this.db.prepare(`SELECT * FROM memories ${where} ${orderBy} ${limit}`).all(...params);
|
|
2092
|
+
return rows.map(rowToEntry);
|
|
2093
|
+
}
|
|
2094
|
+
// ─── Search (async — semantic or FTS) ────────────────
|
|
2095
|
+
async search(text, opts = {}) {
|
|
2096
|
+
if (this.embedder && this.hasVec) {
|
|
2097
|
+
try {
|
|
2098
|
+
const [queryVec] = await this.embedder.embed([text]);
|
|
2099
|
+
const limit2 = opts.limit ?? 20;
|
|
2100
|
+
const candidates = this.db.prepare(
|
|
2101
|
+
`SELECT m.*, v.distance
|
|
2102
|
+
FROM memories_vec v
|
|
2103
|
+
JOIN memories m ON m.rowid = v.rowid
|
|
2104
|
+
WHERE v.embedding MATCH ?
|
|
2105
|
+
ORDER BY v.distance
|
|
2106
|
+
LIMIT ?`
|
|
2107
|
+
).all(new Float32Array(queryVec), limit2 * 3);
|
|
2108
|
+
const filtered = candidates.filter((row) => {
|
|
2109
|
+
if (opts.scope && row.scope !== opts.scope && row.scope !== "global") return false;
|
|
2110
|
+
if (opts.types && opts.types.length > 0 && !opts.types.includes(row.type))
|
|
2111
|
+
return false;
|
|
2112
|
+
return true;
|
|
2113
|
+
});
|
|
2114
|
+
return filtered.slice(0, limit2).map((row) => rowToEntry(row));
|
|
2115
|
+
} catch {
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
const limit = opts.limit ?? 20;
|
|
2119
|
+
const ftsQuery = text.split(/\s+/).filter(Boolean).map((w) => `"${w}"`).join(" OR ");
|
|
2120
|
+
if (!ftsQuery) return this.query(opts);
|
|
2121
|
+
try {
|
|
2122
|
+
const rows = this.db.prepare(
|
|
2123
|
+
`SELECT m.*, rank
|
|
2124
|
+
FROM memories_fts fts
|
|
2125
|
+
JOIN memories m ON m.rowid = fts.rowid
|
|
2126
|
+
WHERE memories_fts MATCH ?
|
|
2127
|
+
ORDER BY rank
|
|
2128
|
+
LIMIT ?`
|
|
2129
|
+
).all(ftsQuery, limit);
|
|
2130
|
+
const filtered = rows.filter((row) => {
|
|
2131
|
+
if (opts.scope && row.scope !== opts.scope && row.scope !== "global") return false;
|
|
2132
|
+
if (opts.types && opts.types.length > 0 && !opts.types.includes(row.type))
|
|
2133
|
+
return false;
|
|
2134
|
+
return true;
|
|
2135
|
+
});
|
|
2136
|
+
return filtered.map(rowToEntry);
|
|
2137
|
+
} catch {
|
|
2138
|
+
return this.query(opts);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
// ─── Lifecycle ───────────────────────────────────────
|
|
2142
|
+
markAccessed(ids) {
|
|
2143
|
+
if (ids.length === 0) return;
|
|
2144
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2145
|
+
const stmt = this.db.prepare(
|
|
2146
|
+
"UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?"
|
|
2147
|
+
);
|
|
2148
|
+
const transaction = this.db.transaction(() => {
|
|
2149
|
+
for (const id of ids) {
|
|
2150
|
+
stmt.run(now, id);
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
transaction();
|
|
2154
|
+
}
|
|
2155
|
+
decay(maxAgeDays = 30, minAccessCount = 3) {
|
|
2156
|
+
const staleResult = this.db.prepare(
|
|
2157
|
+
`DELETE FROM memories
|
|
2158
|
+
WHERE access_count < ?
|
|
2159
|
+
AND julianday('now') - julianday(last_accessed_at) > ?
|
|
2160
|
+
AND type NOT IN ('focus', 'task')`
|
|
2161
|
+
).run(minAccessCount, maxAgeDays);
|
|
2162
|
+
const taskResult = this.db.prepare(
|
|
2163
|
+
`DELETE FROM memories
|
|
2164
|
+
WHERE type = 'task'
|
|
2165
|
+
AND outcome = 'done'
|
|
2166
|
+
AND julianday('now') - julianday(last_accessed_at) > 7`
|
|
2167
|
+
).run();
|
|
2168
|
+
return staleResult.changes + taskResult.changes;
|
|
2169
|
+
}
|
|
2170
|
+
expireEphemeral() {
|
|
2171
|
+
const result = this.db.prepare(
|
|
2172
|
+
`DELETE FROM memories
|
|
2173
|
+
WHERE type = 'focus'
|
|
2174
|
+
AND expires_at IS NOT NULL
|
|
2175
|
+
AND expires_at < ?`
|
|
2176
|
+
).run((/* @__PURE__ */ new Date()).toISOString());
|
|
2177
|
+
return result.changes;
|
|
2178
|
+
}
|
|
2179
|
+
// ─── Stats ───────────────────────────────────────────
|
|
2180
|
+
stats() {
|
|
2181
|
+
const total = this.db.prepare("SELECT COUNT(*) as count FROM memories").get().count;
|
|
2182
|
+
const byTypeRows = this.db.prepare("SELECT type, COUNT(*) as count FROM memories GROUP BY type").all();
|
|
2183
|
+
const byType = {};
|
|
2184
|
+
for (const row of byTypeRows) {
|
|
2185
|
+
byType[row.type] = row.count;
|
|
2186
|
+
}
|
|
2187
|
+
const byScopeRows = this.db.prepare("SELECT scope, COUNT(*) as count FROM memories GROUP BY scope").all();
|
|
2188
|
+
const byScope = {};
|
|
2189
|
+
for (const row of byScopeRows) {
|
|
2190
|
+
byScope[row.scope] = row.count;
|
|
2191
|
+
}
|
|
2192
|
+
return { total, byType, byScope };
|
|
2193
|
+
}
|
|
2194
|
+
// ─── Cleanup ─────────────────────────────────────────
|
|
2195
|
+
close() {
|
|
2196
|
+
this.db.close();
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
function rowToEntry(row) {
|
|
2200
|
+
let tags = [];
|
|
2201
|
+
try {
|
|
2202
|
+
tags = JSON.parse(row.tags);
|
|
2203
|
+
} catch {
|
|
2204
|
+
tags = [];
|
|
2205
|
+
}
|
|
2206
|
+
return {
|
|
2207
|
+
id: row.id,
|
|
2208
|
+
type: row.type,
|
|
2209
|
+
scope: row.scope,
|
|
2210
|
+
content: row.content,
|
|
2211
|
+
source: row.source,
|
|
2212
|
+
tags,
|
|
2213
|
+
createdAt: row.created_at,
|
|
2214
|
+
lastAccessedAt: row.last_accessed_at,
|
|
2215
|
+
accessCount: row.access_count,
|
|
2216
|
+
expiresAt: row.expires_at ?? void 0,
|
|
2217
|
+
outcome: row.outcome ?? void 0,
|
|
2218
|
+
runId: row.run_id ?? void 0,
|
|
2219
|
+
category: row.category ?? void 0,
|
|
2220
|
+
severity: row.severity ?? void 0,
|
|
2221
|
+
supersedes: row.supersedes ?? void 0
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/workflows/registry.ts
|
|
2226
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2227
|
+
import { readdir as readdir4 } from "fs/promises";
|
|
2228
|
+
import path10 from "path";
|
|
2229
|
+
|
|
2230
|
+
// src/workflows/loader.ts
|
|
2231
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2232
|
+
import { parse } from "yaml";
|
|
2233
|
+
import { z as z4 } from "zod";
|
|
2234
|
+
var workflowStepDefSchema = z4.object({
|
|
2235
|
+
type: z4.literal("step").optional().default("step"),
|
|
2236
|
+
agent: z4.string(),
|
|
2237
|
+
dependsOn: z4.array(z4.string()).optional(),
|
|
2238
|
+
prompt: z4.string().optional(),
|
|
2239
|
+
sandbox: z4.enum(["writable", "readonly"]).optional(),
|
|
2240
|
+
maxTurns: z4.number().int().positive().optional(),
|
|
2241
|
+
mcpServers: z4.array(z4.string()).optional(),
|
|
2242
|
+
recovery: z4.object({
|
|
2243
|
+
maxRetries: z4.number().int().nonnegative().optional(),
|
|
2244
|
+
nonRetryable: z4.array(z4.string()).optional()
|
|
2245
|
+
}).optional(),
|
|
2246
|
+
condition: z4.string().optional()
|
|
2247
|
+
});
|
|
2248
|
+
var workflowGateDefSchema = z4.object({
|
|
2249
|
+
type: z4.literal("gate"),
|
|
2250
|
+
dependsOn: z4.array(z4.string()).optional(),
|
|
2251
|
+
description: z4.string(),
|
|
2252
|
+
timeout: z4.string().optional(),
|
|
2253
|
+
autoApprove: z4.boolean().optional()
|
|
2254
|
+
});
|
|
2255
|
+
var workflowHeaderSchema = z4.object({
|
|
2256
|
+
name: z4.string().min(1),
|
|
2257
|
+
description: z4.string().optional(),
|
|
2258
|
+
steps: z4.record(z4.string(), z4.unknown())
|
|
2259
|
+
});
|
|
2260
|
+
function parseStepEntry(stepName, stepValue) {
|
|
2261
|
+
const obj = stepValue;
|
|
2262
|
+
const schema = obj.type === "gate" ? workflowGateDefSchema : workflowStepDefSchema;
|
|
2263
|
+
const result = schema.safeParse(stepValue);
|
|
2264
|
+
if (result.success) {
|
|
2265
|
+
return { step: result.data, errors: [] };
|
|
1419
2266
|
}
|
|
1420
2267
|
return {
|
|
1421
2268
|
step: stepValue,
|
|
@@ -1448,7 +2295,7 @@ ${errors.join("\n")}`);
|
|
|
1448
2295
|
return steps;
|
|
1449
2296
|
}
|
|
1450
2297
|
async function loadWorkflow(filePath) {
|
|
1451
|
-
const content = await
|
|
2298
|
+
const content = await readFile6(filePath, "utf-8");
|
|
1452
2299
|
const raw = parse(content);
|
|
1453
2300
|
const headerResult = workflowHeaderSchema.safeParse(raw);
|
|
1454
2301
|
if (!headerResult.success) {
|
|
@@ -1486,11 +2333,11 @@ var WorkflowRegistry = class {
|
|
|
1486
2333
|
return this.workflows.has(name);
|
|
1487
2334
|
}
|
|
1488
2335
|
async loadFromDir(dir) {
|
|
1489
|
-
if (!
|
|
1490
|
-
const files = await
|
|
2336
|
+
if (!existsSync5(dir)) return;
|
|
2337
|
+
const files = await readdir4(dir);
|
|
1491
2338
|
for (const file of files) {
|
|
1492
2339
|
if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
|
|
1493
|
-
const filePath =
|
|
2340
|
+
const filePath = path10.join(dir, file);
|
|
1494
2341
|
const workflow = await loadWorkflow(filePath);
|
|
1495
2342
|
this.workflows.set(workflow.name, workflow);
|
|
1496
2343
|
}
|
|
@@ -1501,7 +2348,6 @@ var WorkflowRegistry = class {
|
|
|
1501
2348
|
var MAX_PROMPT_SIZE = 100 * 1024;
|
|
1502
2349
|
var MAX_METADATA_DEPTH = 5;
|
|
1503
2350
|
var SHUTDOWN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1504
|
-
var WORKTREES_DIR = ".neo/worktrees";
|
|
1505
2351
|
var textEncoder = new TextEncoder();
|
|
1506
2352
|
var Orchestrator = class extends NeoEventEmitter {
|
|
1507
2353
|
config;
|
|
@@ -1513,17 +2359,19 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1513
2359
|
idempotencyCache = /* @__PURE__ */ new Map();
|
|
1514
2360
|
abortControllers = /* @__PURE__ */ new Map();
|
|
1515
2361
|
repoIndex = /* @__PURE__ */ new Map();
|
|
1516
|
-
|
|
2362
|
+
runStore = new RunStore();
|
|
1517
2363
|
journalDir;
|
|
1518
2364
|
builtInWorkflowDir;
|
|
1519
2365
|
customWorkflowDir;
|
|
1520
2366
|
costJournal = null;
|
|
1521
2367
|
eventJournal = null;
|
|
1522
2368
|
webhookDispatcher = null;
|
|
2369
|
+
memoryStore = null;
|
|
1523
2370
|
_paused = false;
|
|
1524
2371
|
_costToday = 0;
|
|
1525
2372
|
_startedAt = 0;
|
|
1526
2373
|
_drainResolve = null;
|
|
2374
|
+
skipOrphanRecovery;
|
|
1527
2375
|
constructor(config, options = {}) {
|
|
1528
2376
|
super();
|
|
1529
2377
|
this.config = config;
|
|
@@ -1531,8 +2379,9 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1531
2379
|
this.journalDir = options.journalDir ?? getJournalsDir();
|
|
1532
2380
|
this.builtInWorkflowDir = options.builtInWorkflowDir;
|
|
1533
2381
|
this.customWorkflowDir = options.customWorkflowDir;
|
|
2382
|
+
this.skipOrphanRecovery = options.skipOrphanRecovery ?? false;
|
|
1534
2383
|
for (const repo of config.repos) {
|
|
1535
|
-
const resolvedPath =
|
|
2384
|
+
const resolvedPath = path11.resolve(repo.path);
|
|
1536
2385
|
const normalizedRepo = { ...repo, path: resolvedPath };
|
|
1537
2386
|
this.repoIndex.set(resolvedPath, normalizedRepo);
|
|
1538
2387
|
}
|
|
@@ -1630,8 +2479,15 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1630
2479
|
this._startedAt = Date.now();
|
|
1631
2480
|
this.costJournal = new CostJournal({ dir: this.journalDir });
|
|
1632
2481
|
this.eventJournal = new EventJournal({ dir: this.journalDir });
|
|
1633
|
-
|
|
1634
|
-
|
|
2482
|
+
const supervisorWebhooks = await this.discoverSupervisorWebhooks();
|
|
2483
|
+
const allWebhooks = [...this.config.webhooks, ...supervisorWebhooks];
|
|
2484
|
+
if (allWebhooks.length > 0) {
|
|
2485
|
+
this.webhookDispatcher = new WebhookDispatcher(allWebhooks);
|
|
2486
|
+
}
|
|
2487
|
+
if (supervisorWebhooks.length > 0) {
|
|
2488
|
+
console.log(
|
|
2489
|
+
`[neo] Discovered ${supervisorWebhooks.length} supervisor webhook(s): ${supervisorWebhooks.map((w) => w.url).join(", ")}`
|
|
2490
|
+
);
|
|
1635
2491
|
}
|
|
1636
2492
|
this._costToday = await this.costJournal.getDayTotal();
|
|
1637
2493
|
if (this.builtInWorkflowDir) {
|
|
@@ -1641,12 +2497,10 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1641
2497
|
this.registerWorkflow(workflow);
|
|
1642
2498
|
}
|
|
1643
2499
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
const worktreeBase = path9.join(repo.path, WORKTREES_DIR);
|
|
1647
|
-
await cleanupOrphanedWorktrees(worktreeBase).catch(() => {
|
|
1648
|
-
});
|
|
2500
|
+
if (!this.skipOrphanRecovery) {
|
|
2501
|
+
await this.recoverOrphanedRuns();
|
|
1649
2502
|
}
|
|
2503
|
+
await mkdir6(this.config.sessions.dir, { recursive: true });
|
|
1650
2504
|
}
|
|
1651
2505
|
async shutdown() {
|
|
1652
2506
|
this._paused = true;
|
|
@@ -1670,6 +2524,9 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1670
2524
|
type: "orchestrator:shutdown",
|
|
1671
2525
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1672
2526
|
});
|
|
2527
|
+
if (this.webhookDispatcher) {
|
|
2528
|
+
await this.webhookDispatcher.flush();
|
|
2529
|
+
}
|
|
1673
2530
|
}
|
|
1674
2531
|
// ─── Emit override (journal events) ───────────────────
|
|
1675
2532
|
emit(event) {
|
|
@@ -1709,8 +2566,8 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1709
2566
|
return idempotencyKey;
|
|
1710
2567
|
}
|
|
1711
2568
|
buildDispatchContext(input) {
|
|
1712
|
-
const runId = input.runId ??
|
|
1713
|
-
const sessionId =
|
|
2569
|
+
const runId = input.runId ?? randomUUID3();
|
|
2570
|
+
const sessionId = randomUUID3();
|
|
1714
2571
|
const workflow = this.workflows.get(input.workflow);
|
|
1715
2572
|
if (!workflow) {
|
|
1716
2573
|
const available = [...this.workflows.keys()].join(", ") || "none";
|
|
@@ -1746,28 +2603,39 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1746
2603
|
}
|
|
1747
2604
|
async executeStep(ctx) {
|
|
1748
2605
|
const { input, runId, sessionId, startedAt, agent, repoConfig, activeSession } = ctx;
|
|
1749
|
-
let
|
|
2606
|
+
let sessionPath;
|
|
2607
|
+
await this.persistRun({
|
|
2608
|
+
version: 1,
|
|
2609
|
+
runId,
|
|
2610
|
+
workflow: input.workflow,
|
|
2611
|
+
repo: input.repo,
|
|
2612
|
+
prompt: input.prompt,
|
|
2613
|
+
pid: process.pid,
|
|
2614
|
+
status: "running",
|
|
2615
|
+
steps: {},
|
|
2616
|
+
createdAt: activeSession.startedAt,
|
|
2617
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2618
|
+
metadata: input.metadata
|
|
2619
|
+
});
|
|
1750
2620
|
try {
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
}
|
|
1763
|
-
const stepResult = await this.runAgentSession(ctx, worktreePath);
|
|
2621
|
+
const branchName = input.branch || repoConfig.defaultBranch;
|
|
2622
|
+
const sessionDir = path11.join(this.config.sessions.dir, runId);
|
|
2623
|
+
const info = await createSessionClone({
|
|
2624
|
+
repoPath: input.repo,
|
|
2625
|
+
branch: branchName,
|
|
2626
|
+
baseBranch: repoConfig.defaultBranch,
|
|
2627
|
+
sessionDir
|
|
2628
|
+
});
|
|
2629
|
+
sessionPath = info.path;
|
|
2630
|
+
activeSession.sessionPath = sessionPath;
|
|
2631
|
+
const stepResult = await this.runAgentSession(ctx, sessionPath);
|
|
1764
2632
|
this.emitCostEvents(sessionId, stepResult.costUsd, ctx);
|
|
1765
2633
|
this.emitSessionComplete(ctx, stepResult);
|
|
1766
2634
|
return stepResult;
|
|
1767
2635
|
} catch (error) {
|
|
1768
2636
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1769
2637
|
this.emitSessionFail(ctx, errorMsg);
|
|
1770
|
-
|
|
2638
|
+
const failResult = {
|
|
1771
2639
|
status: "failure",
|
|
1772
2640
|
sessionId,
|
|
1773
2641
|
costUsd: 0,
|
|
@@ -1778,7 +2646,23 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1778
2646
|
error: errorMsg,
|
|
1779
2647
|
attempt: 1
|
|
1780
2648
|
};
|
|
2649
|
+
try {
|
|
2650
|
+
const store = this.getMemoryStore();
|
|
2651
|
+
await store.write({
|
|
2652
|
+
type: "episode",
|
|
2653
|
+
scope: input.repo,
|
|
2654
|
+
content: `Run ${runId.slice(0, 8)} (${agent.name}): failed${failResult.error ? ` \u2014 ${failResult.error.slice(0, 150)}` : ""}`,
|
|
2655
|
+
source: agent.name,
|
|
2656
|
+
outcome: "failure",
|
|
2657
|
+
runId
|
|
2658
|
+
});
|
|
2659
|
+
} catch {
|
|
2660
|
+
}
|
|
2661
|
+
return failResult;
|
|
1781
2662
|
} finally {
|
|
2663
|
+
if (sessionPath) {
|
|
2664
|
+
await this.finalizeSession(sessionPath, ctx);
|
|
2665
|
+
}
|
|
1782
2666
|
this.semaphore.release(sessionId);
|
|
1783
2667
|
this._activeSessions.delete(sessionId);
|
|
1784
2668
|
this.abortControllers.delete(sessionId);
|
|
@@ -1788,18 +2672,27 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1788
2672
|
}
|
|
1789
2673
|
}
|
|
1790
2674
|
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
input.
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2675
|
+
/**
|
|
2676
|
+
* Push the branch (writable only), then remove the session clone.
|
|
2677
|
+
* Runs in `finally` so it executes on both success and failure.
|
|
2678
|
+
*/
|
|
2679
|
+
async finalizeSession(sessionPath, ctx) {
|
|
2680
|
+
if (ctx.agent.sandbox === "writable") {
|
|
2681
|
+
const branch = ctx.input.branch;
|
|
2682
|
+
const remote = ctx.repoConfig.pushRemote ?? "origin";
|
|
2683
|
+
try {
|
|
2684
|
+
await pushSessionBranch(sessionPath, branch, remote).catch(() => {
|
|
2685
|
+
});
|
|
2686
|
+
} catch {
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
try {
|
|
2690
|
+
await removeSessionClone(sessionPath);
|
|
2691
|
+
} catch {
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
async runAgentSession(ctx, sessionPath) {
|
|
2695
|
+
const { input, runId, sessionId, stepName, stepDef, agent, repoConfig, activeSession } = ctx;
|
|
1803
2696
|
this.emit({
|
|
1804
2697
|
type: "session:start",
|
|
1805
2698
|
sessionId,
|
|
@@ -1811,69 +2704,101 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1811
2704
|
metadata: input.metadata,
|
|
1812
2705
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1813
2706
|
});
|
|
2707
|
+
const executor = new SessionExecutor(
|
|
2708
|
+
{
|
|
2709
|
+
initTimeoutMs: this.config.sessions.initTimeoutMs,
|
|
2710
|
+
maxDurationMs: this.config.sessions.maxDurationMs,
|
|
2711
|
+
maxRetries: this.config.recovery.maxRetries,
|
|
2712
|
+
backoffBaseMs: this.config.recovery.backoffBaseMs
|
|
2713
|
+
},
|
|
2714
|
+
(key) => {
|
|
2715
|
+
if (key === "costToday") return this._costToday;
|
|
2716
|
+
if (key === "budgetCapUsd") return this.config.budget.dailyCapUsd;
|
|
2717
|
+
return void 0;
|
|
2718
|
+
}
|
|
2719
|
+
);
|
|
2720
|
+
const strategy = input.gitStrategy ?? repoConfig.gitStrategy ?? "branch";
|
|
2721
|
+
const mcpServers = this.resolveMcpServers(stepDef, agent);
|
|
2722
|
+
const memoryContext = this.loadMemoryContext(input.repo);
|
|
1814
2723
|
const recoveryOpts = stepDef.recovery;
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2724
|
+
const result = await executor.execute(
|
|
2725
|
+
{
|
|
2726
|
+
runId,
|
|
2727
|
+
sessionId,
|
|
2728
|
+
agent,
|
|
2729
|
+
stepDef,
|
|
2730
|
+
repoConfig,
|
|
2731
|
+
repoPath: input.repo,
|
|
2732
|
+
prompt: input.prompt,
|
|
2733
|
+
branch: input.branch,
|
|
2734
|
+
gitStrategy: strategy,
|
|
2735
|
+
sessionPath,
|
|
2736
|
+
metadata: input.metadata,
|
|
2737
|
+
startedAt: activeSession.startedAt
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
middleware: this.userMiddleware,
|
|
2741
|
+
mcpServers,
|
|
2742
|
+
memoryContext,
|
|
2743
|
+
onAttempt: (attempt, strategy2) => {
|
|
2744
|
+
if (attempt > 1) {
|
|
2745
|
+
this.emit({
|
|
2746
|
+
type: "session:fail",
|
|
2747
|
+
sessionId,
|
|
2748
|
+
runId,
|
|
2749
|
+
error: `Retrying with strategy: ${strategy2}`,
|
|
2750
|
+
attempt: attempt - 1,
|
|
2751
|
+
maxRetries: recoveryOpts?.maxRetries ?? this.config.recovery.maxRetries,
|
|
2752
|
+
willRetry: true,
|
|
2753
|
+
metadata: input.metadata,
|
|
2754
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
1840
2757
|
}
|
|
1841
2758
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
status
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
}
|
|
2759
|
+
);
|
|
2760
|
+
try {
|
|
2761
|
+
const store = this.getMemoryStore();
|
|
2762
|
+
const isSuccess = result.status === "success";
|
|
2763
|
+
await store.write({
|
|
2764
|
+
type: "episode",
|
|
2765
|
+
scope: input.repo,
|
|
2766
|
+
content: `Run ${runId.slice(0, 8)} (${agent.name}): ${isSuccess ? "completed" : "failed"}${result.error ? ` \u2014 ${result.error.slice(0, 150)}` : ""}`,
|
|
2767
|
+
source: agent.name,
|
|
2768
|
+
outcome: isSuccess ? "success" : "failure",
|
|
2769
|
+
runId
|
|
2770
|
+
});
|
|
2771
|
+
} catch {
|
|
2772
|
+
}
|
|
2773
|
+
return result;
|
|
1856
2774
|
}
|
|
1857
2775
|
async finalizeDispatch(ctx, stepResult, idempotencyKey) {
|
|
1858
|
-
const { input, runId, stepName,
|
|
2776
|
+
const { input, runId, stepName, activeSession } = ctx;
|
|
1859
2777
|
const taskResult = {
|
|
1860
2778
|
runId,
|
|
1861
2779
|
workflow: input.workflow,
|
|
1862
2780
|
repo: input.repo,
|
|
1863
2781
|
status: stepResult.status === "success" ? "success" : "failure",
|
|
1864
2782
|
steps: { [stepName]: stepResult },
|
|
1865
|
-
branch: stepResult.status === "success" && activeSession.
|
|
2783
|
+
branch: stepResult.status === "success" && activeSession.sessionPath ? input.branch : void 0,
|
|
1866
2784
|
costUsd: stepResult.costUsd,
|
|
1867
2785
|
durationMs: Date.now() - ctx.startedAt,
|
|
1868
2786
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1869
2787
|
metadata: input.metadata
|
|
1870
2788
|
};
|
|
2789
|
+
if (stepResult.prUrl) {
|
|
2790
|
+
taskResult.prUrl = stepResult.prUrl;
|
|
2791
|
+
}
|
|
2792
|
+
if (stepResult.prNumber !== void 0) {
|
|
2793
|
+
taskResult.prNumber = stepResult.prNumber;
|
|
2794
|
+
}
|
|
1871
2795
|
await this.persistRun({
|
|
1872
2796
|
version: 1,
|
|
1873
2797
|
runId,
|
|
1874
2798
|
workflow: input.workflow,
|
|
1875
2799
|
repo: input.repo,
|
|
1876
2800
|
prompt: input.prompt,
|
|
2801
|
+
pid: process.pid,
|
|
1877
2802
|
branch: taskResult.branch,
|
|
1878
2803
|
status: taskResult.status === "success" ? "completed" : "failed",
|
|
1879
2804
|
steps: taskResult.steps,
|
|
@@ -1890,6 +2815,30 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1890
2815
|
}
|
|
1891
2816
|
return taskResult;
|
|
1892
2817
|
}
|
|
2818
|
+
// ─── Private: Memory injection ──────────────────────────
|
|
2819
|
+
getMemoryStore() {
|
|
2820
|
+
if (!this.memoryStore) {
|
|
2821
|
+
const supervisorDir = path11.join(getSupervisorsDir(), "supervisor");
|
|
2822
|
+
this.memoryStore = new MemoryStore(path11.join(supervisorDir, "memory.sqlite"));
|
|
2823
|
+
}
|
|
2824
|
+
return this.memoryStore;
|
|
2825
|
+
}
|
|
2826
|
+
loadMemoryContext(repoPath) {
|
|
2827
|
+
try {
|
|
2828
|
+
const store = this.getMemoryStore();
|
|
2829
|
+
const memories = store.query({
|
|
2830
|
+
scope: repoPath,
|
|
2831
|
+
types: ["fact", "procedure", "feedback"],
|
|
2832
|
+
limit: 25,
|
|
2833
|
+
sortBy: "relevance"
|
|
2834
|
+
});
|
|
2835
|
+
if (memories.length === 0) return void 0;
|
|
2836
|
+
store.markAccessed(memories.map((m) => m.id));
|
|
2837
|
+
return formatMemoriesForPrompt(memories);
|
|
2838
|
+
} catch {
|
|
2839
|
+
return void 0;
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
1893
2842
|
// ─── Private: Event helpers ────────────────────────────
|
|
1894
2843
|
emitCostEvents(sessionId, sessionCost, ctx) {
|
|
1895
2844
|
this._costToday += sessionCost;
|
|
@@ -1964,7 +2913,7 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
1964
2913
|
`Validation error: prompt exceeds maximum size of ${String(MAX_PROMPT_SIZE)} bytes`
|
|
1965
2914
|
);
|
|
1966
2915
|
}
|
|
1967
|
-
if (!
|
|
2916
|
+
if (!existsSync6(input.repo)) {
|
|
1968
2917
|
throw new Error(`Validation error: repo path does not exist: ${input.repo}`);
|
|
1969
2918
|
}
|
|
1970
2919
|
if (!this.workflows.has(input.workflow)) {
|
|
@@ -2037,32 +2986,14 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2037
2986
|
return agent;
|
|
2038
2987
|
}
|
|
2039
2988
|
resolveRepo(repoPath) {
|
|
2040
|
-
const repo = this.repoIndex.get(
|
|
2989
|
+
const repo = this.repoIndex.get(path11.resolve(repoPath));
|
|
2041
2990
|
if (repo) return repo;
|
|
2042
2991
|
return {
|
|
2043
2992
|
path: repoPath,
|
|
2044
2993
|
defaultBranch: "main",
|
|
2045
2994
|
branchPrefix: "feat",
|
|
2046
2995
|
pushRemote: "origin",
|
|
2047
|
-
|
|
2048
|
-
};
|
|
2049
|
-
}
|
|
2050
|
-
buildMiddlewareContext(runId, workflow, step, agent, repo) {
|
|
2051
|
-
const store = /* @__PURE__ */ new Map();
|
|
2052
|
-
return {
|
|
2053
|
-
runId,
|
|
2054
|
-
workflow,
|
|
2055
|
-
step,
|
|
2056
|
-
agent,
|
|
2057
|
-
repo,
|
|
2058
|
-
get: ((key) => {
|
|
2059
|
-
if (key === "costToday") return this._costToday;
|
|
2060
|
-
if (key === "budgetCapUsd") return this.config.budget.dailyCapUsd;
|
|
2061
|
-
return store.get(key);
|
|
2062
|
-
}),
|
|
2063
|
-
set: ((key, value) => {
|
|
2064
|
-
store.set(key, value);
|
|
2065
|
-
})
|
|
2996
|
+
gitStrategy: "branch"
|
|
2066
2997
|
};
|
|
2067
2998
|
}
|
|
2068
2999
|
computeBudgetRemainingPct() {
|
|
@@ -2070,48 +3001,76 @@ var Orchestrator = class extends NeoEventEmitter {
|
|
|
2070
3001
|
if (cap <= 0) return 0;
|
|
2071
3002
|
return Math.max(0, (cap - this._costToday) / cap * 100);
|
|
2072
3003
|
}
|
|
2073
|
-
// ─── Private:
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
3004
|
+
// ─── Private: MCP server resolution ────────────────────
|
|
3005
|
+
resolveMcpServers(stepDef, agent) {
|
|
3006
|
+
const configServers = this.config.mcpServers;
|
|
3007
|
+
if (!configServers) return void 0;
|
|
3008
|
+
const names = /* @__PURE__ */ new Set();
|
|
3009
|
+
if (stepDef.mcpServers) {
|
|
3010
|
+
for (const name of stepDef.mcpServers) names.add(name);
|
|
3011
|
+
}
|
|
3012
|
+
if (agent.definition.mcpServers) {
|
|
3013
|
+
for (const name of agent.definition.mcpServers) names.add(name);
|
|
3014
|
+
}
|
|
3015
|
+
if (names.size === 0) return void 0;
|
|
3016
|
+
const resolved = {};
|
|
3017
|
+
for (const name of names) {
|
|
3018
|
+
const serverConfig = configServers[name];
|
|
3019
|
+
if (serverConfig) {
|
|
3020
|
+
resolved[name] = serverConfig;
|
|
2081
3021
|
}
|
|
2082
|
-
const filePath = path9.join(runsDir, `${run.runId}.json`);
|
|
2083
|
-
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
2084
|
-
} catch {
|
|
2085
3022
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
3023
|
+
return Object.keys(resolved).length > 0 ? resolved : void 0;
|
|
3024
|
+
}
|
|
3025
|
+
// ─── Private: Supervisor discovery ─────────────────────
|
|
3026
|
+
/** Discover running supervisor daemons and return webhook configs for their endpoints. */
|
|
3027
|
+
async discoverSupervisorWebhooks() {
|
|
3028
|
+
const { readdir: readdir6 } = await import("fs/promises");
|
|
3029
|
+
const supervisorsDir = getSupervisorsDir();
|
|
3030
|
+
if (!existsSync6(supervisorsDir)) return [];
|
|
3031
|
+
const webhooks = [];
|
|
2090
3032
|
try {
|
|
2091
|
-
const entries = await
|
|
2092
|
-
const jsonFiles = [];
|
|
3033
|
+
const entries = await readdir6(supervisorsDir, { withFileTypes: true });
|
|
2093
3034
|
for (const entry of entries) {
|
|
2094
|
-
if (entry.isDirectory())
|
|
2095
|
-
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
run.status = "failed";
|
|
2109
|
-
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2110
|
-
await writeFile2(filePath, JSON.stringify(run, null, 2), "utf-8");
|
|
3035
|
+
if (!entry.isDirectory()) continue;
|
|
3036
|
+
try {
|
|
3037
|
+
const statePath = path11.join(supervisorsDir, entry.name, "state.json");
|
|
3038
|
+
const raw = await readFile7(statePath, "utf-8");
|
|
3039
|
+
const state = JSON.parse(raw);
|
|
3040
|
+
if (state.status !== "running" || !state.port) continue;
|
|
3041
|
+
if (state.pid && !isProcessAlive(state.pid)) continue;
|
|
3042
|
+
webhooks.push({
|
|
3043
|
+
url: `http://localhost:${String(state.port)}/webhook`,
|
|
3044
|
+
events: ["session:complete", "session:fail", "budget:alert"],
|
|
3045
|
+
secret: this.config.supervisor.secret,
|
|
3046
|
+
timeoutMs: 5e3
|
|
3047
|
+
});
|
|
3048
|
+
} catch {
|
|
2111
3049
|
}
|
|
2112
3050
|
}
|
|
2113
3051
|
} catch {
|
|
2114
3052
|
}
|
|
3053
|
+
return webhooks;
|
|
3054
|
+
}
|
|
3055
|
+
// ─── Private: Run persistence ──────────────────────────
|
|
3056
|
+
async persistRun(run) {
|
|
3057
|
+
await this.runStore.persistRun(run);
|
|
3058
|
+
}
|
|
3059
|
+
async recoverOrphanedRuns() {
|
|
3060
|
+
const orphanedRuns = await this.runStore.recoverOrphanedRuns();
|
|
3061
|
+
for (const run of orphanedRuns) {
|
|
3062
|
+
this.emit({
|
|
3063
|
+
type: "session:fail",
|
|
3064
|
+
sessionId: run.runId,
|
|
3065
|
+
runId: run.runId,
|
|
3066
|
+
error: "Orphaned run: process died without completing",
|
|
3067
|
+
attempt: 1,
|
|
3068
|
+
maxRetries: this.config.recovery.maxRetries,
|
|
3069
|
+
willRetry: false,
|
|
3070
|
+
metadata: run.metadata,
|
|
3071
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
2115
3074
|
}
|
|
2116
3075
|
};
|
|
2117
3076
|
function isPlainObject(value) {
|
|
@@ -2128,39 +3087,45 @@ function objectDepth(obj, current = 0) {
|
|
|
2128
3087
|
}
|
|
2129
3088
|
|
|
2130
3089
|
// src/supervisor/schemas.ts
|
|
2131
|
-
import { z as
|
|
2132
|
-
var
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
sessionId:
|
|
2136
|
-
port:
|
|
2137
|
-
cwd:
|
|
2138
|
-
startedAt:
|
|
2139
|
-
lastHeartbeat:
|
|
2140
|
-
heartbeatCount:
|
|
2141
|
-
totalCostUsd:
|
|
2142
|
-
todayCostUsd:
|
|
2143
|
-
costResetDate:
|
|
2144
|
-
|
|
3090
|
+
import { z as z5 } from "zod";
|
|
3091
|
+
var wakeReasonSchema = z5.enum(["events", "timer", "active_runs", "forced"]);
|
|
3092
|
+
var supervisorDaemonStateSchema = z5.object({
|
|
3093
|
+
pid: z5.number(),
|
|
3094
|
+
sessionId: z5.string(),
|
|
3095
|
+
port: z5.number(),
|
|
3096
|
+
cwd: z5.string(),
|
|
3097
|
+
startedAt: z5.string(),
|
|
3098
|
+
lastHeartbeat: z5.string().optional(),
|
|
3099
|
+
heartbeatCount: z5.number().default(0),
|
|
3100
|
+
totalCostUsd: z5.number().default(0),
|
|
3101
|
+
todayCostUsd: z5.number().default(0),
|
|
3102
|
+
costResetDate: z5.string().optional(),
|
|
3103
|
+
idleSkipCount: z5.number().default(0),
|
|
3104
|
+
activeWorkSkipCount: z5.number().default(0),
|
|
3105
|
+
status: z5.enum(["running", "draining", "stopped"]).default("running"),
|
|
3106
|
+
lastConsolidationHeartbeat: z5.number().default(0),
|
|
3107
|
+
lastCompactionHeartbeat: z5.number().default(0),
|
|
3108
|
+
lastConsolidationTimestamp: z5.string().optional(),
|
|
3109
|
+
wakeReason: wakeReasonSchema.optional()
|
|
2145
3110
|
});
|
|
2146
|
-
var webhookIncomingEventSchema =
|
|
2147
|
-
id:
|
|
2148
|
-
source:
|
|
2149
|
-
event:
|
|
2150
|
-
payload:
|
|
2151
|
-
receivedAt:
|
|
2152
|
-
processedAt:
|
|
3111
|
+
var webhookIncomingEventSchema = z5.object({
|
|
3112
|
+
id: z5.string().optional(),
|
|
3113
|
+
source: z5.string().optional(),
|
|
3114
|
+
event: z5.string().optional(),
|
|
3115
|
+
payload: z5.record(z5.string(), z5.unknown()).optional(),
|
|
3116
|
+
receivedAt: z5.string(),
|
|
3117
|
+
processedAt: z5.string().optional()
|
|
2153
3118
|
});
|
|
2154
|
-
var inboxMessageSchema =
|
|
2155
|
-
id:
|
|
2156
|
-
from:
|
|
2157
|
-
text:
|
|
2158
|
-
timestamp:
|
|
2159
|
-
processedAt:
|
|
3119
|
+
var inboxMessageSchema = z5.object({
|
|
3120
|
+
id: z5.string(),
|
|
3121
|
+
from: z5.enum(["tui", "api", "external", "agent"]),
|
|
3122
|
+
text: z5.string(),
|
|
3123
|
+
timestamp: z5.string(),
|
|
3124
|
+
processedAt: z5.string().optional()
|
|
2160
3125
|
});
|
|
2161
|
-
var activityEntrySchema =
|
|
2162
|
-
id:
|
|
2163
|
-
type:
|
|
3126
|
+
var activityEntrySchema = z5.object({
|
|
3127
|
+
id: z5.string(),
|
|
3128
|
+
type: z5.enum([
|
|
2164
3129
|
"heartbeat",
|
|
2165
3130
|
"decision",
|
|
2166
3131
|
"action",
|
|
@@ -2172,15 +3137,27 @@ var activityEntrySchema = z4.object({
|
|
|
2172
3137
|
"dispatch",
|
|
2173
3138
|
"tool_use"
|
|
2174
3139
|
]),
|
|
2175
|
-
summary:
|
|
2176
|
-
detail:
|
|
2177
|
-
timestamp:
|
|
3140
|
+
summary: z5.string(),
|
|
3141
|
+
detail: z5.unknown().optional(),
|
|
3142
|
+
timestamp: z5.string()
|
|
2178
3143
|
});
|
|
3144
|
+
var logBufferEntrySchema = z5.object({
|
|
3145
|
+
id: z5.string(),
|
|
3146
|
+
type: z5.enum(["progress", "action", "decision", "blocker", "milestone", "discovery"]),
|
|
3147
|
+
message: z5.string(),
|
|
3148
|
+
agent: z5.string().optional(),
|
|
3149
|
+
runId: z5.string().optional(),
|
|
3150
|
+
repo: z5.string().optional(),
|
|
3151
|
+
target: z5.enum(["memory", "knowledge", "digest"]),
|
|
3152
|
+
timestamp: z5.string(),
|
|
3153
|
+
consolidatedAt: z5.string().optional()
|
|
3154
|
+
});
|
|
3155
|
+
var internalEventKindSchema = z5.enum(["consolidation_timer", "active_run_check"]);
|
|
2179
3156
|
|
|
2180
3157
|
// src/supervisor/activity-log.ts
|
|
2181
|
-
import { randomUUID as
|
|
2182
|
-
import { appendFile as appendFile4, readFile as
|
|
2183
|
-
import
|
|
3158
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
3159
|
+
import { appendFile as appendFile4, readFile as readFile8, rename, stat } from "fs/promises";
|
|
3160
|
+
import path12 from "path";
|
|
2184
3161
|
var ACTIVITY_FILE = "activity.jsonl";
|
|
2185
3162
|
var MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
2186
3163
|
var ActivityLog = class {
|
|
@@ -2188,7 +3165,7 @@ var ActivityLog = class {
|
|
|
2188
3165
|
dir;
|
|
2189
3166
|
constructor(dir) {
|
|
2190
3167
|
this.dir = dir;
|
|
2191
|
-
this.filePath =
|
|
3168
|
+
this.filePath = path12.join(dir, ACTIVITY_FILE);
|
|
2192
3169
|
}
|
|
2193
3170
|
/**
|
|
2194
3171
|
* Append a structured entry to the activity log.
|
|
@@ -2205,7 +3182,7 @@ var ActivityLog = class {
|
|
|
2205
3182
|
*/
|
|
2206
3183
|
async log(type, summary, detail) {
|
|
2207
3184
|
await this.append({
|
|
2208
|
-
id:
|
|
3185
|
+
id: randomUUID4(),
|
|
2209
3186
|
type,
|
|
2210
3187
|
summary,
|
|
2211
3188
|
detail,
|
|
@@ -2218,7 +3195,7 @@ var ActivityLog = class {
|
|
|
2218
3195
|
async tail(n) {
|
|
2219
3196
|
let content;
|
|
2220
3197
|
try {
|
|
2221
|
-
content = await
|
|
3198
|
+
content = await readFile8(this.filePath, "utf-8");
|
|
2222
3199
|
} catch {
|
|
2223
3200
|
return [];
|
|
2224
3201
|
}
|
|
@@ -2238,7 +3215,7 @@ var ActivityLog = class {
|
|
|
2238
3215
|
const stats = await stat(this.filePath);
|
|
2239
3216
|
if (stats.size > MAX_SIZE_BYTES) {
|
|
2240
3217
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2241
|
-
const rotatedPath =
|
|
3218
|
+
const rotatedPath = path12.join(this.dir, `activity-${timestamp}.jsonl`);
|
|
2242
3219
|
await rename(this.filePath, rotatedPath);
|
|
2243
3220
|
}
|
|
2244
3221
|
} catch {
|
|
@@ -2247,15 +3224,15 @@ var ActivityLog = class {
|
|
|
2247
3224
|
};
|
|
2248
3225
|
|
|
2249
3226
|
// src/supervisor/daemon.ts
|
|
2250
|
-
import { randomUUID as
|
|
2251
|
-
import { existsSync as
|
|
2252
|
-
import { mkdir as
|
|
3227
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3228
|
+
import { existsSync as existsSync8 } from "fs";
|
|
3229
|
+
import { mkdir as mkdir7, readFile as readFile12, rm as rm2, writeFile as writeFile6 } from "fs/promises";
|
|
2253
3230
|
import { homedir as homedir3 } from "os";
|
|
2254
|
-
import
|
|
3231
|
+
import path15 from "path";
|
|
2255
3232
|
|
|
2256
3233
|
// src/supervisor/event-queue.ts
|
|
2257
3234
|
import { watch } from "fs";
|
|
2258
|
-
import { readFile as
|
|
3235
|
+
import { readFile as readFile9, writeFile as writeFile3 } from "fs/promises";
|
|
2259
3236
|
var EventQueue = class {
|
|
2260
3237
|
queue = [];
|
|
2261
3238
|
seenIds = /* @__PURE__ */ new Set();
|
|
@@ -2302,6 +3279,36 @@ var EventQueue = class {
|
|
|
2302
3279
|
this.queue.length = 0;
|
|
2303
3280
|
return events;
|
|
2304
3281
|
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Drain and group events: deduplicates messages by content,
|
|
3284
|
+
* keeps webhooks and run completions separate.
|
|
3285
|
+
*/
|
|
3286
|
+
drainAndGroup() {
|
|
3287
|
+
const events = this.drain();
|
|
3288
|
+
const messageMap = /* @__PURE__ */ new Map();
|
|
3289
|
+
const webhooks = [];
|
|
3290
|
+
const runCompletions = [];
|
|
3291
|
+
for (const event of events) {
|
|
3292
|
+
if (event.kind === "message") {
|
|
3293
|
+
const key = event.data.text.trim().toLowerCase();
|
|
3294
|
+
const existing = messageMap.get(key);
|
|
3295
|
+
if (existing) {
|
|
3296
|
+
existing.count++;
|
|
3297
|
+
} else {
|
|
3298
|
+
messageMap.set(key, { text: event.data.text, from: event.data.from, count: 1 });
|
|
3299
|
+
}
|
|
3300
|
+
} else if (event.kind === "webhook") {
|
|
3301
|
+
webhooks.push(event);
|
|
3302
|
+
} else {
|
|
3303
|
+
runCompletions.push(event);
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return {
|
|
3307
|
+
messages: [...messageMap.values()],
|
|
3308
|
+
webhooks,
|
|
3309
|
+
runCompletions
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
2305
3312
|
size() {
|
|
2306
3313
|
return this.queue.length;
|
|
2307
3314
|
}
|
|
@@ -2309,7 +3316,14 @@ var EventQueue = class {
|
|
|
2309
3316
|
* Start watching inbox.jsonl and events.jsonl for new entries.
|
|
2310
3317
|
* New lines are parsed and pushed into the queue.
|
|
2311
3318
|
*/
|
|
2312
|
-
startWatching(inboxPath, eventsPath) {
|
|
3319
|
+
async startWatching(inboxPath, eventsPath) {
|
|
3320
|
+
for (const p of [inboxPath, eventsPath]) {
|
|
3321
|
+
try {
|
|
3322
|
+
await writeFile3(p, "", { flag: "a" });
|
|
3323
|
+
} catch (err) {
|
|
3324
|
+
console.error(`[EventQueue] Failed to ensure file exists: ${p}`, err);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
2313
3327
|
this.watchJsonlFile(inboxPath, "message");
|
|
2314
3328
|
this.watchJsonlFile(eventsPath, "webhook");
|
|
2315
3329
|
}
|
|
@@ -2357,18 +3371,20 @@ var EventQueue = class {
|
|
|
2357
3371
|
watchJsonlFile(filePath, kind) {
|
|
2358
3372
|
try {
|
|
2359
3373
|
const watcher = watch(filePath, () => {
|
|
2360
|
-
this.readNewLines(filePath, kind).catch(() => {
|
|
3374
|
+
this.readNewLines(filePath, kind).catch((err) => {
|
|
3375
|
+
console.error(`[EventQueue] Failed to read new lines from ${filePath}:`, err);
|
|
2361
3376
|
});
|
|
2362
3377
|
});
|
|
2363
3378
|
this.watchers.push(watcher);
|
|
2364
|
-
} catch {
|
|
3379
|
+
} catch (err) {
|
|
3380
|
+
console.error(`[EventQueue] Cannot watch file (may not exist yet): ${filePath}`, err);
|
|
2365
3381
|
}
|
|
2366
3382
|
}
|
|
2367
3383
|
async readNewLines(filePath, kind) {
|
|
2368
3384
|
let content;
|
|
2369
3385
|
try {
|
|
2370
|
-
content = await
|
|
2371
|
-
} catch {
|
|
3386
|
+
content = await readFile9(filePath, "utf-8");
|
|
3387
|
+
} catch (_err) {
|
|
2372
3388
|
return;
|
|
2373
3389
|
}
|
|
2374
3390
|
const offset = this.fileOffsets.get(filePath) ?? 0;
|
|
@@ -2385,15 +3401,15 @@ var EventQueue = class {
|
|
|
2385
3401
|
} else {
|
|
2386
3402
|
this.push({ kind: "message", data: parsed });
|
|
2387
3403
|
}
|
|
2388
|
-
} catch {
|
|
3404
|
+
} catch (_err) {
|
|
2389
3405
|
}
|
|
2390
3406
|
}
|
|
2391
3407
|
}
|
|
2392
3408
|
async replayFile(filePath, kind) {
|
|
2393
3409
|
let content;
|
|
2394
3410
|
try {
|
|
2395
|
-
content = await
|
|
2396
|
-
} catch {
|
|
3411
|
+
content = await readFile9(filePath, "utf-8");
|
|
3412
|
+
} catch (_err) {
|
|
2397
3413
|
return;
|
|
2398
3414
|
}
|
|
2399
3415
|
this.fileOffsets.set(filePath, content.length);
|
|
@@ -2409,7 +3425,7 @@ var EventQueue = class {
|
|
|
2409
3425
|
this.push({ kind: "message", data: parsed });
|
|
2410
3426
|
}
|
|
2411
3427
|
unprocessed.push(line);
|
|
2412
|
-
} catch {
|
|
3428
|
+
} catch (_err) {
|
|
2413
3429
|
}
|
|
2414
3430
|
}
|
|
2415
3431
|
}
|
|
@@ -2428,7 +3444,7 @@ var EventQueue = class {
|
|
|
2428
3444
|
}
|
|
2429
3445
|
async markInFile(filePath, matchTimestamp, processedAt) {
|
|
2430
3446
|
try {
|
|
2431
|
-
const content = await
|
|
3447
|
+
const content = await readFile9(filePath, "utf-8");
|
|
2432
3448
|
const lines = content.split("\n");
|
|
2433
3449
|
let changed = false;
|
|
2434
3450
|
const updated = lines.map((line) => {
|
|
@@ -2440,7 +3456,7 @@ var EventQueue = class {
|
|
|
2440
3456
|
changed = true;
|
|
2441
3457
|
return JSON.stringify(parsed);
|
|
2442
3458
|
}
|
|
2443
|
-
} catch {
|
|
3459
|
+
} catch (_err) {
|
|
2444
3460
|
}
|
|
2445
3461
|
return line;
|
|
2446
3462
|
});
|
|
@@ -2448,150 +3464,689 @@ var EventQueue = class {
|
|
|
2448
3464
|
await writeFile3(filePath, updated.join("\n"), "utf-8");
|
|
2449
3465
|
this.fileOffsets.set(filePath, updated.join("\n").length);
|
|
2450
3466
|
}
|
|
2451
|
-
} catch {
|
|
3467
|
+
} catch (err) {
|
|
3468
|
+
console.error(`[EventQueue] Failed to mark events as processed in ${filePath}:`, err);
|
|
2452
3469
|
}
|
|
2453
3470
|
}
|
|
2454
3471
|
};
|
|
2455
3472
|
|
|
2456
3473
|
// src/supervisor/heartbeat.ts
|
|
2457
|
-
import { randomUUID as
|
|
2458
|
-
import {
|
|
3474
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
3475
|
+
import { existsSync as existsSync7 } from "fs";
|
|
3476
|
+
import { readdir as readdir5, readFile as readFile11, writeFile as writeFile5 } from "fs/promises";
|
|
2459
3477
|
import { homedir as homedir2 } from "os";
|
|
2460
|
-
import
|
|
3478
|
+
import path14 from "path";
|
|
2461
3479
|
|
|
2462
|
-
// src/supervisor/
|
|
2463
|
-
import { readFile as
|
|
2464
|
-
import
|
|
2465
|
-
var
|
|
2466
|
-
var
|
|
2467
|
-
|
|
3480
|
+
// src/supervisor/log-buffer.ts
|
|
3481
|
+
import { appendFile as appendFile5, readFile as readFile10, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3482
|
+
import path13 from "path";
|
|
3483
|
+
var LOG_BUFFER_FILE = "log-buffer.jsonl";
|
|
3484
|
+
var MAX_FILE_BYTES = 1024 * 1024;
|
|
3485
|
+
var COMPACTION_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
3486
|
+
function bufferPath(dir) {
|
|
3487
|
+
return path13.join(dir, LOG_BUFFER_FILE);
|
|
3488
|
+
}
|
|
3489
|
+
function parseLines(content) {
|
|
3490
|
+
const entries = [];
|
|
3491
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
3492
|
+
for (const line of lines) {
|
|
3493
|
+
try {
|
|
3494
|
+
entries.push(JSON.parse(line));
|
|
3495
|
+
} catch {
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
return entries;
|
|
3499
|
+
}
|
|
3500
|
+
async function readLogBuffer(dir) {
|
|
3501
|
+
try {
|
|
3502
|
+
const content = await readFile10(bufferPath(dir), "utf-8");
|
|
3503
|
+
return parseLines(content);
|
|
3504
|
+
} catch {
|
|
3505
|
+
return [];
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
async function readUnconsolidated(dir) {
|
|
3509
|
+
const entries = await readLogBuffer(dir);
|
|
3510
|
+
return entries.filter((e) => !e.consolidatedAt);
|
|
3511
|
+
}
|
|
3512
|
+
async function markConsolidated(dir, ids) {
|
|
3513
|
+
const filePath = bufferPath(dir);
|
|
3514
|
+
let content;
|
|
3515
|
+
try {
|
|
3516
|
+
content = await readFile10(filePath, "utf-8");
|
|
3517
|
+
} catch {
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
3520
|
+
const idSet = new Set(ids);
|
|
3521
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3522
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
3523
|
+
const updated = [];
|
|
3524
|
+
for (const line of lines) {
|
|
3525
|
+
try {
|
|
3526
|
+
const entry = JSON.parse(line);
|
|
3527
|
+
if (idSet.has(entry.id) && !entry.consolidatedAt) {
|
|
3528
|
+
entry.consolidatedAt = now;
|
|
3529
|
+
}
|
|
3530
|
+
updated.push(JSON.stringify(entry));
|
|
3531
|
+
} catch {
|
|
3532
|
+
updated.push(line);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
await writeFile4(filePath, `${updated.join("\n")}
|
|
3536
|
+
`, "utf-8");
|
|
3537
|
+
}
|
|
3538
|
+
async function compactLogBuffer(dir) {
|
|
3539
|
+
const filePath = bufferPath(dir);
|
|
3540
|
+
let content;
|
|
2468
3541
|
try {
|
|
2469
|
-
|
|
3542
|
+
content = await readFile10(filePath, "utf-8");
|
|
2470
3543
|
} catch {
|
|
2471
|
-
return
|
|
3544
|
+
return;
|
|
2472
3545
|
}
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
function extractMemoryFromResponse(response) {
|
|
2478
|
-
const match = /<memory>([\s\S]*?)<\/memory>/i.exec(response);
|
|
2479
|
-
if (!match?.[1]) return null;
|
|
2480
|
-
const content = match[1].trim();
|
|
2481
|
-
if (content.startsWith("{")) {
|
|
3546
|
+
const now = Date.now();
|
|
3547
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
3548
|
+
const kept = [];
|
|
3549
|
+
for (const line of lines) {
|
|
2482
3550
|
try {
|
|
2483
|
-
JSON.parse(
|
|
2484
|
-
|
|
3551
|
+
const entry = JSON.parse(line);
|
|
3552
|
+
if (entry.consolidatedAt) {
|
|
3553
|
+
const consolidatedTime = new Date(entry.consolidatedAt).getTime();
|
|
3554
|
+
if (now - consolidatedTime > COMPACTION_AGE_MS) {
|
|
3555
|
+
continue;
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
kept.push(JSON.stringify(entry));
|
|
2485
3559
|
} catch {
|
|
2486
3560
|
}
|
|
2487
3561
|
}
|
|
2488
|
-
|
|
3562
|
+
let result = `${kept.join("\n")}
|
|
3563
|
+
`;
|
|
3564
|
+
while (Buffer.byteLength(result, "utf-8") > MAX_FILE_BYTES && kept.length > 0) {
|
|
3565
|
+
kept.shift();
|
|
3566
|
+
result = `${kept.join("\n")}
|
|
3567
|
+
`;
|
|
3568
|
+
}
|
|
3569
|
+
await writeFile4(filePath, result, "utf-8");
|
|
2489
3570
|
}
|
|
2490
|
-
function
|
|
2491
|
-
|
|
2492
|
-
|
|
3571
|
+
async function appendLogBuffer(dir, entry) {
|
|
3572
|
+
try {
|
|
3573
|
+
await appendFile5(bufferPath(dir), `${JSON.stringify(entry)}
|
|
3574
|
+
`, "utf-8");
|
|
3575
|
+
} catch {
|
|
3576
|
+
}
|
|
2493
3577
|
}
|
|
2494
3578
|
|
|
2495
3579
|
// src/supervisor/prompt-builder.ts
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
Your
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
3580
|
+
var ROLE = `You are the neo autonomous supervisor \u2014 a stateless dispatch controller.
|
|
3581
|
+
|
|
3582
|
+
You receive state (events, memory, work queue) and produce actions (tool calls).
|
|
3583
|
+
|
|
3584
|
+
<behavioral-contract>
|
|
3585
|
+
- Your ONLY visible output is \`neo log\` commands. The TUI shows these and nothing else.
|
|
3586
|
+
- Your text output is NEVER shown to anyone \u2014 every token of text is wasted cost.
|
|
3587
|
+
- Produce tool calls, not explanations. Do not narrate your reasoning.
|
|
3588
|
+
- You NEVER modify code \u2014 that is the agents' job.
|
|
3589
|
+
</behavioral-contract>`;
|
|
3590
|
+
var COMMANDS = `### Dispatching agents
|
|
3591
|
+
\`\`\`bash
|
|
3592
|
+
neo run <agent> --prompt "..." --repo <path> --branch <name> [--priority critical|high|medium|low] [--meta '<json>']
|
|
3593
|
+
\`\`\`
|
|
3594
|
+
|
|
3595
|
+
| Flag | Required | Description |
|
|
3596
|
+
|------|----------|-------------|
|
|
3597
|
+
| \`--prompt\` | always | Task description for the agent |
|
|
3598
|
+
| \`--repo\` | always | Target repository path |
|
|
3599
|
+
| \`--branch\` | always | Branch name for the isolated clone |
|
|
3600
|
+
| \`--priority\` | no | \`critical\`, \`high\`, \`medium\`, \`low\` |
|
|
3601
|
+
| \`--meta\` | **always** | JSON with \`"label"\` for identification + \`"ticketId"\`, \`"stage"\`, etc. |
|
|
3602
|
+
|
|
3603
|
+
All agents require \`--branch\`. Each agent session runs in an isolated clone on that branch.
|
|
3604
|
+
Always include \`--meta '{"label":"T1-auth-middleware","ticketId":"YC-42","stage":"develop"}'\` so you can identify runs later.
|
|
3605
|
+
|
|
3606
|
+
### Monitoring & reading agent output
|
|
3607
|
+
\`\`\`bash
|
|
3608
|
+
neo runs --short # check recent runs
|
|
3609
|
+
neo runs --short --status running # check active runs are alive
|
|
3610
|
+
neo runs <runId> # full run details + agent output (MUST READ on completion)
|
|
3611
|
+
neo cost --short [--all] # check budget
|
|
3612
|
+
\`\`\`
|
|
3613
|
+
|
|
3614
|
+
\`neo runs <runId>\` returns the agent's full output. **ALWAYS read it when a run completes** \u2014 it contains structured JSON (PR URLs, issues, plans, milestones) that you need to decide next steps.
|
|
3615
|
+
|
|
3616
|
+
### Memory
|
|
3617
|
+
\`\`\`bash
|
|
3618
|
+
neo memory write --type fact --scope /path "Stable fact about repo"
|
|
3619
|
+
neo memory write --type focus --expires 2h "Current working context"
|
|
3620
|
+
neo memory write --type procedure --scope /path "How to do X"
|
|
3621
|
+
neo memory write --type task --scope /path --severity high --category "neo runs <id>" "Task description"
|
|
3622
|
+
neo memory update <id> --outcome in_progress|done|blocked|abandoned
|
|
3623
|
+
neo memory forget <id>
|
|
3624
|
+
neo memory search "keyword"
|
|
3625
|
+
neo memory list --type fact
|
|
3626
|
+
\`\`\`
|
|
3627
|
+
|
|
3628
|
+
### Reporting
|
|
3629
|
+
\`\`\`bash
|
|
3630
|
+
neo log <type> "<message>" # visible in TUI only
|
|
3631
|
+
\`\`\``;
|
|
3632
|
+
var COMMANDS_COMPACT = `### Commands (reference)
|
|
3633
|
+
\`neo run <agent> --prompt "..." --repo <path> --branch <name> --meta '{"label":"T1-auth",...}'\`
|
|
3634
|
+
\`neo runs [--short | <runId>]\` \xB7 \`neo runs --short --status running\` \xB7 \`neo cost --short\`
|
|
3635
|
+
\`neo memory write|update|forget|search|list\` \xB7 \`neo log <type> "<msg>"\`
|
|
3636
|
+
ALWAYS read run output on completion: \`neo runs <runId>\` \u2014 it contains the agent's structured result.`;
|
|
3637
|
+
var HEARTBEAT_RULES = `### Heartbeat lifecycle
|
|
3638
|
+
|
|
3639
|
+
<decision-tree>
|
|
3640
|
+
1. DEDUP FIRST \u2014 check focus for PROCESSED entries. Skip any runId already processed.
|
|
3641
|
+
2. MONITOR RUNS \u2014 \`neo runs --short\` to check active run status. If a run completed since last HB, read its output with \`neo runs <runId>\` BEFORE doing anything else.
|
|
3642
|
+
3. PENDING TASKS? \u2014 dispatch the next eligible task from work queue. Do not re-plan.
|
|
3643
|
+
4. EVENTS? \u2014 process run completions, messages, webhooks. Parse agent JSON output.
|
|
3644
|
+
5. FOLLOW-UPS? \u2014 check CI (\`gh pr checks\`), deferred dispatches.
|
|
3645
|
+
6. DISPATCH \u2014 route work to agents. Mark tasks \`in_progress\`, add ACTIVE to focus.
|
|
3646
|
+
7. YIELD \u2014 log your decisions and yield. Do not poll. Completions arrive at future heartbeats.
|
|
3647
|
+
</decision-tree>
|
|
3648
|
+
|
|
3649
|
+
<run-monitoring>
|
|
3650
|
+
Runs are your agents in the field. You MUST track them:
|
|
3651
|
+
- **On dispatch**: always include a label in \`--meta\` for identification: \`--meta '{"label":"T6-csv-export","ticketId":"YC-42",...}'\`
|
|
3652
|
+
- **On completion**: ALWAYS run \`neo runs <runId>\` to read the agent's full output. The output contains structured JSON (PR URLs, issues, plans) \u2014 you need it to decide next steps.
|
|
3653
|
+
- **On failure**: read the output to understand why. Check if the task should be retried, blocked, or abandoned.
|
|
3654
|
+
- **Active runs**: check \`neo runs --short --status running\` to verify your runs are still alive. If a run disappeared, investigate.
|
|
3655
|
+
</run-monitoring>
|
|
3656
|
+
|
|
3657
|
+
<rules>
|
|
3658
|
+
- Work queue IS your plan. Never re-plan existing tasks.
|
|
3659
|
+
- Maximize parallelism: dispatch independent tasks in the same heartbeat.
|
|
3660
|
+
- After dispatch: update focus, yield immediately. Do NOT wait for results.
|
|
3661
|
+
- Deferred work (CI pending): MUST check at next heartbeat.
|
|
3662
|
+
- Before dispatching a task, run the \`--category\` command from the task to retrieve context.
|
|
3663
|
+
</rules>`;
|
|
3664
|
+
var REPORTING_RULES = `### Reporting
|
|
3665
|
+
|
|
3666
|
+
\`neo log\` is your ONLY visible output. Use telegraphic format.
|
|
3667
|
+
|
|
3668
|
+
<log-format>
|
|
3669
|
+
neo log decision "<ticket> \u2192 <action> | <1-line reason>"
|
|
3670
|
+
neo log action "<agent> <repo>:<branch> run:<runId> | <context>"
|
|
3671
|
+
neo log discovery "<what> in <where>"
|
|
3672
|
+
</log-format>
|
|
3673
|
+
|
|
3674
|
+
<examples>
|
|
3675
|
+
<example type="good">
|
|
3676
|
+
neo log decision "YC-42 \u2192 developer | clear spec, complexity 3"
|
|
3677
|
+
neo log action "developer standards:feat/YC-42-auth run:5900a64a | task T1"
|
|
3678
|
+
neo log discovery "CI requires node 20 in api-service"
|
|
3679
|
+
</example>
|
|
3680
|
+
<example type="bad">
|
|
3681
|
+
neo log plan "Good! Now let me check the status and update things accordingly."
|
|
3682
|
+
neo log decision "Heartbeat #309: Idle cycle - no action required. All 4 repositories stable."
|
|
3683
|
+
neo log action "I've dispatched a developer agent to work on the authentication feature."
|
|
3684
|
+
</example>
|
|
3685
|
+
</examples>`;
|
|
3686
|
+
var MEMORY_RULES_CORE = `### Memory
|
|
3687
|
+
|
|
3688
|
+
<memory-types>
|
|
3689
|
+
| Type | Store when | TTL |
|
|
3690
|
+
|------|-----------|-----|
|
|
3691
|
+
| \`fact\` | Stable truth affecting dispatch decisions | Permanent (decays) |
|
|
3692
|
+
| \`procedure\` | Same failure 3+ times | Permanent |
|
|
3693
|
+
| \`focus\` | After every dispatch/deferral | --expires required |
|
|
3694
|
+
| \`task\` | Any planned work (tickets, decompositions, follow-ups) | Until done/abandoned |
|
|
3695
|
+
| \`feedback\` | Same review complaint 3+ times | Permanent |
|
|
3696
|
+
</memory-types>
|
|
3697
|
+
|
|
3698
|
+
<memory-rules>
|
|
3699
|
+
- Focus MUST use structured format: ACTIVE/PENDING/WAITING/PROCESSED lines only.
|
|
3700
|
+
- NEVER store: file counts, line numbers, completed work details, data available via \`neo runs <id>\`.
|
|
3701
|
+
- After PR merge: forget related facts unless they are reusable architectural truths.
|
|
3702
|
+
- Pattern escalation: same failure 3+ times \u2192 write a \`procedure\`.
|
|
3703
|
+
- Every memory that references external context MUST include a retrieval command (in \`--category\` for tasks, in content for facts/procedures). You are stateless \u2014 if you can't retrieve it later, don't store it.
|
|
3704
|
+
</memory-rules>
|
|
3705
|
+
|
|
3706
|
+
<task-workflow>
|
|
3707
|
+
Tasks are your work queue. The work queue section above shows them with markers (\`\u25CB\` pending, \`[ACTIVE]\` in_progress, \`[BLOCKED]\` blocked).
|
|
3708
|
+
|
|
3709
|
+
Create a task for any planned work: incoming tickets, architect decompositions, refiner sub-tickets, follow-up actions, CI fixes.
|
|
3710
|
+
- \`--severity critical|high|medium|low\` \u2014 dispatch highest severity first
|
|
3711
|
+
- \`--tags "initiative:<name>"\` \u2014 groups related tasks (shown as [initiative] headers in queue)
|
|
3712
|
+
- \`--tags "depends:mem_<id>"\` \u2014 task cannot start until dependency is done
|
|
3713
|
+
- \`--category\` \u2014 **MANDATORY** \u2014 the command to retrieve context for this task (shown as \`\u2192 <command>\` in queue)
|
|
3714
|
+
|
|
3715
|
+
**Context retrieval rule**: every task and relevant memory MUST include a way for you to access its source context at a future heartbeat. You are stateless \u2014 without this, you lose the context.
|
|
3716
|
+
- Agent output: \`--category "neo runs <runId>"\`
|
|
3717
|
+
- Note/plan: \`--category "cat notes/plan-feature.md"\`
|
|
3718
|
+
- Notion ticket: \`--category "API-retrieve-a-page <notionPageId>"\`
|
|
3719
|
+
- Architect decomposition: \`--category "neo runs <architectRunId>"\` (contains milestones + tasks)
|
|
3720
|
+
|
|
3721
|
+
Lifecycle: create \u2192 \`neo memory update <id> --outcome in_progress\` (on dispatch) \u2192 \`done\` (on success) / \`blocked\` (on failure, will retry) / \`abandoned\` (terminal, won't retry)
|
|
3722
|
+
|
|
3723
|
+
Dispatch rule: pick the highest-severity task with no unmet dependencies. Dispatch independent tasks in parallel. Before dispatching, run the \`--category\` command to retrieve task context.
|
|
3724
|
+
</task-workflow>
|
|
3725
|
+
|
|
3726
|
+
<focus-format>
|
|
3727
|
+
ACTIVE: <runId> <agent> "<task>" branch:<name>
|
|
3728
|
+
PENDING: <taskId> "<description>" depends:<taskId>
|
|
3729
|
+
WAITING: <what> since:HB<N>
|
|
3730
|
+
PROCESSED: <runId> \u2192 <outcome> PR#<N>
|
|
3731
|
+
</focus-format>
|
|
3732
|
+
|
|
3733
|
+
**Notes** (\`notes/\`, via Bash): use for detailed multi-page plans that span multiple heartbeats. After creating a plan, write a focus summary with \`--category "cat notes/<file>"\`. Delete notes when done.`;
|
|
3734
|
+
var MEMORY_RULES_EXAMPLES = `<memory-commands>
|
|
3735
|
+
neo memory write --type focus --expires 2h "ACTIVE: 5900a64a developer 'T1' branch:feat/x"
|
|
3736
|
+
neo memory write --type fact --scope /repo "CI requires pnpm build \u2014 discovered in run abc123"
|
|
3737
|
+
neo memory write --type procedure --scope /repo "Check gh pr view before re-dispatch"
|
|
3738
|
+
neo memory write --type task --scope /repo --severity high --category "neo runs abc123" --tags "initiative:auth-v2,depends:mem_xyz" "T1: Auth middleware"
|
|
3739
|
+
neo memory update <id> --outcome in_progress|done|blocked|abandoned
|
|
3740
|
+
neo memory forget <id>
|
|
3741
|
+
</memory-commands>`;
|
|
3742
|
+
function getCommandsSection(heartbeatCount) {
|
|
3743
|
+
return heartbeatCount <= 3 ? COMMANDS : COMMANDS_COMPACT;
|
|
3744
|
+
}
|
|
3745
|
+
function buildContextSections(opts) {
|
|
3746
|
+
const parts = [];
|
|
2518
3747
|
if (opts.repos.length > 0) {
|
|
2519
3748
|
const repoList = opts.repos.map((r) => `- ${r.path} (branch: ${r.defaultBranch})`).join("\n");
|
|
2520
|
-
|
|
3749
|
+
parts.push(`Repositories:
|
|
2521
3750
|
${repoList}`);
|
|
2522
|
-
} else {
|
|
2523
|
-
sections.push("## Registered repositories\n(none \u2014 run 'neo init' in a repo to register it)");
|
|
2524
3751
|
}
|
|
2525
3752
|
if (opts.mcpServerNames.length > 0) {
|
|
2526
3753
|
const mcpList = opts.mcpServerNames.map((n) => `- ${n}`).join("\n");
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
${mcpList}
|
|
2530
|
-
|
|
2531
|
-
You can use these tools directly to query external systems.`
|
|
2532
|
-
);
|
|
3754
|
+
parts.push(`Integrations (MCP):
|
|
3755
|
+
${mcpList}`);
|
|
2533
3756
|
}
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
- Today: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`
|
|
3757
|
+
parts.push(
|
|
3758
|
+
`Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)`
|
|
2537
3759
|
);
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
3760
|
+
return parts;
|
|
3761
|
+
}
|
|
3762
|
+
function buildMemorySection(memories, supervisorDir) {
|
|
3763
|
+
const focusEntries = memories.filter((m) => m.type === "focus");
|
|
3764
|
+
const factEntries = memories.filter((m) => m.type === "fact");
|
|
3765
|
+
const procedureEntries = memories.filter((m) => m.type === "procedure");
|
|
3766
|
+
const feedbackEntries = memories.filter((m) => m.type === "feedback");
|
|
3767
|
+
const parts = [];
|
|
3768
|
+
if (focusEntries.length > 0) {
|
|
3769
|
+
const lines = focusEntries.map((m) => `- ${m.content}`).join("\n");
|
|
3770
|
+
parts.push(`<focus>
|
|
3771
|
+
${lines}
|
|
3772
|
+
</focus>`);
|
|
2546
3773
|
} else {
|
|
2547
|
-
|
|
2548
|
-
"
|
|
3774
|
+
parts.push(
|
|
3775
|
+
"<focus>\n(empty \u2014 use neo memory write --type focus to set working context)\n</focus>"
|
|
2549
3776
|
);
|
|
2550
3777
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
3778
|
+
if (factEntries.length > 0) {
|
|
3779
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
3780
|
+
for (const m of factEntries) {
|
|
3781
|
+
const scope = m.scope === "global" ? "global" : m.scope.split("/").pop() ?? m.scope;
|
|
3782
|
+
const group = byScope.get(scope) ?? [];
|
|
3783
|
+
group.push(m);
|
|
3784
|
+
byScope.set(scope, group);
|
|
3785
|
+
}
|
|
3786
|
+
const scopeSections = [];
|
|
3787
|
+
for (const [scope, entries] of byScope) {
|
|
3788
|
+
const oldestAccess = Math.min(
|
|
3789
|
+
...entries.map((m) => Date.now() - new Date(m.lastAccessedAt).getTime())
|
|
3790
|
+
);
|
|
3791
|
+
const daysAgo = Math.floor(oldestAccess / 864e5);
|
|
3792
|
+
const staleHint = daysAgo >= 5 ? ` (last accessed ${daysAgo}d ago)` : "";
|
|
3793
|
+
const lines = entries.map((m) => {
|
|
3794
|
+
const confidence = m.accessCount >= 3 ? "" : " (unconfirmed)";
|
|
3795
|
+
return ` - ${m.content}${confidence}`;
|
|
3796
|
+
}).join("\n");
|
|
3797
|
+
scopeSections.push(` [${scope}]${staleHint} (${entries.length})
|
|
3798
|
+
${lines}`);
|
|
3799
|
+
}
|
|
3800
|
+
parts.push(`Known facts:
|
|
3801
|
+
${scopeSections.join("\n")}`);
|
|
3802
|
+
}
|
|
3803
|
+
if (procedureEntries.length > 0) {
|
|
3804
|
+
const lines = procedureEntries.map((m) => `- ${m.content}`).join("\n");
|
|
3805
|
+
parts.push(`Procedures:
|
|
3806
|
+
${lines}`);
|
|
3807
|
+
}
|
|
3808
|
+
if (feedbackEntries.length > 0) {
|
|
3809
|
+
const lines = feedbackEntries.map((m) => `- [${m.category ?? "general"}] ${m.content}`).join("\n");
|
|
3810
|
+
parts.push(`Recurring review issues:
|
|
3811
|
+
${lines}`);
|
|
3812
|
+
}
|
|
3813
|
+
parts.push(`For detailed plans and checklists, use notes:
|
|
3814
|
+
\`\`\`bash
|
|
3815
|
+
cat > ${supervisorDir}/notes/plan-feature.md << 'EOF'
|
|
3816
|
+
<your detailed plan here>
|
|
3817
|
+
EOF
|
|
3818
|
+
\`\`\``);
|
|
3819
|
+
return parts.join("\n\n");
|
|
2553
3820
|
}
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
"
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
return `## Your current memory
|
|
2565
|
-
(empty \u2014 this is your first heartbeat, initialize your memory)
|
|
2566
|
-
|
|
2567
|
-
Your memory MUST be a JSON object inside \`<memory>...</memory>\` tags:
|
|
2568
|
-
\`\`\`
|
|
2569
|
-
${schema}
|
|
2570
|
-
\`\`\`
|
|
2571
|
-
Keep under 8KB. Prune old decisions (keep last 10).`;
|
|
3821
|
+
var DONE_OUTCOMES = /* @__PURE__ */ new Set(["done", "abandoned"]);
|
|
3822
|
+
var MAX_TASKS = 15;
|
|
3823
|
+
function buildWorkQueueSection(memories) {
|
|
3824
|
+
const tasks = memories.filter((m) => m.type === "task" && !DONE_OUTCOMES.has(m.outcome ?? ""));
|
|
3825
|
+
const doneCount = countDoneTasks(memories);
|
|
3826
|
+
if (tasks.length === 0) {
|
|
3827
|
+
if (doneCount > 0) {
|
|
3828
|
+
return `Work queue (0 remaining, ${doneCount} done) \u2014 all tasks complete. Pick up new work or wait for events.`;
|
|
3829
|
+
}
|
|
3830
|
+
return "";
|
|
2572
3831
|
}
|
|
2573
|
-
const
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
3832
|
+
const groups = groupTasksByInitiative(tasks);
|
|
3833
|
+
const lines = renderTaskGroups(groups);
|
|
3834
|
+
if (tasks.length > MAX_TASKS) {
|
|
3835
|
+
lines.push(` ... and ${tasks.length - MAX_TASKS} more pending`);
|
|
3836
|
+
}
|
|
3837
|
+
const header = `Work queue (${tasks.length} remaining, ${doneCount} done) \u2014 dispatch the next eligible task:`;
|
|
3838
|
+
return `${header}
|
|
3839
|
+
${lines.join("\n")}`;
|
|
3840
|
+
}
|
|
3841
|
+
function countDoneTasks(memories) {
|
|
3842
|
+
return memories.filter((m) => m.type === "task" && DONE_OUTCOMES.has(m.outcome ?? "")).length;
|
|
3843
|
+
}
|
|
3844
|
+
function groupTasksByInitiative(tasks) {
|
|
3845
|
+
const initiativeMap = /* @__PURE__ */ new Map();
|
|
3846
|
+
const noInitiative = [];
|
|
3847
|
+
for (const task of tasks) {
|
|
3848
|
+
const tag = task.tags.find((t) => t.startsWith("initiative:"));
|
|
3849
|
+
if (tag) {
|
|
3850
|
+
const key = tag.slice("initiative:".length);
|
|
3851
|
+
const group = initiativeMap.get(key) ?? [];
|
|
3852
|
+
group.push(task);
|
|
3853
|
+
initiativeMap.set(key, group);
|
|
3854
|
+
} else {
|
|
3855
|
+
noInitiative.push(task);
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
const groups = [];
|
|
3859
|
+
for (const [initiative, taskList] of initiativeMap) {
|
|
3860
|
+
groups.push({ initiative, tasks: taskList });
|
|
3861
|
+
}
|
|
3862
|
+
if (noInitiative.length > 0) {
|
|
3863
|
+
groups.push({ initiative: null, tasks: noInitiative });
|
|
3864
|
+
}
|
|
3865
|
+
return groups;
|
|
3866
|
+
}
|
|
3867
|
+
function renderTaskGroups(groups) {
|
|
3868
|
+
const lines = [];
|
|
3869
|
+
let rendered = 0;
|
|
3870
|
+
for (const group of groups) {
|
|
3871
|
+
if (rendered >= MAX_TASKS) break;
|
|
3872
|
+
if (group.initiative && groups.length > 1) {
|
|
3873
|
+
lines.push(` [${group.initiative}]`);
|
|
3874
|
+
}
|
|
3875
|
+
for (const task of group.tasks) {
|
|
3876
|
+
if (rendered >= MAX_TASKS) break;
|
|
3877
|
+
lines.push(` ${formatTaskLine(task)}`);
|
|
3878
|
+
rendered++;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
return lines;
|
|
3882
|
+
}
|
|
3883
|
+
function formatTaskLine(task) {
|
|
3884
|
+
const marker = formatTaskMarker(task.outcome);
|
|
3885
|
+
const severity = task.severity ? `[${task.severity}] ` : "";
|
|
3886
|
+
const scope = task.scope !== "global" ? ` (${getBasename(task.scope)})` : "";
|
|
3887
|
+
const run = task.runId ? ` [run ${task.runId.slice(0, 8)}]` : "";
|
|
3888
|
+
const cat = task.category ? ` \u2192 ${task.category}` : "";
|
|
3889
|
+
return `${marker} ${severity}${task.content}${scope}${run}${cat}`;
|
|
3890
|
+
}
|
|
3891
|
+
function formatTaskMarker(outcome) {
|
|
3892
|
+
switch (outcome) {
|
|
3893
|
+
case "in_progress":
|
|
3894
|
+
return "[ACTIVE]";
|
|
3895
|
+
case "blocked":
|
|
3896
|
+
return "[BLOCKED]";
|
|
3897
|
+
default:
|
|
3898
|
+
return "\u25CB";
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
function getBasename(scopePath) {
|
|
3902
|
+
const parts = scopePath.split("/");
|
|
3903
|
+
return parts[parts.length - 1] || scopePath;
|
|
3904
|
+
}
|
|
3905
|
+
var SIGNIFICANT_TYPES = /* @__PURE__ */ new Set(["decision", "action", "dispatch", "error"]);
|
|
3906
|
+
function buildRecentActionsSection(entries) {
|
|
3907
|
+
const significant = entries.filter((e) => SIGNIFICANT_TYPES.has(e.type));
|
|
3908
|
+
if (significant.length === 0) return "";
|
|
3909
|
+
const lines = significant.map((e) => {
|
|
3910
|
+
const ago = formatTimeAgo(Date.now() - new Date(e.timestamp).getTime());
|
|
3911
|
+
return `- [${e.type}] ${e.summary} (${ago})`;
|
|
3912
|
+
});
|
|
3913
|
+
return `Recent actions (your last heartbeats):
|
|
3914
|
+
${lines.join("\n")}`;
|
|
3915
|
+
}
|
|
3916
|
+
function formatTimeAgo(ms) {
|
|
3917
|
+
if (ms < 6e4) return "just now";
|
|
3918
|
+
const minutes = Math.floor(ms / 6e4);
|
|
3919
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
3920
|
+
const hours = Math.floor(minutes / 60);
|
|
3921
|
+
if (hours < 24) return `${hours}h${minutes % 60}m ago`;
|
|
3922
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
3923
|
+
}
|
|
3924
|
+
function buildEventsSection(grouped) {
|
|
3925
|
+
const { messages, webhooks, runCompletions } = grouped;
|
|
3926
|
+
const totalEvents = messages.length + webhooks.length + runCompletions.length;
|
|
3927
|
+
if (totalEvents === 0) {
|
|
3928
|
+
return "No new events.";
|
|
3929
|
+
}
|
|
3930
|
+
const parts = [];
|
|
3931
|
+
for (const msg of messages) {
|
|
3932
|
+
const countSuffix = msg.count > 1 ? ` (x${msg.count})` : "";
|
|
3933
|
+
parts.push(`Message from ${msg.from}${countSuffix}: ${msg.text}`);
|
|
3934
|
+
}
|
|
3935
|
+
for (const evt of webhooks) {
|
|
3936
|
+
parts.push(formatEvent(evt));
|
|
3937
|
+
}
|
|
3938
|
+
for (const evt of runCompletions) {
|
|
3939
|
+
parts.push(formatEvent(evt));
|
|
3940
|
+
}
|
|
3941
|
+
return `${totalEvents} pending event(s):
|
|
3942
|
+
${parts.join("\n\n")}`;
|
|
2579
3943
|
}
|
|
2580
3944
|
function formatEvent(event) {
|
|
2581
3945
|
switch (event.kind) {
|
|
2582
3946
|
case "webhook":
|
|
2583
|
-
return
|
|
3947
|
+
return `Webhook [${event.data.source ?? "unknown"}] ${event.data.event ?? ""}
|
|
2584
3948
|
\`\`\`json
|
|
2585
|
-
${JSON.stringify(event.data.payload ?? {}, null, 2)
|
|
3949
|
+
${JSON.stringify(event.data.payload ?? {}, null, 2)}
|
|
2586
3950
|
\`\`\``;
|
|
2587
3951
|
case "message":
|
|
2588
|
-
return
|
|
3952
|
+
return `Message from ${event.data.from}: ${event.data.text}`;
|
|
2589
3953
|
case "run_complete":
|
|
2590
|
-
return
|
|
3954
|
+
return `Run completed: ${event.runId} (check with \`neo runs\`)`;
|
|
3955
|
+
case "internal":
|
|
3956
|
+
return `Internal event: ${event.eventKind}`;
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
function isIdleHeartbeat(opts) {
|
|
3960
|
+
const { messages, webhooks, runCompletions } = opts.grouped;
|
|
3961
|
+
const totalEvents = messages.length + webhooks.length + runCompletions.length;
|
|
3962
|
+
const hasWork = buildWorkQueueSection(opts.memories) !== "";
|
|
3963
|
+
return totalEvents === 0 && opts.activeRuns.length === 0 && !hasWork;
|
|
3964
|
+
}
|
|
3965
|
+
function buildIdlePrompt(opts) {
|
|
3966
|
+
return `<role>
|
|
3967
|
+
${ROLE}
|
|
3968
|
+
Heartbeat #${opts.heartbeatCount}
|
|
3969
|
+
</role>
|
|
3970
|
+
|
|
3971
|
+
<context>
|
|
3972
|
+
No events. No active runs. No pending tasks.
|
|
3973
|
+
Budget: $${opts.budgetStatus.todayUsd.toFixed(2)} / $${opts.budgetStatus.capUsd.toFixed(2)} (${opts.budgetStatus.remainingPct.toFixed(0)}% remaining)
|
|
3974
|
+
</context>
|
|
3975
|
+
|
|
3976
|
+
<directive>
|
|
3977
|
+
Nothing to do. Run \`neo log discovery "idle"\` and yield. Do not produce any other output.
|
|
3978
|
+
</directive>`;
|
|
3979
|
+
}
|
|
3980
|
+
function buildStandardPrompt(opts) {
|
|
3981
|
+
const sections = [];
|
|
3982
|
+
sections.push(`<role>
|
|
3983
|
+
${ROLE}
|
|
3984
|
+
Heartbeat #${opts.heartbeatCount}
|
|
3985
|
+
</role>`);
|
|
3986
|
+
const contextParts = [];
|
|
3987
|
+
const workQueue = buildWorkQueueSection(opts.memories);
|
|
3988
|
+
if (workQueue) {
|
|
3989
|
+
contextParts.push(workQueue);
|
|
3990
|
+
}
|
|
3991
|
+
if (opts.activeRuns.length > 0) {
|
|
3992
|
+
contextParts.push(`Active runs:
|
|
3993
|
+
${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
|
|
3994
|
+
}
|
|
3995
|
+
contextParts.push(...buildContextSections(opts));
|
|
3996
|
+
contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
|
|
3997
|
+
const recentActions = buildRecentActionsSection(opts.recentActions);
|
|
3998
|
+
if (recentActions) {
|
|
3999
|
+
contextParts.push(recentActions);
|
|
4000
|
+
}
|
|
4001
|
+
contextParts.push(`Events:
|
|
4002
|
+
${buildEventsSection(opts.grouped)}`);
|
|
4003
|
+
sections.push(`<context>
|
|
4004
|
+
${contextParts.join("\n\n")}
|
|
4005
|
+
</context>`);
|
|
4006
|
+
sections.push(`<reference>
|
|
4007
|
+
${getCommandsSection(opts.heartbeatCount)}
|
|
4008
|
+
</reference>`);
|
|
4009
|
+
const instructionParts = [];
|
|
4010
|
+
instructionParts.push(HEARTBEAT_RULES);
|
|
4011
|
+
instructionParts.push(REPORTING_RULES);
|
|
4012
|
+
instructionParts.push(MEMORY_RULES_CORE);
|
|
4013
|
+
if (opts.customInstructions) {
|
|
4014
|
+
instructionParts.push(`### Custom instructions
|
|
4015
|
+
${opts.customInstructions}`);
|
|
4016
|
+
}
|
|
4017
|
+
const { messages, webhooks, runCompletions } = opts.grouped;
|
|
4018
|
+
const hasEvents = messages.length + webhooks.length + runCompletions.length > 0;
|
|
4019
|
+
instructionParts.push(
|
|
4020
|
+
hasEvents ? "Process events, dispatch eligible work, yield. Each heartbeat costs ~$0.10 \u2014 be efficient." : "No events. If pending work exists, dispatch it. Otherwise yield immediately."
|
|
4021
|
+
);
|
|
4022
|
+
sections.push(`<instructions>
|
|
4023
|
+
${instructionParts.join("\n\n")}
|
|
4024
|
+
</instructions>`);
|
|
4025
|
+
return sections.join("\n\n");
|
|
4026
|
+
}
|
|
4027
|
+
function buildConsolidationPrompt(opts) {
|
|
4028
|
+
const sections = [];
|
|
4029
|
+
sections.push(`<role>
|
|
4030
|
+
${ROLE}
|
|
4031
|
+
Heartbeat #${opts.heartbeatCount} (CONSOLIDATION)
|
|
4032
|
+
</role>`);
|
|
4033
|
+
const contextParts = [];
|
|
4034
|
+
const workQueueConsolidation = buildWorkQueueSection(opts.memories);
|
|
4035
|
+
if (workQueueConsolidation) {
|
|
4036
|
+
contextParts.push(workQueueConsolidation);
|
|
4037
|
+
}
|
|
4038
|
+
if (opts.activeRuns.length > 0) {
|
|
4039
|
+
contextParts.push(`Active runs:
|
|
4040
|
+
${opts.activeRuns.map((r) => `- ${r}`).join("\n")}`);
|
|
4041
|
+
}
|
|
4042
|
+
contextParts.push(...buildContextSections(opts));
|
|
4043
|
+
contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
|
|
4044
|
+
const recentActions = buildRecentActionsSection(opts.recentActions);
|
|
4045
|
+
if (recentActions) {
|
|
4046
|
+
contextParts.push(recentActions);
|
|
4047
|
+
}
|
|
4048
|
+
contextParts.push(`Events:
|
|
4049
|
+
${buildEventsSection(opts.grouped)}`);
|
|
4050
|
+
sections.push(`<context>
|
|
4051
|
+
${contextParts.join("\n\n")}
|
|
4052
|
+
</context>`);
|
|
4053
|
+
sections.push(`<reference>
|
|
4054
|
+
${getCommandsSection(opts.heartbeatCount)}
|
|
4055
|
+
</reference>`);
|
|
4056
|
+
const instructionParts = [];
|
|
4057
|
+
instructionParts.push(HEARTBEAT_RULES);
|
|
4058
|
+
instructionParts.push(REPORTING_RULES);
|
|
4059
|
+
instructionParts.push(MEMORY_RULES_CORE);
|
|
4060
|
+
instructionParts.push(MEMORY_RULES_EXAMPLES);
|
|
4061
|
+
if (opts.customInstructions) {
|
|
4062
|
+
instructionParts.push(`### Custom instructions
|
|
4063
|
+
${opts.customInstructions}`);
|
|
4064
|
+
}
|
|
4065
|
+
instructionParts.push(
|
|
4066
|
+
`### Consolidation
|
|
4067
|
+
This is a CONSOLIDATION heartbeat.
|
|
4068
|
+
|
|
4069
|
+
**Idle guard**: if there are NO active runs AND no new events since last consolidation, log "idle, no changes" and yield immediately. Do NOT re-validate facts you already reviewed.
|
|
4070
|
+
|
|
4071
|
+
If there IS active work, your job:
|
|
4072
|
+
|
|
4073
|
+
1. **Review memory** \u2014 check facts and procedures for accuracy. Remove outdated entries. Resolve contradictions (keep newer). Remove facts about completed work (merged PRs, finished initiatives).
|
|
4074
|
+
2. **Update focus** \u2014 rewrite focus using the MANDATORY structured format (ACTIVE/PENDING/WAITING/PROCESSED). Remove resolved items. Add new context.
|
|
4075
|
+
3. **Pattern escalation** \u2014 if agents hit the same issue 3+ times (check recent actions), write a \`procedure\` to prevent recurrence.
|
|
4076
|
+
4. **Prune completed work** \u2014 if a PR is merged or an initiative is done, forget related facts that are no longer actionable. Keep only reusable architectural truths.
|
|
4077
|
+
5. **Prune done tasks** \u2014 forget tasks with outcome \`done\` or \`abandoned\` older than 7 days.`
|
|
4078
|
+
);
|
|
4079
|
+
sections.push(`<instructions>
|
|
4080
|
+
${instructionParts.join("\n\n")}
|
|
4081
|
+
</instructions>`);
|
|
4082
|
+
return sections.join("\n\n");
|
|
4083
|
+
}
|
|
4084
|
+
function buildCompactionPrompt(opts) {
|
|
4085
|
+
const sections = [];
|
|
4086
|
+
sections.push(`<role>
|
|
4087
|
+
${ROLE}
|
|
4088
|
+
Heartbeat #${opts.heartbeatCount} (COMPACTION)
|
|
4089
|
+
</role>`);
|
|
4090
|
+
const contextParts = [];
|
|
4091
|
+
contextParts.push(...buildContextSections(opts));
|
|
4092
|
+
contextParts.push(buildMemorySection(opts.memories, opts.supervisorDir));
|
|
4093
|
+
const workQueueCompaction = buildWorkQueueSection(opts.memories);
|
|
4094
|
+
if (workQueueCompaction) {
|
|
4095
|
+
contextParts.push(workQueueCompaction);
|
|
4096
|
+
}
|
|
4097
|
+
sections.push(`<context>
|
|
4098
|
+
${contextParts.join("\n\n")}
|
|
4099
|
+
</context>`);
|
|
4100
|
+
sections.push(`<reference>
|
|
4101
|
+
${getCommandsSection(opts.heartbeatCount)}
|
|
4102
|
+
</reference>`);
|
|
4103
|
+
const instructionParts = [];
|
|
4104
|
+
instructionParts.push(HEARTBEAT_RULES);
|
|
4105
|
+
instructionParts.push(REPORTING_RULES);
|
|
4106
|
+
instructionParts.push(MEMORY_RULES_CORE);
|
|
4107
|
+
instructionParts.push(MEMORY_RULES_EXAMPLES);
|
|
4108
|
+
if (opts.customInstructions) {
|
|
4109
|
+
instructionParts.push(`### Custom instructions
|
|
4110
|
+
${opts.customInstructions}`);
|
|
2591
4111
|
}
|
|
4112
|
+
instructionParts.push(`### Compaction
|
|
4113
|
+
This is a COMPACTION heartbeat. Deep-clean your ENTIRE memory.
|
|
4114
|
+
|
|
4115
|
+
1. **Remove stale facts** \u2014 facts >7 days old with no recent reinforcement. Check the "(last accessed Xd ago)" hints in the facts section.
|
|
4116
|
+
2. **Remove completed-work facts** \u2014 if all PRs for a repo initiative are merged/closed, forget related facts. Keep only reusable architectural truths (build system, CI config, tooling).
|
|
4117
|
+
3. **Remove trivial facts** \u2014 file counts, line numbers, structural details that \`ls\` or \`cat package.json\` can answer. These waste context.
|
|
4118
|
+
4. **Merge duplicates** \u2014 combine similar facts within the same scope into one.
|
|
4119
|
+
5. **Clean up focus** \u2014 forget resolved items, rewrite remaining in structured format.
|
|
4120
|
+
6. **Prune done tasks** \u2014 forget tasks with outcome \`done\` or \`abandoned\` older than 7 days.
|
|
4121
|
+
7. **Delete completed notes** from notes/ directory.
|
|
4122
|
+
8. **Stay under 15 facts per scope** \u2014 prioritize facts that affect dispatch decisions.
|
|
4123
|
+
|
|
4124
|
+
Flag contradictions: if two facts contradict, keep the newer one.
|
|
4125
|
+
|
|
4126
|
+
\`\`\`bash
|
|
4127
|
+
neo memory list --type fact
|
|
4128
|
+
neo memory forget <stale-id>
|
|
4129
|
+
\`\`\``);
|
|
4130
|
+
sections.push(`<instructions>
|
|
4131
|
+
${instructionParts.join("\n\n")}
|
|
4132
|
+
</instructions>`);
|
|
4133
|
+
return sections.join("\n\n");
|
|
2592
4134
|
}
|
|
2593
4135
|
|
|
2594
4136
|
// src/supervisor/heartbeat.ts
|
|
4137
|
+
var DEFAULT_IDLE_SKIP_MAX = 20;
|
|
4138
|
+
var DEFAULT_ACTIVE_WORK_SKIP_MAX = 3;
|
|
4139
|
+
var DEFAULT_CONSOLIDATION_INTERVAL = 5;
|
|
4140
|
+
function shouldConsolidate(heartbeatCount, lastConsolidationHeartbeat, consolidationInterval, hasPendingEntries) {
|
|
4141
|
+
const since = heartbeatCount - lastConsolidationHeartbeat;
|
|
4142
|
+
if (since >= consolidationInterval) return true;
|
|
4143
|
+
if (hasPendingEntries && since >= 2) return true;
|
|
4144
|
+
return false;
|
|
4145
|
+
}
|
|
4146
|
+
function shouldCompact(heartbeatCount, lastCompactionHeartbeat, compactionInterval = 50) {
|
|
4147
|
+
const since = heartbeatCount - lastCompactionHeartbeat;
|
|
4148
|
+
return since >= compactionInterval;
|
|
4149
|
+
}
|
|
2595
4150
|
var HeartbeatLoop = class {
|
|
2596
4151
|
stopping = false;
|
|
2597
4152
|
consecutiveFailures = 0;
|
|
@@ -2603,6 +4158,9 @@ var HeartbeatLoop = class {
|
|
|
2603
4158
|
eventQueue;
|
|
2604
4159
|
activityLog;
|
|
2605
4160
|
customInstructions;
|
|
4161
|
+
defaultInstructionsPath;
|
|
4162
|
+
memoryStore = null;
|
|
4163
|
+
memoryDbPath;
|
|
2606
4164
|
constructor(options) {
|
|
2607
4165
|
this.config = options.config;
|
|
2608
4166
|
this.supervisorDir = options.supervisorDir;
|
|
@@ -2610,6 +4168,17 @@ var HeartbeatLoop = class {
|
|
|
2610
4168
|
this.sessionId = options.sessionId;
|
|
2611
4169
|
this.eventQueue = options.eventQueue;
|
|
2612
4170
|
this.activityLog = options.activityLog;
|
|
4171
|
+
this.defaultInstructionsPath = options.defaultInstructionsPath;
|
|
4172
|
+
this.memoryDbPath = options.memoryDbPath;
|
|
4173
|
+
}
|
|
4174
|
+
getMemoryStore() {
|
|
4175
|
+
if (!this.memoryStore && this.memoryDbPath) {
|
|
4176
|
+
try {
|
|
4177
|
+
this.memoryStore = new MemoryStore(this.memoryDbPath);
|
|
4178
|
+
} catch {
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
return this.memoryStore;
|
|
2613
4182
|
}
|
|
2614
4183
|
async start() {
|
|
2615
4184
|
this.customInstructions = await this.loadInstructions();
|
|
@@ -2624,7 +4193,7 @@ var HeartbeatLoop = class {
|
|
|
2624
4193
|
await this.activityLog.log("error", `Heartbeat failed: ${msg}`, { error: msg });
|
|
2625
4194
|
if (this.consecutiveFailures >= this.config.supervisor.maxConsecutiveFailures) {
|
|
2626
4195
|
const backoffMs = Math.min(
|
|
2627
|
-
this.config.supervisor.
|
|
4196
|
+
this.config.supervisor.eventTimeoutMs * 2 ** (this.consecutiveFailures - this.config.supervisor.maxConsecutiveFailures),
|
|
2628
4197
|
15 * 60 * 1e3
|
|
2629
4198
|
// max 15 minutes
|
|
2630
4199
|
);
|
|
@@ -2637,7 +4206,7 @@ var HeartbeatLoop = class {
|
|
|
2637
4206
|
}
|
|
2638
4207
|
}
|
|
2639
4208
|
if (this.stopping) break;
|
|
2640
|
-
await this.eventQueue.waitForEvent(this.config.supervisor.
|
|
4209
|
+
await this.eventQueue.waitForEvent(this.config.supervisor.eventTimeoutMs);
|
|
2641
4210
|
}
|
|
2642
4211
|
await this.activityLog.log("heartbeat", "Supervisor heartbeat loop stopped");
|
|
2643
4212
|
}
|
|
@@ -2648,43 +4217,234 @@ var HeartbeatLoop = class {
|
|
|
2648
4217
|
}
|
|
2649
4218
|
async runHeartbeat() {
|
|
2650
4219
|
const startTime = Date.now();
|
|
2651
|
-
const heartbeatId =
|
|
4220
|
+
const heartbeatId = randomUUID5();
|
|
2652
4221
|
const state = await this.readState();
|
|
2653
4222
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4223
|
+
const budgetCheck = await this.checkBudgetExceeded(state, today);
|
|
4224
|
+
if (budgetCheck.exceeded) return;
|
|
4225
|
+
const grouped = this.eventQueue.drainAndGroup();
|
|
4226
|
+
const totalEventCount = grouped.messages.length + grouped.webhooks.length + grouped.runCompletions.length;
|
|
4227
|
+
const activeRuns = await this.getActiveRuns();
|
|
4228
|
+
const skipResult = await this.handleSkipLogic({
|
|
4229
|
+
state,
|
|
4230
|
+
totalEventCount,
|
|
4231
|
+
activeRuns
|
|
4232
|
+
});
|
|
4233
|
+
if (skipResult.shouldSkip) return;
|
|
4234
|
+
if (skipResult.resetCounters) {
|
|
4235
|
+
await this.updateState({ idleSkipCount: 0, activeWorkSkipCount: 0 });
|
|
4236
|
+
}
|
|
4237
|
+
const modeResult = await this.determineHeartbeatMode(state);
|
|
4238
|
+
const { prompt, modeLabel } = await this.buildHeartbeatModePrompt({
|
|
4239
|
+
grouped,
|
|
4240
|
+
todayCost: budgetCheck.todayCost,
|
|
4241
|
+
heartbeatCount: modeResult.heartbeatCount,
|
|
4242
|
+
unconsolidated: modeResult.unconsolidated,
|
|
4243
|
+
isCompaction: modeResult.isCompaction,
|
|
4244
|
+
isConsolidation: modeResult.isConsolidation,
|
|
4245
|
+
activeRuns,
|
|
4246
|
+
lastHeartbeat: state?.lastHeartbeat,
|
|
4247
|
+
lastConsolidationTimestamp: modeResult.lastConsolidationTs
|
|
4248
|
+
});
|
|
4249
|
+
await this.activityLog.log(
|
|
4250
|
+
"heartbeat",
|
|
4251
|
+
`Heartbeat #${modeResult.heartbeatCount} starting (${modeLabel})`,
|
|
4252
|
+
{
|
|
4253
|
+
heartbeatId,
|
|
4254
|
+
eventCount: totalEventCount,
|
|
4255
|
+
messages: grouped.messages.length,
|
|
4256
|
+
webhooks: grouped.webhooks.length,
|
|
4257
|
+
runCompletions: grouped.runCompletions.length,
|
|
4258
|
+
isConsolidation: modeResult.isConsolidation
|
|
4259
|
+
}
|
|
4260
|
+
);
|
|
4261
|
+
const { costUsd, turnCount } = await this.callSdk(prompt, heartbeatId);
|
|
4262
|
+
if (modeResult.isConsolidation) {
|
|
4263
|
+
const allIds = modeResult.unconsolidated.map((e) => e.id);
|
|
4264
|
+
if (allIds.length > 0) {
|
|
4265
|
+
await markConsolidated(this.supervisorDir, allIds);
|
|
4266
|
+
}
|
|
4267
|
+
await compactLogBuffer(this.supervisorDir);
|
|
4268
|
+
}
|
|
4269
|
+
const durationMs = Date.now() - startTime;
|
|
4270
|
+
const { stateUpdate } = this.buildStateUpdate({
|
|
4271
|
+
state,
|
|
4272
|
+
today,
|
|
4273
|
+
todayCost: budgetCheck.todayCost,
|
|
4274
|
+
costUsd,
|
|
4275
|
+
heartbeatCount: modeResult.heartbeatCount,
|
|
4276
|
+
isConsolidation: modeResult.isConsolidation,
|
|
4277
|
+
isCompaction: modeResult.isCompaction
|
|
4278
|
+
});
|
|
4279
|
+
await this.updateState(stateUpdate);
|
|
4280
|
+
await this.activityLog.log(
|
|
4281
|
+
"heartbeat",
|
|
4282
|
+
`Heartbeat #${modeResult.heartbeatCount + 1} complete (${modeLabel})`,
|
|
4283
|
+
{
|
|
4284
|
+
heartbeatId,
|
|
4285
|
+
costUsd,
|
|
4286
|
+
durationMs,
|
|
4287
|
+
turnCount,
|
|
4288
|
+
isConsolidation: modeResult.isConsolidation
|
|
4289
|
+
}
|
|
4290
|
+
);
|
|
4291
|
+
}
|
|
4292
|
+
/**
|
|
4293
|
+
* Check if supervisor daily budget is exceeded.
|
|
4294
|
+
*/
|
|
4295
|
+
async checkBudgetExceeded(state, today) {
|
|
2654
4296
|
const todayCost = state?.costResetDate === today ? state.todayCostUsd ?? 0 : 0;
|
|
2655
4297
|
if (todayCost >= this.config.supervisor.dailyCapUsd) {
|
|
2656
4298
|
await this.activityLog.log(
|
|
2657
4299
|
"error",
|
|
2658
4300
|
`Supervisor daily budget exceeded ($${todayCost.toFixed(2)} / $${this.config.supervisor.dailyCapUsd}). Skipping heartbeat.`
|
|
2659
4301
|
);
|
|
2660
|
-
await this.sleep(this.config.supervisor.
|
|
2661
|
-
return;
|
|
4302
|
+
await this.sleep(this.config.supervisor.eventTimeoutMs);
|
|
4303
|
+
return { todayCost, exceeded: true };
|
|
4304
|
+
}
|
|
4305
|
+
return { todayCost, exceeded: false };
|
|
4306
|
+
}
|
|
4307
|
+
/**
|
|
4308
|
+
* Handle skip logic for idle and active-work scenarios.
|
|
4309
|
+
*/
|
|
4310
|
+
async handleSkipLogic(opts) {
|
|
4311
|
+
const { state, totalEventCount, activeRuns } = opts;
|
|
4312
|
+
const idleSkipCount = state?.idleSkipCount ?? 0;
|
|
4313
|
+
const activeWorkSkipCount = state?.activeWorkSkipCount ?? 0;
|
|
4314
|
+
const hasActiveWork = activeRuns.length > 0;
|
|
4315
|
+
if (totalEventCount === 0) {
|
|
4316
|
+
if (hasActiveWork) {
|
|
4317
|
+
if (activeWorkSkipCount < DEFAULT_ACTIVE_WORK_SKIP_MAX) {
|
|
4318
|
+
await this.updateState({
|
|
4319
|
+
activeWorkSkipCount: activeWorkSkipCount + 1,
|
|
4320
|
+
idleSkipCount: 0
|
|
4321
|
+
});
|
|
4322
|
+
await this.activityLog.log(
|
|
4323
|
+
"heartbeat",
|
|
4324
|
+
`Active-work skip #${activeWorkSkipCount + 1}/${DEFAULT_ACTIVE_WORK_SKIP_MAX} \u2014 ${activeRuns.length} runs active, no events`
|
|
4325
|
+
);
|
|
4326
|
+
return { shouldSkip: true, resetCounters: false };
|
|
4327
|
+
}
|
|
4328
|
+
} else {
|
|
4329
|
+
if (idleSkipCount < DEFAULT_IDLE_SKIP_MAX) {
|
|
4330
|
+
await this.updateState({
|
|
4331
|
+
idleSkipCount: idleSkipCount + 1,
|
|
4332
|
+
activeWorkSkipCount: 0
|
|
4333
|
+
});
|
|
4334
|
+
await this.activityLog.log("heartbeat", `Idle skip #${idleSkipCount + 1} \u2014 no events`);
|
|
4335
|
+
return { shouldSkip: true, resetCounters: false };
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
const needsReset = idleSkipCount > 0 || activeWorkSkipCount > 0;
|
|
4340
|
+
return { shouldSkip: false, resetCounters: needsReset };
|
|
4341
|
+
}
|
|
4342
|
+
/**
|
|
4343
|
+
* Determine heartbeat mode: compaction > consolidation > standard.
|
|
4344
|
+
*/
|
|
4345
|
+
async determineHeartbeatMode(state) {
|
|
4346
|
+
const heartbeatCount = state?.heartbeatCount ?? 0;
|
|
4347
|
+
const lastConsolidation = state?.lastConsolidationHeartbeat ?? 0;
|
|
4348
|
+
const lastCompaction = state?.lastCompactionHeartbeat ?? 0;
|
|
4349
|
+
const lastConsolidationTs = state?.lastConsolidationTimestamp;
|
|
4350
|
+
const unconsolidated = await readUnconsolidated(this.supervisorDir);
|
|
4351
|
+
const hasNewEntriesSinceLastConsolidation = lastConsolidationTs ? unconsolidated.some((e) => e.timestamp > lastConsolidationTs) : unconsolidated.length > 0;
|
|
4352
|
+
const hasPendingEntries = unconsolidated.length > 0;
|
|
4353
|
+
const isCompaction = shouldCompact(heartbeatCount, lastCompaction);
|
|
4354
|
+
const wouldConsolidate = shouldConsolidate(
|
|
4355
|
+
heartbeatCount,
|
|
4356
|
+
lastConsolidation,
|
|
4357
|
+
DEFAULT_CONSOLIDATION_INTERVAL,
|
|
4358
|
+
hasPendingEntries
|
|
4359
|
+
);
|
|
4360
|
+
const isConsolidation = isCompaction || wouldConsolidate && hasNewEntriesSinceLastConsolidation;
|
|
4361
|
+
return {
|
|
4362
|
+
isConsolidation,
|
|
4363
|
+
isCompaction,
|
|
4364
|
+
unconsolidated,
|
|
4365
|
+
heartbeatCount,
|
|
4366
|
+
lastConsolidation,
|
|
4367
|
+
lastConsolidationTs
|
|
4368
|
+
};
|
|
4369
|
+
}
|
|
4370
|
+
/**
|
|
4371
|
+
* Build the state update object after heartbeat completion.
|
|
4372
|
+
*/
|
|
4373
|
+
buildStateUpdate(opts) {
|
|
4374
|
+
const stateUpdate = {
|
|
4375
|
+
sessionId: this.sessionId,
|
|
4376
|
+
lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4377
|
+
heartbeatCount: opts.heartbeatCount + 1,
|
|
4378
|
+
totalCostUsd: (opts.state?.totalCostUsd ?? 0) + opts.costUsd,
|
|
4379
|
+
todayCostUsd: opts.todayCost + opts.costUsd,
|
|
4380
|
+
costResetDate: opts.today
|
|
4381
|
+
};
|
|
4382
|
+
if (opts.isConsolidation) {
|
|
4383
|
+
stateUpdate.lastConsolidationHeartbeat = opts.heartbeatCount + 1;
|
|
4384
|
+
stateUpdate.lastConsolidationTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2662
4385
|
}
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
4386
|
+
if (opts.isCompaction) {
|
|
4387
|
+
stateUpdate.lastCompactionHeartbeat = opts.heartbeatCount + 1;
|
|
4388
|
+
}
|
|
4389
|
+
return { stateUpdate };
|
|
4390
|
+
}
|
|
4391
|
+
/**
|
|
4392
|
+
* Build the prompt for the current heartbeat mode.
|
|
4393
|
+
*/
|
|
4394
|
+
async buildHeartbeatModePrompt(opts) {
|
|
2666
4395
|
const mcpServerNames = this.config.mcpServers ? Object.keys(this.config.mcpServers) : [];
|
|
2667
|
-
const
|
|
4396
|
+
const store = this.getMemoryStore();
|
|
4397
|
+
const memories = store ? store.query({ limit: 40, sortBy: "relevance" }) : [];
|
|
4398
|
+
const recentActions = await this.activityLog.tail(20);
|
|
4399
|
+
const sharedOpts = {
|
|
2668
4400
|
repos: this.config.repos,
|
|
2669
|
-
|
|
2670
|
-
memorySizeKB: memoryCheck.sizeKB,
|
|
2671
|
-
events,
|
|
4401
|
+
grouped: opts.grouped,
|
|
2672
4402
|
budgetStatus: {
|
|
2673
|
-
todayUsd: todayCost,
|
|
4403
|
+
todayUsd: opts.todayCost,
|
|
2674
4404
|
capUsd: this.config.supervisor.dailyCapUsd,
|
|
2675
|
-
remainingPct: (this.config.supervisor.dailyCapUsd - todayCost) / this.config.supervisor.dailyCapUsd * 100
|
|
4405
|
+
remainingPct: (this.config.supervisor.dailyCapUsd - opts.todayCost) / this.config.supervisor.dailyCapUsd * 100
|
|
2676
4406
|
},
|
|
2677
|
-
activeRuns:
|
|
2678
|
-
|
|
2679
|
-
heartbeatCount: state?.heartbeatCount ?? 0,
|
|
4407
|
+
activeRuns: opts.activeRuns,
|
|
4408
|
+
heartbeatCount: opts.heartbeatCount,
|
|
2680
4409
|
mcpServerNames,
|
|
2681
|
-
customInstructions: this.customInstructions
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
4410
|
+
customInstructions: this.customInstructions,
|
|
4411
|
+
supervisorDir: this.supervisorDir,
|
|
4412
|
+
memories,
|
|
4413
|
+
recentActions
|
|
4414
|
+
};
|
|
4415
|
+
if (opts.isCompaction) {
|
|
4416
|
+
return {
|
|
4417
|
+
prompt: buildCompactionPrompt({
|
|
4418
|
+
...sharedOpts,
|
|
4419
|
+
lastConsolidationTimestamp: opts.lastConsolidationTimestamp
|
|
4420
|
+
}),
|
|
4421
|
+
modeLabel: "compaction"
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
if (opts.isConsolidation) {
|
|
4425
|
+
return {
|
|
4426
|
+
prompt: buildConsolidationPrompt({
|
|
4427
|
+
...sharedOpts,
|
|
4428
|
+
lastConsolidationTimestamp: opts.lastConsolidationTimestamp
|
|
4429
|
+
}),
|
|
4430
|
+
modeLabel: "consolidation"
|
|
4431
|
+
};
|
|
4432
|
+
}
|
|
4433
|
+
if (isIdleHeartbeat(sharedOpts)) {
|
|
4434
|
+
return {
|
|
4435
|
+
prompt: buildIdlePrompt(sharedOpts),
|
|
4436
|
+
modeLabel: "idle"
|
|
4437
|
+
};
|
|
4438
|
+
}
|
|
4439
|
+
return {
|
|
4440
|
+
prompt: buildStandardPrompt(sharedOpts),
|
|
4441
|
+
modeLabel: "standard"
|
|
4442
|
+
};
|
|
4443
|
+
}
|
|
4444
|
+
/**
|
|
4445
|
+
* Call the Claude SDK and stream results.
|
|
4446
|
+
*/
|
|
4447
|
+
async callSdk(prompt, heartbeatId) {
|
|
2688
4448
|
const abortController = new AbortController();
|
|
2689
4449
|
this.activeAbort = abortController;
|
|
2690
4450
|
const timeout = setTimeout(() => {
|
|
@@ -2695,24 +4455,27 @@ var HeartbeatLoop = class {
|
|
|
2695
4455
|
let turnCount = 0;
|
|
2696
4456
|
try {
|
|
2697
4457
|
const sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
4458
|
+
const allowedTools = ["Bash", "Read"];
|
|
4459
|
+
if (this.config.mcpServers) {
|
|
4460
|
+
for (const name of Object.keys(this.config.mcpServers)) {
|
|
4461
|
+
allowedTools.push(`mcp__${name}__*`);
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
2698
4464
|
const queryOptions = {
|
|
2699
4465
|
cwd: homedir2(),
|
|
2700
|
-
|
|
2701
|
-
allowedTools: ["Bash", "Read"],
|
|
4466
|
+
allowedTools,
|
|
2702
4467
|
permissionMode: "bypassPermissions",
|
|
2703
|
-
allowDangerouslySkipPermissions: true
|
|
4468
|
+
allowDangerouslySkipPermissions: true,
|
|
4469
|
+
mcpServers: this.config.mcpServers ?? {}
|
|
2704
4470
|
};
|
|
2705
|
-
if (this.config.mcpServers) {
|
|
2706
|
-
queryOptions.mcpServers = this.config.mcpServers;
|
|
2707
|
-
}
|
|
2708
4471
|
const stream = sdk.query({ prompt, options: queryOptions });
|
|
2709
4472
|
for await (const message of stream) {
|
|
2710
4473
|
if (abortController.signal.aborted) break;
|
|
2711
4474
|
const msg = message;
|
|
2712
|
-
if (msg
|
|
4475
|
+
if (isInitMessage(msg)) {
|
|
2713
4476
|
this.sessionId = msg.session_id;
|
|
2714
4477
|
}
|
|
2715
|
-
if (msg
|
|
4478
|
+
if (isResultMessage(msg)) {
|
|
2716
4479
|
output = msg.result ?? "";
|
|
2717
4480
|
costUsd = msg.total_cost_usd ?? 0;
|
|
2718
4481
|
turnCount = msg.num_turns ?? 0;
|
|
@@ -2723,35 +4486,11 @@ var HeartbeatLoop = class {
|
|
|
2723
4486
|
clearTimeout(timeout);
|
|
2724
4487
|
this.activeAbort = null;
|
|
2725
4488
|
}
|
|
2726
|
-
|
|
2727
|
-
if (newMemory) {
|
|
2728
|
-
await saveMemory(this.supervisorDir, newMemory);
|
|
2729
|
-
}
|
|
2730
|
-
const durationMs = Date.now() - startTime;
|
|
2731
|
-
await this.updateState({
|
|
2732
|
-
sessionId: this.sessionId,
|
|
2733
|
-
lastHeartbeat: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2734
|
-
heartbeatCount: (state?.heartbeatCount ?? 0) + 1,
|
|
2735
|
-
totalCostUsd: (state?.totalCostUsd ?? 0) + costUsd,
|
|
2736
|
-
todayCostUsd: todayCost + costUsd,
|
|
2737
|
-
costResetDate: today
|
|
2738
|
-
});
|
|
2739
|
-
await this.activityLog.log(
|
|
2740
|
-
"heartbeat",
|
|
2741
|
-
`Heartbeat #${(state?.heartbeatCount ?? 0) + 1} complete`,
|
|
2742
|
-
{
|
|
2743
|
-
heartbeatId,
|
|
2744
|
-
costUsd,
|
|
2745
|
-
durationMs,
|
|
2746
|
-
turnCount,
|
|
2747
|
-
memoryUpdated: !!newMemory,
|
|
2748
|
-
responseSummary: output.slice(0, 500)
|
|
2749
|
-
}
|
|
2750
|
-
);
|
|
4489
|
+
return { output, costUsd, turnCount };
|
|
2751
4490
|
}
|
|
2752
4491
|
async readState() {
|
|
2753
4492
|
try {
|
|
2754
|
-
const raw = await
|
|
4493
|
+
const raw = await readFile11(this.statePath, "utf-8");
|
|
2755
4494
|
return JSON.parse(raw);
|
|
2756
4495
|
} catch {
|
|
2757
4496
|
return null;
|
|
@@ -2759,29 +4498,63 @@ var HeartbeatLoop = class {
|
|
|
2759
4498
|
}
|
|
2760
4499
|
async updateState(updates) {
|
|
2761
4500
|
try {
|
|
2762
|
-
const raw = await
|
|
4501
|
+
const raw = await readFile11(this.statePath, "utf-8");
|
|
2763
4502
|
const state = JSON.parse(raw);
|
|
2764
4503
|
Object.assign(state, updates);
|
|
2765
4504
|
await writeFile5(this.statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
2766
4505
|
} catch {
|
|
2767
4506
|
}
|
|
2768
4507
|
}
|
|
4508
|
+
/** Read persisted run files and return summaries of active (running/paused) runs. */
|
|
4509
|
+
async getActiveRuns() {
|
|
4510
|
+
const runsDir = getRunsDir();
|
|
4511
|
+
if (!existsSync7(runsDir)) return [];
|
|
4512
|
+
try {
|
|
4513
|
+
const entries = await readdir5(runsDir, { withFileTypes: true });
|
|
4514
|
+
const active = [];
|
|
4515
|
+
for (const entry of entries) {
|
|
4516
|
+
if (!entry.isDirectory()) continue;
|
|
4517
|
+
const subDir = path14.join(runsDir, entry.name);
|
|
4518
|
+
const files = await readdir5(subDir);
|
|
4519
|
+
for (const f of files) {
|
|
4520
|
+
if (!f.endsWith(".json")) continue;
|
|
4521
|
+
try {
|
|
4522
|
+
const raw = await readFile11(path14.join(subDir, f), "utf-8");
|
|
4523
|
+
const run = JSON.parse(raw);
|
|
4524
|
+
if (run.status === "running" || run.status === "paused") {
|
|
4525
|
+
active.push(
|
|
4526
|
+
`${run.runId} [${run.status}] ${run.workflow} on ${path14.basename(run.repo)}`
|
|
4527
|
+
);
|
|
4528
|
+
}
|
|
4529
|
+
} catch {
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
return active;
|
|
4534
|
+
} catch {
|
|
4535
|
+
return [];
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
2769
4538
|
/**
|
|
2770
4539
|
* Load custom instructions from SUPERVISOR.md.
|
|
2771
4540
|
* Resolution order:
|
|
2772
4541
|
* 1. Explicit path via `supervisor.instructions` in config
|
|
2773
|
-
* 2.
|
|
4542
|
+
* 2. User default: ~/.neo/SUPERVISOR.md
|
|
4543
|
+
* 3. Bundled default from @neotx/agents (if path provided)
|
|
2774
4544
|
*/
|
|
2775
4545
|
async loadInstructions() {
|
|
2776
4546
|
const candidates = [];
|
|
2777
4547
|
if (this.config.supervisor.instructions) {
|
|
2778
|
-
candidates.push(
|
|
4548
|
+
candidates.push(path14.resolve(this.config.supervisor.instructions));
|
|
4549
|
+
}
|
|
4550
|
+
candidates.push(path14.join(getDataDir(), "SUPERVISOR.md"));
|
|
4551
|
+
if (this.defaultInstructionsPath) {
|
|
4552
|
+
candidates.push(this.defaultInstructionsPath);
|
|
2779
4553
|
}
|
|
2780
|
-
candidates.push(path12.join(getDataDir(), "SUPERVISOR.md"));
|
|
2781
4554
|
for (const filePath of candidates) {
|
|
2782
4555
|
try {
|
|
2783
|
-
const content = await
|
|
2784
|
-
await this.activityLog.log("event", `Loaded
|
|
4556
|
+
const content = await readFile11(filePath, "utf-8");
|
|
4557
|
+
await this.activityLog.log("event", `Loaded instructions from ${filePath}`);
|
|
2785
4558
|
return content;
|
|
2786
4559
|
} catch {
|
|
2787
4560
|
}
|
|
@@ -2790,32 +4563,33 @@ var HeartbeatLoop = class {
|
|
|
2790
4563
|
}
|
|
2791
4564
|
/** Route a single SDK stream message to the appropriate log handler. */
|
|
2792
4565
|
async logStreamMessage(msg, heartbeatId) {
|
|
2793
|
-
if (msg
|
|
2794
|
-
if (!msg.subtype) {
|
|
4566
|
+
if (isAssistantMessage(msg)) {
|
|
2795
4567
|
await this.logContentBlocks(msg, heartbeatId);
|
|
2796
|
-
} else if (msg
|
|
4568
|
+
} else if (isToolUseMessage(msg)) {
|
|
2797
4569
|
await this.logToolUse(msg, heartbeatId);
|
|
2798
|
-
} else if (msg
|
|
4570
|
+
} else if (isToolResultMessage(msg)) {
|
|
2799
4571
|
await this.logToolResult(msg, heartbeatId);
|
|
2800
4572
|
}
|
|
2801
4573
|
}
|
|
2802
|
-
/** Log thinking and plan blocks from assistant content. */
|
|
4574
|
+
/** Log thinking and plan blocks from assistant content — no truncation. */
|
|
2803
4575
|
async logContentBlocks(msg, heartbeatId) {
|
|
4576
|
+
if (!isAssistantMessage(msg)) return;
|
|
2804
4577
|
const content = msg.message?.content;
|
|
2805
4578
|
if (!content) return;
|
|
2806
4579
|
for (const block of content) {
|
|
2807
4580
|
if (block.type === "thinking" && block.thinking) {
|
|
2808
|
-
await this.activityLog.log("thinking", block.thinking
|
|
4581
|
+
await this.activityLog.log("thinking", block.thinking, { heartbeatId });
|
|
2809
4582
|
}
|
|
2810
4583
|
if (block.type === "text" && block.text) {
|
|
2811
|
-
await this.activityLog.log("plan", block.text
|
|
4584
|
+
await this.activityLog.log("plan", block.text, { heartbeatId });
|
|
2812
4585
|
break;
|
|
2813
4586
|
}
|
|
2814
4587
|
}
|
|
2815
4588
|
}
|
|
2816
4589
|
/** Log tool use events — distinguish MCP tools from built-in tools. */
|
|
2817
4590
|
async logToolUse(msg, heartbeatId) {
|
|
2818
|
-
|
|
4591
|
+
if (!isToolUseMessage(msg)) return;
|
|
4592
|
+
const toolName = msg.tool;
|
|
2819
4593
|
const isMcp = toolName.startsWith("mcp__");
|
|
2820
4594
|
await this.activityLog.log(
|
|
2821
4595
|
isMcp ? "tool_use" : "action",
|
|
@@ -2825,7 +4599,8 @@ var HeartbeatLoop = class {
|
|
|
2825
4599
|
}
|
|
2826
4600
|
/** Detect agent dispatches from bash tool results. */
|
|
2827
4601
|
async logToolResult(msg, heartbeatId) {
|
|
2828
|
-
|
|
4602
|
+
if (!isToolResultMessage(msg)) return;
|
|
4603
|
+
const result = msg.result ?? "";
|
|
2829
4604
|
const runMatch = /Run\s+(\S+)\s+dispatched/i.exec(result);
|
|
2830
4605
|
if (runMatch) {
|
|
2831
4606
|
await this.activityLog.log("dispatch", `Agent dispatched: ${runMatch[1]}`, {
|
|
@@ -2840,8 +4615,8 @@ var HeartbeatLoop = class {
|
|
|
2840
4615
|
};
|
|
2841
4616
|
|
|
2842
4617
|
// src/supervisor/webhook-server.ts
|
|
2843
|
-
import { timingSafeEqual } from "crypto";
|
|
2844
|
-
import { appendFile as
|
|
4618
|
+
import { createHmac as createHmac2, timingSafeEqual } from "crypto";
|
|
4619
|
+
import { appendFile as appendFile6 } from "fs/promises";
|
|
2845
4620
|
import { createServer } from "http";
|
|
2846
4621
|
var MAX_BODY_SIZE = 1024 * 1024;
|
|
2847
4622
|
var WebhookServer = class {
|
|
@@ -2893,24 +4668,25 @@ var WebhookServer = class {
|
|
|
2893
4668
|
this.sendJson(res, 404, { error: "Not found" });
|
|
2894
4669
|
}
|
|
2895
4670
|
async handleWebhook(req, res) {
|
|
4671
|
+
const body = await this.readBody(req);
|
|
4672
|
+
if (body === null) {
|
|
4673
|
+
this.sendJson(res, 413, { error: "Payload too large (max 1MB)" });
|
|
4674
|
+
return;
|
|
4675
|
+
}
|
|
2896
4676
|
if (this.secret) {
|
|
2897
|
-
const
|
|
2898
|
-
if (!
|
|
2899
|
-
this.sendJson(res, 401, { error: "Missing X-Neo-
|
|
4677
|
+
const signature = req.headers["x-neo-signature"];
|
|
4678
|
+
if (!signature) {
|
|
4679
|
+
this.sendJson(res, 401, { error: "Missing X-Neo-Signature header" });
|
|
2900
4680
|
return;
|
|
2901
4681
|
}
|
|
2902
|
-
const expected =
|
|
2903
|
-
const
|
|
2904
|
-
|
|
2905
|
-
|
|
4682
|
+
const expected = createHmac2("sha256", this.secret).update(body).digest("hex");
|
|
4683
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
4684
|
+
const actualBuf = Buffer.from(signature, "utf-8");
|
|
4685
|
+
if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
|
|
4686
|
+
this.sendJson(res, 403, { error: "Invalid signature" });
|
|
2906
4687
|
return;
|
|
2907
4688
|
}
|
|
2908
4689
|
}
|
|
2909
|
-
const body = await this.readBody(req);
|
|
2910
|
-
if (body === null) {
|
|
2911
|
-
this.sendJson(res, 413, { error: "Payload too large (max 1MB)" });
|
|
2912
|
-
return;
|
|
2913
|
-
}
|
|
2914
4690
|
let parsed;
|
|
2915
4691
|
try {
|
|
2916
4692
|
parsed = JSON.parse(body);
|
|
@@ -2925,7 +4701,7 @@ var WebhookServer = class {
|
|
|
2925
4701
|
payload: parsed.payload ?? parsed,
|
|
2926
4702
|
receivedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2927
4703
|
};
|
|
2928
|
-
await
|
|
4704
|
+
await appendFile6(this.eventsPath, `${JSON.stringify(event)}
|
|
2929
4705
|
`, "utf-8");
|
|
2930
4706
|
this.onEvent(event);
|
|
2931
4707
|
this.sendJson(res, 200, { ok: true, id: event.id });
|
|
@@ -2960,6 +4736,7 @@ var SupervisorDaemon = class {
|
|
|
2960
4736
|
name;
|
|
2961
4737
|
config;
|
|
2962
4738
|
dir;
|
|
4739
|
+
defaultInstructionsPath;
|
|
2963
4740
|
webhookServer = null;
|
|
2964
4741
|
eventQueue = null;
|
|
2965
4742
|
heartbeatLoop = null;
|
|
@@ -2969,13 +4746,14 @@ var SupervisorDaemon = class {
|
|
|
2969
4746
|
this.name = options.name;
|
|
2970
4747
|
this.config = options.config;
|
|
2971
4748
|
this.dir = getSupervisorDir(options.name);
|
|
4749
|
+
this.defaultInstructionsPath = options.defaultInstructionsPath;
|
|
2972
4750
|
}
|
|
2973
4751
|
async start() {
|
|
2974
|
-
await
|
|
2975
|
-
const lockPath =
|
|
2976
|
-
if (
|
|
4752
|
+
await mkdir7(this.dir, { recursive: true });
|
|
4753
|
+
const lockPath = path15.join(this.dir, "daemon.lock");
|
|
4754
|
+
if (existsSync8(lockPath)) {
|
|
2977
4755
|
const lockPid = await this.readLockPid(lockPath);
|
|
2978
|
-
if (lockPid &&
|
|
4756
|
+
if (lockPid && isProcessAlive(lockPid)) {
|
|
2979
4757
|
throw new Error(
|
|
2980
4758
|
`Supervisor "${this.name}" already running (PID ${lockPid}). Use --kill first.`
|
|
2981
4759
|
);
|
|
@@ -2990,29 +4768,38 @@ var SupervisorDaemon = class {
|
|
|
2990
4768
|
if (existingState?.sessionId && existingState.status !== "stopped") {
|
|
2991
4769
|
this.sessionId = existingState.sessionId;
|
|
2992
4770
|
} else {
|
|
2993
|
-
this.sessionId =
|
|
4771
|
+
this.sessionId = randomUUID6();
|
|
2994
4772
|
}
|
|
2995
4773
|
this.activityLog = new ActivityLog(this.dir);
|
|
2996
4774
|
this.eventQueue = new EventQueue({
|
|
2997
4775
|
maxEventsPerSec: this.config.supervisor.maxEventsPerSec
|
|
2998
4776
|
});
|
|
2999
|
-
const inboxPath =
|
|
3000
|
-
const eventsPath =
|
|
4777
|
+
const inboxPath = path15.join(this.dir, "inbox.jsonl");
|
|
4778
|
+
const eventsPath = path15.join(this.dir, "events.jsonl");
|
|
3001
4779
|
await this.eventQueue.replayUnprocessed(inboxPath, eventsPath);
|
|
3002
|
-
this.eventQueue.startWatching(inboxPath, eventsPath);
|
|
4780
|
+
await this.eventQueue.startWatching(inboxPath, eventsPath);
|
|
3003
4781
|
this.webhookServer = new WebhookServer({
|
|
3004
4782
|
port: this.config.supervisor.port,
|
|
3005
4783
|
secret: this.config.supervisor.secret,
|
|
3006
4784
|
eventsPath,
|
|
3007
4785
|
onEvent: (event) => {
|
|
3008
4786
|
this.eventQueue?.push({ kind: "webhook", data: event });
|
|
4787
|
+
if ((event.event === "session:complete" || event.event === "session:fail") && event.payload) {
|
|
4788
|
+
const runId = typeof event.payload.runId === "string" ? event.payload.runId : void 0;
|
|
4789
|
+
if (runId) {
|
|
4790
|
+
this.eventQueue?.push({
|
|
4791
|
+
kind: "run_complete",
|
|
4792
|
+
runId,
|
|
4793
|
+
timestamp: event.receivedAt
|
|
4794
|
+
});
|
|
4795
|
+
}
|
|
4796
|
+
}
|
|
3009
4797
|
},
|
|
3010
4798
|
getHealth: () => this.getHealthInfo()
|
|
3011
4799
|
});
|
|
3012
4800
|
await this.webhookServer.start();
|
|
3013
4801
|
await this.writeState({
|
|
3014
4802
|
pid: process.pid,
|
|
3015
|
-
tmuxSession: `neo-${this.name}`,
|
|
3016
4803
|
sessionId: this.sessionId,
|
|
3017
4804
|
port: this.config.supervisor.port,
|
|
3018
4805
|
cwd: homedir3(),
|
|
@@ -3022,7 +4809,12 @@ var SupervisorDaemon = class {
|
|
|
3022
4809
|
totalCostUsd: existingState?.totalCostUsd ?? 0,
|
|
3023
4810
|
todayCostUsd: existingState?.todayCostUsd ?? 0,
|
|
3024
4811
|
costResetDate: existingState?.costResetDate,
|
|
3025
|
-
|
|
4812
|
+
idleSkipCount: existingState?.idleSkipCount ?? 0,
|
|
4813
|
+
activeWorkSkipCount: existingState?.activeWorkSkipCount ?? 0,
|
|
4814
|
+
status: "running",
|
|
4815
|
+
lastConsolidationHeartbeat: existingState?.lastConsolidationHeartbeat ?? 0,
|
|
4816
|
+
lastCompactionHeartbeat: existingState?.lastCompactionHeartbeat ?? 0,
|
|
4817
|
+
lastConsolidationTimestamp: existingState?.lastConsolidationTimestamp
|
|
3026
4818
|
});
|
|
3027
4819
|
const shutdown = () => {
|
|
3028
4820
|
this.stop().catch(console.error);
|
|
@@ -3033,14 +4825,15 @@ var SupervisorDaemon = class {
|
|
|
3033
4825
|
"event",
|
|
3034
4826
|
`Supervisor "${this.name}" started on port ${this.config.supervisor.port}`
|
|
3035
4827
|
);
|
|
3036
|
-
const statePath =
|
|
4828
|
+
const statePath = path15.join(this.dir, "state.json");
|
|
3037
4829
|
this.heartbeatLoop = new HeartbeatLoop({
|
|
3038
4830
|
config: this.config,
|
|
3039
4831
|
supervisorDir: this.dir,
|
|
3040
4832
|
statePath,
|
|
3041
4833
|
sessionId: this.sessionId,
|
|
3042
4834
|
eventQueue: this.eventQueue,
|
|
3043
|
-
activityLog: this.activityLog
|
|
4835
|
+
activityLog: this.activityLog,
|
|
4836
|
+
defaultInstructionsPath: this.defaultInstructionsPath
|
|
3044
4837
|
});
|
|
3045
4838
|
await this.heartbeatLoop.start();
|
|
3046
4839
|
}
|
|
@@ -3055,7 +4848,7 @@ var SupervisorDaemon = class {
|
|
|
3055
4848
|
state.status = "stopped";
|
|
3056
4849
|
await this.writeState(state);
|
|
3057
4850
|
}
|
|
3058
|
-
const lockPath =
|
|
4851
|
+
const lockPath = path15.join(this.dir, "daemon.lock");
|
|
3059
4852
|
await rm2(lockPath, { force: true });
|
|
3060
4853
|
if (this.activityLog) {
|
|
3061
4854
|
await this.activityLog.log("event", `Supervisor "${this.name}" stopped`);
|
|
@@ -3072,35 +4865,27 @@ var SupervisorDaemon = class {
|
|
|
3072
4865
|
};
|
|
3073
4866
|
}
|
|
3074
4867
|
async readState() {
|
|
3075
|
-
const statePath =
|
|
4868
|
+
const statePath = path15.join(this.dir, "state.json");
|
|
3076
4869
|
try {
|
|
3077
|
-
const raw = await
|
|
4870
|
+
const raw = await readFile12(statePath, "utf-8");
|
|
3078
4871
|
return JSON.parse(raw);
|
|
3079
4872
|
} catch {
|
|
3080
4873
|
return null;
|
|
3081
4874
|
}
|
|
3082
4875
|
}
|
|
3083
4876
|
async writeState(state) {
|
|
3084
|
-
const statePath =
|
|
4877
|
+
const statePath = path15.join(this.dir, "state.json");
|
|
3085
4878
|
await writeFile6(statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
3086
4879
|
}
|
|
3087
4880
|
async readLockPid(lockPath) {
|
|
3088
4881
|
try {
|
|
3089
|
-
const raw = await
|
|
4882
|
+
const raw = await readFile12(lockPath, "utf-8");
|
|
3090
4883
|
const pid = Number.parseInt(raw.trim(), 10);
|
|
3091
4884
|
return Number.isNaN(pid) ? null : pid;
|
|
3092
4885
|
} catch {
|
|
3093
4886
|
return null;
|
|
3094
4887
|
}
|
|
3095
4888
|
}
|
|
3096
|
-
isProcessAlive(pid) {
|
|
3097
|
-
try {
|
|
3098
|
-
process.kill(pid, 0);
|
|
3099
|
-
return true;
|
|
3100
|
-
} catch {
|
|
3101
|
-
return false;
|
|
3102
|
-
}
|
|
3103
|
-
}
|
|
3104
4889
|
};
|
|
3105
4890
|
|
|
3106
4891
|
// src/index.ts
|
|
@@ -3112,10 +4897,13 @@ export {
|
|
|
3112
4897
|
EventJournal,
|
|
3113
4898
|
EventQueue,
|
|
3114
4899
|
HeartbeatLoop,
|
|
4900
|
+
LocalEmbedder,
|
|
4901
|
+
MemoryStore,
|
|
3115
4902
|
NeoEventEmitter,
|
|
3116
4903
|
Orchestrator,
|
|
3117
4904
|
Semaphore,
|
|
3118
4905
|
SessionError,
|
|
4906
|
+
SessionExecutor,
|
|
3119
4907
|
SupervisorDaemon,
|
|
3120
4908
|
VERSION,
|
|
3121
4909
|
WebhookDispatcher,
|
|
@@ -3128,18 +4916,18 @@ export {
|
|
|
3128
4916
|
agentSandboxSchema,
|
|
3129
4917
|
agentToolEntrySchema,
|
|
3130
4918
|
agentToolSchema,
|
|
4919
|
+
appendLogBuffer,
|
|
3131
4920
|
auditLog,
|
|
3132
4921
|
budgetGuard,
|
|
3133
|
-
|
|
4922
|
+
buildFullPrompt,
|
|
4923
|
+
buildGitStrategyInstructions,
|
|
3134
4924
|
buildMiddlewareChain,
|
|
4925
|
+
buildReportingInstructions,
|
|
3135
4926
|
buildSDKHooks,
|
|
3136
4927
|
buildSandboxConfig,
|
|
3137
|
-
checkMemorySize,
|
|
3138
|
-
cleanupOrphanedWorktrees,
|
|
3139
4928
|
createBranch,
|
|
3140
|
-
|
|
4929
|
+
createSessionClone,
|
|
3141
4930
|
deleteBranch,
|
|
3142
|
-
extractMemoryFromResponse,
|
|
3143
4931
|
fetchRemote,
|
|
3144
4932
|
getBranchName,
|
|
3145
4933
|
getCurrentBranch,
|
|
@@ -3154,17 +4942,17 @@ export {
|
|
|
3154
4942
|
getSupervisorEventsPath,
|
|
3155
4943
|
getSupervisorInboxPath,
|
|
3156
4944
|
getSupervisorLockPath,
|
|
3157
|
-
getSupervisorMemoryPath,
|
|
3158
4945
|
getSupervisorStatePath,
|
|
3159
4946
|
getSupervisorsDir,
|
|
3160
4947
|
globalConfigSchema,
|
|
3161
4948
|
inboxMessageSchema,
|
|
4949
|
+
isProcessAlive,
|
|
3162
4950
|
listReposFromGlobalConfig,
|
|
3163
|
-
|
|
4951
|
+
listSessionClones,
|
|
3164
4952
|
loadAgentFile,
|
|
3165
4953
|
loadConfig,
|
|
3166
4954
|
loadGlobalConfig,
|
|
3167
|
-
|
|
4955
|
+
loadRepoInstructions,
|
|
3168
4956
|
loadWorkflow,
|
|
3169
4957
|
loopDetection,
|
|
3170
4958
|
matchesFilter,
|
|
@@ -3172,18 +4960,17 @@ export {
|
|
|
3172
4960
|
neoConfigSchema,
|
|
3173
4961
|
parseOutput,
|
|
3174
4962
|
pushBranch,
|
|
4963
|
+
pushSessionBranch,
|
|
3175
4964
|
removeRepoFromGlobalConfig,
|
|
3176
|
-
|
|
4965
|
+
removeSessionClone,
|
|
3177
4966
|
repoConfigSchema,
|
|
3178
4967
|
resolveAgent,
|
|
3179
4968
|
runSession,
|
|
3180
4969
|
runWithRecovery,
|
|
3181
|
-
saveMemory,
|
|
3182
4970
|
supervisorDaemonStateSchema,
|
|
3183
4971
|
supervisorDaemonStateSchema as supervisorStateSchema,
|
|
3184
4972
|
toRepoSlug,
|
|
3185
4973
|
webhookIncomingEventSchema,
|
|
3186
|
-
withGitLock,
|
|
3187
4974
|
workflowGateDefSchema,
|
|
3188
4975
|
workflowStepDefSchema
|
|
3189
4976
|
};
|