@nac3/forge-cli 0.2.0-alpha.59z.121 → 0.2.0-alpha.59z.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/bridge.d.ts +69 -0
- package/dist/agents/bridge.d.ts.map +1 -0
- package/dist/agents/bridge.js +295 -0
- package/dist/agents/bridge.js.map +1 -0
- package/dist/agents/cli_real.d.ts +38 -0
- package/dist/agents/cli_real.d.ts.map +1 -0
- package/dist/agents/cli_real.js +46 -0
- package/dist/agents/cli_real.js.map +1 -0
- package/dist/agents/config_schema.d.ts +1 -1
- package/dist/agents/config_schema.d.ts.map +1 -1
- package/dist/agents/config_schema.js +16 -0
- package/dist/agents/config_schema.js.map +1 -1
- package/dist/agents/defaults.d.ts.map +1 -1
- package/dist/agents/defaults.js.map +1 -1
- package/dist/agents/delegate.d.ts +36 -0
- package/dist/agents/delegate.d.ts.map +1 -0
- package/dist/agents/delegate.js +68 -0
- package/dist/agents/delegate.js.map +1 -0
- package/dist/agents/iterative_step.d.ts +95 -0
- package/dist/agents/iterative_step.d.ts.map +1 -0
- package/dist/agents/iterative_step.js +249 -0
- package/dist/agents/iterative_step.js.map +1 -0
- package/dist/agents/llm_step.d.ts +50 -0
- package/dist/agents/llm_step.d.ts.map +1 -0
- package/dist/agents/llm_step.js +28 -0
- package/dist/agents/llm_step.js.map +1 -0
- package/dist/agents/real_step_factory.d.ts +95 -0
- package/dist/agents/real_step_factory.d.ts.map +1 -0
- package/dist/agents/real_step_factory.js +51 -0
- package/dist/agents/real_step_factory.js.map +1 -0
- package/dist/agents/runner.d.ts +14 -0
- package/dist/agents/runner.d.ts.map +1 -1
- package/dist/agents/runner.js +10 -1
- package/dist/agents/runner.js.map +1 -1
- package/dist/agents/runtime.d.ts +115 -0
- package/dist/agents/runtime.d.ts.map +1 -0
- package/dist/agents/runtime.js +429 -0
- package/dist/agents/runtime.js.map +1 -0
- package/dist/agents/system_prompt.d.ts +36 -0
- package/dist/agents/system_prompt.d.ts.map +1 -0
- package/dist/agents/system_prompt.js +101 -0
- package/dist/agents/system_prompt.js.map +1 -0
- package/dist/agents/types.d.ts +12 -1
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/agents/types.js.map +1 -1
- package/dist/bin/yf.d.ts.map +1 -1
- package/dist/bin/yf.js +9 -0
- package/dist/bin/yf.js.map +1 -1
- package/dist/chat/claude_cli.d.ts +42 -0
- package/dist/chat/claude_cli.d.ts.map +1 -0
- package/dist/chat/claude_cli.js +299 -0
- package/dist/chat/claude_cli.js.map +1 -0
- package/dist/chat/graph_snapshot.d.ts +38 -0
- package/dist/chat/graph_snapshot.d.ts.map +1 -0
- package/dist/chat/graph_snapshot.js +159 -0
- package/dist/chat/graph_snapshot.js.map +1 -0
- package/dist/chat/ingest_session.d.ts +24 -4
- package/dist/chat/ingest_session.d.ts.map +1 -1
- package/dist/chat/ingest_session.js +139 -4
- package/dist/chat/ingest_session.js.map +1 -1
- package/dist/chat/panel.d.ts.map +1 -1
- package/dist/chat/panel.js +1358 -0
- package/dist/chat/panel.js.map +1 -1
- package/dist/chat/server.d.ts +21 -0
- package/dist/chat/server.d.ts.map +1 -1
- package/dist/chat/server.js +904 -4
- package/dist/chat/server.js.map +1 -1
- package/dist/chat/tools/nac3_invoke.d.ts +5 -0
- package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
- package/dist/chat/tools/nac3_invoke.js +1 -0
- package/dist/chat/tools/nac3_invoke.js.map +1 -1
- package/dist/chat/tools.d.ts.map +1 -1
- package/dist/chat/tools.js +12 -0
- package/dist/chat/tools.js.map +1 -1
- package/dist/commands/agent.d.ts +12 -1
- package/dist/commands/agent.d.ts.map +1 -1
- package/dist/commands/agent.js +92 -1
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/chat.d.ts +10 -0
- package/dist/commands/chat.d.ts.map +1 -1
- package/dist/commands/chat.js +12 -1
- package/dist/commands/chat.js.map +1 -1
- package/dist/commands/director.d.ts +37 -0
- package/dist/commands/director.d.ts.map +1 -0
- package/dist/commands/director.js +290 -0
- package/dist/commands/director.js.map +1 -0
- package/dist/commands/keys_setup.d.ts +1 -0
- package/dist/commands/keys_setup.d.ts.map +1 -1
- package/dist/commands/keys_setup.js +61 -11
- package/dist/commands/keys_setup.js.map +1 -1
- package/dist/core/pilot_setup.d.ts +14 -1
- package/dist/core/pilot_setup.d.ts.map +1 -1
- package/dist/core/pilot_setup.js +62 -1
- package/dist/core/pilot_setup.js.map +1 -1
- package/dist/core/provider_keys.d.ts.map +1 -1
- package/dist/core/provider_keys.js +12 -5
- package/dist/core/provider_keys.js.map +1 -1
- package/dist/design/yujin_design_resolver.d.ts +77 -0
- package/dist/design/yujin_design_resolver.d.ts.map +1 -0
- package/dist/design/yujin_design_resolver.js +113 -0
- package/dist/design/yujin_design_resolver.js.map +1 -0
- package/dist/docs/doctrine/default-design-system.md +120 -0
- package/dist/docs/doctrine/language-sovereignty.md +143 -0
- package/dist/docs/doctrine/testing-policy.md +211 -0
- package/dist/nac3/approval_queue.d.ts +8 -0
- package/dist/nac3/approval_queue.d.ts.map +1 -1
- package/dist/nac3/approval_queue.js.map +1 -1
- package/dist/nac3/internal_manifest.d.ts.map +1 -1
- package/dist/nac3/internal_manifest.js +178 -0
- package/dist/nac3/internal_manifest.js.map +1 -1
- package/dist/office/excel.d.ts +22 -0
- package/dist/office/excel.d.ts.map +1 -1
- package/dist/office/excel.js +82 -0
- package/dist/office/excel.js.map +1 -1
- package/dist/qa/brain_matrix/fixtures/scenario_a11y_focus_after_open.json +22 -0
- package/dist/qa/brain_matrix/fixtures/scenario_a11y_label_unlabeled_button.json +26 -0
- package/dist/qa/brain_matrix/fixtures/scenario_agent_director_consults_workflow_before_dispatch.json +42 -0
- package/dist/qa/brain_matrix/fixtures/scenario_analyst_extracts_then_returns.json +28 -0
- package/dist/qa/brain_matrix/fixtures/scenario_coder_consults_nac_spec_before_writing.json +28 -0
- package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_override_shadcn.json +45 -0
- package/dist/qa/brain_matrix/fixtures/scenario_default_design_system_use_card_template.json +44 -0
- package/dist/qa/brain_matrix/fixtures/scenario_director_delegates_to_analyst.json +26 -0
- package/dist/qa/brain_matrix/fixtures/scenario_director_no_self_delegation.json +20 -0
- package/dist/qa/brain_matrix/fixtures/scenario_director_terminal_done_marker.json +19 -0
- package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_default_design_system.json +50 -0
- package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_language_sovereignty.json +53 -0
- package/dist/qa/brain_matrix/fixtures/scenario_doctrine_discover_testing_policy.json +55 -0
- package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_not_conducive_spa_python.json +61 -0
- package/dist/qa/brain_matrix/fixtures/scenario_language_sovereignty_user_picks_python.json +48 -0
- package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_destructive_pauses_for_r8.json +42 -0
- package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_happy_path_writes_pizarron.json +76 -0
- package/dist/qa/brain_matrix/fixtures/scenario_mcp_invoke_network_timeout_graceful.json +41 -0
- package/dist/qa/brain_matrix/fixtures/scenario_no_hardcoded_color_use_token.json +47 -0
- package/dist/qa/brain_matrix/fixtures/scenario_office_excel_update_cell_picks_right_verb.json +26 -0
- package/dist/qa/brain_matrix/fixtures/scenario_scaffolder_destructive_pauses_for_approval.json +23 -0
- package/dist/qa/brain_matrix/fixtures/scenario_sumie_icon_pick_from_catalog.json +56 -0
- package/dist/qa/brain_matrix/fixtures/scenario_tester_uses_shell_exec_only.json +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workflow/graph.d.ts +59 -0
- package/dist/workflow/graph.d.ts.map +1 -0
- package/dist/workflow/graph.js +137 -0
- package/dist/workflow/graph.js.map +1 -0
- package/dist/workflow/state.d.ts +34 -0
- package/dist/workflow/state.d.ts.map +1 -1
- package/dist/workflow/state.js.map +1 -1
- package/docs/doctrine/default-design-system.md +120 -0
- package/docs/doctrine/language-sovereignty.md +143 -0
- package/docs/doctrine/testing-policy.md +211 -0
- package/package.json +102 -100
- package/src/i18n/catalogs/ar.json +20 -1
- package/src/i18n/catalogs/de.json +20 -1
- package/src/i18n/catalogs/en.json +20 -21
- package/src/i18n/catalogs/es.json +20 -21
- package/src/i18n/catalogs/fr.json +20 -1
- package/src/i18n/catalogs/hi.json +20 -1
- package/src/i18n/catalogs/it.json +20 -1
- package/src/i18n/catalogs/ja.json +20 -1
- package/src/i18n/catalogs/pt.json +20 -1
- package/src/i18n/catalogs/zh.json +20 -1
- package/templates/sumi-context.template.md +57 -0
package/dist/chat/server.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { promises as fs } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { ClaudeClient, ConfigurationError, ClaudeApiError } from './claude.js';
|
|
18
19
|
import { renderPanelHtml } from './panel.js';
|
|
@@ -47,6 +48,19 @@ export async function startChatServer(opts) {
|
|
|
47
48
|
* CLI + panel converge. */
|
|
48
49
|
const { resolveProjectSlug } = await import('./project_slug.js');
|
|
49
50
|
const projectSlug = await resolveProjectSlug(opts.projectRoot);
|
|
51
|
+
/* Director sprint slice 9 -- hydrate ingest sessions from disk
|
|
52
|
+
* for this project so spec extract+plan survive restarts. */
|
|
53
|
+
try {
|
|
54
|
+
const { setProjectSlugForIngest, loadProjectIngests } = await import('./ingest_session.js');
|
|
55
|
+
setProjectSlugForIngest(projectSlug);
|
|
56
|
+
await loadProjectIngests(projectSlug);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
try {
|
|
60
|
+
console.error('ingest_session boot hydrate failed: ' + (err instanceof Error ? err.message : String(err)));
|
|
61
|
+
}
|
|
62
|
+
catch { /* swallow */ }
|
|
63
|
+
}
|
|
50
64
|
/* alpha.59v -- fire-and-forget auto-ingest of package.json deps
|
|
51
65
|
* whenever the boot detects a changed (or never-ingested)
|
|
52
66
|
* package.json. Never blocks the boot; never throws.
|
|
@@ -84,7 +98,27 @@ export async function startChatServer(opts) {
|
|
|
84
98
|
});
|
|
85
99
|
}
|
|
86
100
|
catch { /* never break boot */ }
|
|
87
|
-
|
|
101
|
+
/* Provider selection: 'anthropic-api' (default, Console billing) vs
|
|
102
|
+
* 'claude-code-cli' (subscription billing via `claude -p`
|
|
103
|
+
* subprocess). The ClaudeCliClient is interface-compatible at the
|
|
104
|
+
* .chat() shape used by this server -- runTool round-trips happen
|
|
105
|
+
* inside the subprocess, so the wrapper returns toolRounds=[].
|
|
106
|
+
* Tests inject `opts.claude` directly and bypass this branch. */
|
|
107
|
+
let claude;
|
|
108
|
+
if (opts.claude) {
|
|
109
|
+
claude = opts.claude;
|
|
110
|
+
}
|
|
111
|
+
else if (opts.chatProvider === 'claude-code-cli') {
|
|
112
|
+
const { ClaudeCliClient } = await import('./claude_cli.js');
|
|
113
|
+
const cliOpts = {};
|
|
114
|
+
if (opts.chatProviderAddDirs && opts.chatProviderAddDirs.length > 0) {
|
|
115
|
+
cliOpts['addDirs'] = opts.chatProviderAddDirs;
|
|
116
|
+
}
|
|
117
|
+
claude = new ClaudeCliClient(cliOpts);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
claude = new ClaudeClient();
|
|
121
|
+
}
|
|
88
122
|
const voice = opts.voice ?? new VoiceRouter({
|
|
89
123
|
configDir: configDir(),
|
|
90
124
|
registry: buildDefaultRegistry(),
|
|
@@ -131,6 +165,8 @@ export async function startChatServer(opts) {
|
|
|
131
165
|
claude,
|
|
132
166
|
voice,
|
|
133
167
|
store,
|
|
168
|
+
runtime: opts.runtime,
|
|
169
|
+
chatProvider: opts.chatProvider,
|
|
134
170
|
});
|
|
135
171
|
}
|
|
136
172
|
catch (err) {
|
|
@@ -165,7 +201,19 @@ export async function startChatServer(opts) {
|
|
|
165
201
|
if (componentWatcherHandle) {
|
|
166
202
|
Promise.resolve(componentWatcherHandle.stop()).catch(() => undefined);
|
|
167
203
|
}
|
|
168
|
-
|
|
204
|
+
/* alpha.59z.136 slice 14 -- flush in-flight ingest session
|
|
205
|
+
* writes BEFORE the server closes so test teardowns that
|
|
206
|
+
* fs.rm the home directory immediately after do not race
|
|
207
|
+
* with the fire-and-forget persist queue. */
|
|
208
|
+
(async () => {
|
|
209
|
+
try {
|
|
210
|
+
const { flushPendingIngestWrites, setProjectSlugForIngest } = await import('./ingest_session.js');
|
|
211
|
+
await flushPendingIngestWrites();
|
|
212
|
+
setProjectSlugForIngest(null);
|
|
213
|
+
}
|
|
214
|
+
catch { /* swallow */ }
|
|
215
|
+
server.close(() => resolve());
|
|
216
|
+
})();
|
|
169
217
|
}),
|
|
170
218
|
};
|
|
171
219
|
}
|
|
@@ -195,6 +243,42 @@ async function route(req, res, ctx) {
|
|
|
195
243
|
});
|
|
196
244
|
return;
|
|
197
245
|
}
|
|
246
|
+
/* ------------------------------------------------------------
|
|
247
|
+
* Director sprint slice 5 -- agent runtime panel endpoints.
|
|
248
|
+
* GET /api/agent/state -- runtime status + agent definitions
|
|
249
|
+
* POST /api/agent/run -- dispatch an agent through runtime
|
|
250
|
+
* GET /api/agent/events -- SSE stream of RuntimeEvent
|
|
251
|
+
* All return 503 when ctx.runtime is unwired so the UI can
|
|
252
|
+
* surface a clean "no runtime" state.
|
|
253
|
+
* ------------------------------------------------------------ */
|
|
254
|
+
if (req.method === 'GET' && url.pathname === '/api/agent/state') {
|
|
255
|
+
await handleAgentState(res, ctx);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (req.method === 'POST' && url.pathname === '/api/agent/run') {
|
|
259
|
+
await handleAgentRun(req, res, ctx);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (req.method === 'GET' && url.pathname === '/api/agent/events') {
|
|
263
|
+
await handleAgentEventsSse(req, res, ctx);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (req.method === 'POST' && url.pathname === '/api/agent/approve') {
|
|
267
|
+
await handleAgentApprove(req, res, ctx);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (req.method === 'GET' && url.pathname === '/api/agent/activity') {
|
|
271
|
+
await handleAgentActivity(req, res, ctx);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (req.method === 'GET' && url.pathname === '/api/agent/bridge-tail') {
|
|
275
|
+
await handleAgentBridgeTail(req, res, ctx);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (req.method === 'POST' && url.pathname === '/api/agent/inject') {
|
|
279
|
+
await handleAgentInject(req, res, ctx);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
198
282
|
/* Legal docs surfaced inside the product (Config -> About).
|
|
199
283
|
* Serves the raw markdown so the panel can render or pop a
|
|
200
284
|
* dialog. Routes are static names so a future i18n swap can
|
|
@@ -455,6 +539,113 @@ async function route(req, res, ctx) {
|
|
|
455
539
|
}
|
|
456
540
|
return;
|
|
457
541
|
}
|
|
542
|
+
/* alpha.59z.150 F101 slice 3 -- MCP bridges UI endpoints.
|
|
543
|
+
* GET /api/forge/mcp/bridges -> list of discovered bridges
|
|
544
|
+
* with id/endpoint/source/enabled/
|
|
545
|
+
* display_name/protocol.
|
|
546
|
+
* GET /api/forge/mcp/tools -> tool catalog for the bridge_id
|
|
547
|
+
* query param. Internally calls
|
|
548
|
+
* MCPClient.listTools() (slice 4
|
|
549
|
+
* FULL).
|
|
550
|
+
* POST /api/forge/mcp/toggle -> flip enabled flag on a bridge.
|
|
551
|
+
* Panel uses these to render the Config tab + tool catalog modal.
|
|
552
|
+
* Each one is best-effort; failures degrade to {ok:false, error}. */
|
|
553
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/mcp/bridges') {
|
|
554
|
+
try {
|
|
555
|
+
const { getActiveBridges } = await import('../mcp/bridge_registry.js');
|
|
556
|
+
const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
|
|
557
|
+
const shaped = bridges.map((b) => ({
|
|
558
|
+
id: b.id,
|
|
559
|
+
endpoint: b.endpoint,
|
|
560
|
+
source: b.source,
|
|
561
|
+
enabled: b.enabled,
|
|
562
|
+
display_name: b.display_name ?? b.id,
|
|
563
|
+
protocol: b.protocol ?? 'http_rest',
|
|
564
|
+
}));
|
|
565
|
+
sendJson(res, 200, { ok: true, bridges: shaped, count: shaped.length });
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err), bridges: [], count: 0 });
|
|
569
|
+
}
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/mcp/tools') {
|
|
573
|
+
const bridgeId = url.searchParams.get('bridge_id') ?? '';
|
|
574
|
+
if (!bridgeId) {
|
|
575
|
+
sendJson(res, 400, { ok: false, error: 'bridge_id required' });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const { getActiveBridges } = await import('../mcp/bridge_registry.js');
|
|
580
|
+
const bridges = await getActiveBridges({ projectRoot: ctx.projectRoot });
|
|
581
|
+
const bridge = bridges.find((b) => b.id === bridgeId);
|
|
582
|
+
if (!bridge) {
|
|
583
|
+
sendJson(res, 404, { ok: false, error: 'bridge_id "' + bridgeId + '" not found', tools: [] });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (!bridge.enabled) {
|
|
587
|
+
sendJson(res, 200, { ok: false, error: 'bridge disabled', bridge_id: bridgeId, tools: [], disabled: true });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const { getMCPClient } = await import('../mcp/protocol_client.js');
|
|
592
|
+
const client = getMCPClient(bridge);
|
|
593
|
+
const r = await client.listTools();
|
|
594
|
+
if (r.ok) {
|
|
595
|
+
sendJson(res, 200, { ok: true, bridge_id: bridgeId, tools: r.tools, count: r.count });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
sendJson(res, 200, {
|
|
599
|
+
ok: false,
|
|
600
|
+
error: r.error,
|
|
601
|
+
bridge_id: bridgeId,
|
|
602
|
+
bridge_unreachable: true,
|
|
603
|
+
tools: [],
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (toolErr) {
|
|
608
|
+
sendJson(res, 200, {
|
|
609
|
+
ok: false,
|
|
610
|
+
error: toolErr instanceof Error ? toolErr.message : String(toolErr),
|
|
611
|
+
bridge_id: bridgeId,
|
|
612
|
+
bridge_unreachable: true,
|
|
613
|
+
tools: [],
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/mcp/toggle') {
|
|
623
|
+
let body;
|
|
624
|
+
try {
|
|
625
|
+
body = JSON.parse(await readBody(req));
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const bridgeId = typeof body.bridge_id === 'string' ? body.bridge_id.trim() : '';
|
|
632
|
+
if (!bridgeId) {
|
|
633
|
+
sendJson(res, 400, { ok: false, error: 'bridge_id required' });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
|
|
637
|
+
try {
|
|
638
|
+
const { setBridgeEnabled } = await import('../mcp/state.js');
|
|
639
|
+
await setBridgeEnabled(bridgeId, enabled);
|
|
640
|
+
const { resetBridgeCache } = await import('../mcp/bridge_registry.js');
|
|
641
|
+
resetBridgeCache();
|
|
642
|
+
sendJson(res, 200, { ok: true, bridge_id: bridgeId, enabled });
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
458
649
|
/* alpha.59l -- "what changed in this version" notification.
|
|
459
650
|
* Compares ~/.yujin-forge/last_seen_version vs current VERSION
|
|
460
651
|
* + returns the cumulative bullet catalog entries. Panel shows
|
|
@@ -2485,7 +2676,14 @@ async function handleChat(req, res, ctx) {
|
|
|
2485
2676
|
* cannot infinite-loop when Danger Zone is on. */
|
|
2486
2677
|
const callSig = [];
|
|
2487
2678
|
const LOOP_REPEAT_THRESHOLD = 3;
|
|
2488
|
-
|
|
2679
|
+
/* When the operator launched `yf chat --provider claude-code-cli`,
|
|
2680
|
+
* route EVERY turn through ctx.claude (a ClaudeCliClient) and
|
|
2681
|
+
* skip the unified cost_router branch -- otherwise the router
|
|
2682
|
+
* would pick Gemini/Haiku/etc and bill against Console. The
|
|
2683
|
+
* fallback branch below uses ctx.claude.chat() directly. */
|
|
2684
|
+
const unified = ctx.chatProvider === 'claude-code-cli'
|
|
2685
|
+
? null
|
|
2686
|
+
: makeUnifiedClient(brainSelection);
|
|
2489
2687
|
const callerForChat = unified
|
|
2490
2688
|
? unified
|
|
2491
2689
|
: {
|
|
@@ -2494,6 +2692,40 @@ async function handleChat(req, res, ctx) {
|
|
|
2494
2692
|
return { ...claudeReply, brain: { ...brainSelection, runtime_wired: false } };
|
|
2495
2693
|
},
|
|
2496
2694
|
};
|
|
2695
|
+
/* alpha.59z.135 slice 11 -- panoramic graph snapshot. Compact
|
|
2696
|
+
* structural view of the project graph, cached per-slug with
|
|
2697
|
+
* hash refresh. The chat also receives the relevance ranking
|
|
2698
|
+
* below (graphSection); snapshot is the panorama, relevance is
|
|
2699
|
+
* the zoom. Graceful no-op on worker / vault failure. */
|
|
2700
|
+
let snapshotSection = '';
|
|
2701
|
+
try {
|
|
2702
|
+
const { buildGraphSnapshotSection } = await import('./graph_snapshot.js');
|
|
2703
|
+
const r = await buildGraphSnapshotSection({
|
|
2704
|
+
project_slug: ctx.projectSlug || '',
|
|
2705
|
+
fetcher: async (slug) => {
|
|
2706
|
+
try {
|
|
2707
|
+
const { semanticGraphGet } = await import('../license/hito4_client.js');
|
|
2708
|
+
const resp = await semanticGraphGet('/v1/semantic-graph/nodes', {}, { project_slug: slug });
|
|
2709
|
+
if (!resp.ok || !Array.isArray(resp.body.nodes)) {
|
|
2710
|
+
return { ok: false, nodes: [] };
|
|
2711
|
+
}
|
|
2712
|
+
return {
|
|
2713
|
+
ok: true,
|
|
2714
|
+
nodes: resp.body
|
|
2715
|
+
.nodes
|
|
2716
|
+
.filter((n) => typeof n.id === 'string' && typeof n.kind === 'string')
|
|
2717
|
+
.map((n) => ({ id: n.id, kind: n.kind, label: n.label })),
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
catch {
|
|
2721
|
+
return { ok: false, nodes: [] };
|
|
2722
|
+
}
|
|
2723
|
+
},
|
|
2724
|
+
});
|
|
2725
|
+
if (r.ok && r.text)
|
|
2726
|
+
snapshotSection = '\n\n' + r.text;
|
|
2727
|
+
}
|
|
2728
|
+
catch (_) { /* swallow -- chat continues without snapshot */ }
|
|
2497
2729
|
/* alpha.59p -- semantic graph injection. Pulls "what primitives
|
|
2498
2730
|
* exist in this project" from the worker + appends to system
|
|
2499
2731
|
* prompt so the LLM does NOT reinvent libs/components/protocols
|
|
@@ -2581,7 +2813,7 @@ async function handleChat(req, res, ctx) {
|
|
|
2581
2813
|
tier: brainSelection.tier,
|
|
2582
2814
|
provider: brainSelection.provider,
|
|
2583
2815
|
model: brainSelection.model,
|
|
2584
|
-
}, globalLearnings, mcpAvailable, backendToolsSection) + graphSection + workflowSection,
|
|
2816
|
+
}, globalLearnings, mcpAvailable, backendToolsSection) + snapshotSection + graphSection + workflowSection,
|
|
2585
2817
|
maxTokens: 1024,
|
|
2586
2818
|
tools: FORGE_TOOL_SPECS,
|
|
2587
2819
|
runTool: async (name, input) => {
|
|
@@ -3016,6 +3248,21 @@ backendToolsSection = '') {
|
|
|
3016
3248
|
' judgment ("looks good?"), $$/scope decision, or user-only creds.',
|
|
3017
3249
|
' Otherwise: diagnose yourself + report the diagnosis, not the path.',
|
|
3018
3250
|
'',
|
|
3251
|
+
'SQ 9 -- VERIFY FACTUAL CLAIMS ABOUT THE WORKSPACE (MANDATORY,',
|
|
3252
|
+
' added 2026-06-06 alpha.59z.158). When the user states something',
|
|
3253
|
+
' factual about the current workspace -- "no tiene X", "falta Y",',
|
|
3254
|
+
' "ya existe Z", "el bug esta en archivo W" -- DO NOT take it at',
|
|
3255
|
+
' face value if it would lead you to write code, add files, or',
|
|
3256
|
+
' recommend an action. Verify with native verbs FIRST:',
|
|
3257
|
+
' - "no tiene vault" -> yujin.fs.find({glob:"**/vault*"}) or fs.list-files',
|
|
3258
|
+
' - "falta tests" -> yujin.fs.list-files({subdir:"tests"})',
|
|
3259
|
+
' - "el bug en X.ts" -> yujin.fs.read-file({path:"X.ts"}) first',
|
|
3260
|
+
' - "ya existe Z" -> yujin.fs.find then proceed only if confirmed',
|
|
3261
|
+
' If the verification contradicts the user, REPORT the contradiction',
|
|
3262
|
+
' in chat ("Veo que ya hay server/vault.ts -- queres reemplazarlo,',
|
|
3263
|
+
' extenderlo, o algo mas especifico?"). Never proceed with an action',
|
|
3264
|
+
' premised on an unverified factual claim about the workspace.',
|
|
3265
|
+
'',
|
|
3019
3266
|
'=================================================================',
|
|
3020
3267
|
'PRODUCT WORKFLOW (on-demand)',
|
|
3021
3268
|
'=================================================================',
|
|
@@ -3241,6 +3488,659 @@ function sendJson(res, status, body) {
|
|
|
3241
3488
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
3242
3489
|
res.end(JSON.stringify(body));
|
|
3243
3490
|
}
|
|
3491
|
+
/* ============================================================
|
|
3492
|
+
* Director sprint slice 5 -- agent runtime panel handlers.
|
|
3493
|
+
* ============================================================ */
|
|
3494
|
+
/** Project the AgentDefinition to a UI-safe shape. Drops the
|
|
3495
|
+
* system prompt + secret-ish fields, keeps the fields the panel
|
|
3496
|
+
* graph needs to render the topology + per-agent status. */
|
|
3497
|
+
function projectAgentForPanel(def) {
|
|
3498
|
+
return {
|
|
3499
|
+
slug: def.slug,
|
|
3500
|
+
name: def.name,
|
|
3501
|
+
description: def.description,
|
|
3502
|
+
status: def.status,
|
|
3503
|
+
brain_tier: def.brain_tier,
|
|
3504
|
+
can_call: def.can_call,
|
|
3505
|
+
can_be_called_by: def.can_be_called_by,
|
|
3506
|
+
allowed_verbs: def.allowed_verbs,
|
|
3507
|
+
trigger: def.trigger,
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
async function handleAgentState(res, ctx) {
|
|
3511
|
+
if (!ctx.runtime) {
|
|
3512
|
+
sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
|
|
3513
|
+
return;
|
|
3514
|
+
}
|
|
3515
|
+
const { listAgentDefinitions } = await import('../agents/store.js');
|
|
3516
|
+
const defs = await listAgentDefinitions({ root: ctx.runtime.getAgentsRoot() });
|
|
3517
|
+
sendJson(res, 200, {
|
|
3518
|
+
ok: true,
|
|
3519
|
+
runtime: ctx.runtime.status(),
|
|
3520
|
+
agents: defs.map(projectAgentForPanel),
|
|
3521
|
+
});
|
|
3522
|
+
}
|
|
3523
|
+
async function handleAgentRun(req, res, ctx) {
|
|
3524
|
+
if (!ctx.runtime) {
|
|
3525
|
+
sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
let body;
|
|
3529
|
+
try {
|
|
3530
|
+
body = JSON.parse(await readBody(req));
|
|
3531
|
+
}
|
|
3532
|
+
catch {
|
|
3533
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (typeof body.slug !== 'string' || body.slug.length === 0) {
|
|
3537
|
+
sendJson(res, 400, { ok: false, error: 'missing required field: slug' });
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
const trigger_reason = typeof body.trigger_reason === 'string' && body.trigger_reason.length > 0
|
|
3541
|
+
? body.trigger_reason
|
|
3542
|
+
: 'panel';
|
|
3543
|
+
/* Slice 1 N-projects sprint -- optional project_slug. */
|
|
3544
|
+
const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
|
|
3545
|
+
? body.project_slug.trim()
|
|
3546
|
+
: 'self';
|
|
3547
|
+
try {
|
|
3548
|
+
const run = await ctx.runtime.dispatchAgent(body.slug, trigger_reason, { project_slug });
|
|
3549
|
+
sendJson(res, 200, { ok: true, run });
|
|
3550
|
+
}
|
|
3551
|
+
catch (err) {
|
|
3552
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3553
|
+
/* Map known runtime errors to actionable HTTP codes. */
|
|
3554
|
+
if (/not registered/i.test(msg)) {
|
|
3555
|
+
sendJson(res, 404, { ok: false, error: msg });
|
|
3556
|
+
}
|
|
3557
|
+
else if (/not started/i.test(msg)) {
|
|
3558
|
+
sendJson(res, 503, { ok: false, error: msg });
|
|
3559
|
+
}
|
|
3560
|
+
else {
|
|
3561
|
+
sendJson(res, 500, { ok: false, error: msg });
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
async function handleAgentEventsSse(req, res, ctx) {
|
|
3566
|
+
if (!ctx.runtime) {
|
|
3567
|
+
sendJson(res, 503, { ok: false, error: 'agent runtime not wired into this chat server' });
|
|
3568
|
+
return;
|
|
3569
|
+
}
|
|
3570
|
+
res.statusCode = 200;
|
|
3571
|
+
res.setHeader('content-type', 'text/event-stream; charset=utf-8');
|
|
3572
|
+
res.setHeader('cache-control', 'no-store, no-transform');
|
|
3573
|
+
res.setHeader('connection', 'keep-alive');
|
|
3574
|
+
/* Flush headers so the client can settle the response before
|
|
3575
|
+
* any event is emitted. */
|
|
3576
|
+
if (typeof res.flushHeaders === 'function') {
|
|
3577
|
+
res.flushHeaders();
|
|
3578
|
+
}
|
|
3579
|
+
/* Initial comment line keeps the proxy from buffering the
|
|
3580
|
+
* empty stream + signals the client the connection is live. */
|
|
3581
|
+
res.write(': connected to agent runtime event stream\n\n');
|
|
3582
|
+
const sendEvent = (e) => {
|
|
3583
|
+
try {
|
|
3584
|
+
res.write('event: ' + e.kind + '\n');
|
|
3585
|
+
res.write('data: ' + JSON.stringify(e) + '\n\n');
|
|
3586
|
+
}
|
|
3587
|
+
catch {
|
|
3588
|
+
/* Socket may be closed; ignore. The 'close' handler below
|
|
3589
|
+
* unsubscribes regardless. */
|
|
3590
|
+
}
|
|
3591
|
+
};
|
|
3592
|
+
ctx.runtime.on('event', sendEvent);
|
|
3593
|
+
/* Heartbeat keeps the connection alive through idle proxies.
|
|
3594
|
+
* 15s mirrors the existing chat SSE cadence. */
|
|
3595
|
+
const heartbeat = setInterval(() => {
|
|
3596
|
+
try {
|
|
3597
|
+
res.write(': hb\n\n');
|
|
3598
|
+
}
|
|
3599
|
+
catch { /* socket dead */ }
|
|
3600
|
+
}, 15_000);
|
|
3601
|
+
if (typeof heartbeat.unref === 'function') {
|
|
3602
|
+
heartbeat.unref();
|
|
3603
|
+
}
|
|
3604
|
+
const cleanup = () => {
|
|
3605
|
+
clearInterval(heartbeat);
|
|
3606
|
+
if (ctx.runtime)
|
|
3607
|
+
ctx.runtime.off('event', sendEvent);
|
|
3608
|
+
};
|
|
3609
|
+
req.on('close', cleanup);
|
|
3610
|
+
req.on('error', cleanup);
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3613
|
+
* Director sprint slice 10 -- POST /api/agent/approve.
|
|
3614
|
+
*
|
|
3615
|
+
* Single endpoint that resolves an R8-style approval envelope an
|
|
3616
|
+
* agent emitted (decision: 'approved' | 'rejected'). Delegates to
|
|
3617
|
+
* the existing approval queue + audit log. Independent of runtime
|
|
3618
|
+
* state -- a pending row can survive runtime restarts via the
|
|
3619
|
+
* queue's TTL, so we do NOT gate this endpoint on ctx.runtime.
|
|
3620
|
+
*/
|
|
3621
|
+
async function handleAgentApprove(req, res, ctx) {
|
|
3622
|
+
let body;
|
|
3623
|
+
try {
|
|
3624
|
+
body = JSON.parse(await readBody(req));
|
|
3625
|
+
}
|
|
3626
|
+
catch {
|
|
3627
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
const traceId = typeof body.trace_id === 'string' ? body.trace_id.trim() : '';
|
|
3631
|
+
if (!traceId) {
|
|
3632
|
+
sendJson(res, 400, { ok: false, error: 'trace_id required' });
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
const decision = typeof body.decision === 'string' ? body.decision : '';
|
|
3636
|
+
if (decision !== 'approved' && decision !== 'rejected') {
|
|
3637
|
+
sendJson(res, 400, { ok: false, error: 'decision must be "approved" or "rejected"' });
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
const reason = typeof body.reason === 'string' ? body.reason.slice(0, 240) : undefined;
|
|
3641
|
+
const pending = popPending(traceId);
|
|
3642
|
+
if (!pending) {
|
|
3643
|
+
await logApproval({
|
|
3644
|
+
ts: new Date().toISOString(),
|
|
3645
|
+
trace_id: traceId,
|
|
3646
|
+
verb_id: '?',
|
|
3647
|
+
decision: 'expired',
|
|
3648
|
+
args_sample: {},
|
|
3649
|
+
policy: '?',
|
|
3650
|
+
reason: 'trace_id not in queue (expired or already resolved)',
|
|
3651
|
+
});
|
|
3652
|
+
sendJson(res, 404, { ok: false, error: 'no pending approval for trace_id (expired or already resolved)' });
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
if (decision === 'rejected') {
|
|
3656
|
+
await logApproval({
|
|
3657
|
+
ts: new Date().toISOString(),
|
|
3658
|
+
trace_id: traceId,
|
|
3659
|
+
verb_id: pending.verb.id,
|
|
3660
|
+
decision: 'reject',
|
|
3661
|
+
args_sample: pending.args,
|
|
3662
|
+
policy: pending.verb.policy,
|
|
3663
|
+
reason,
|
|
3664
|
+
});
|
|
3665
|
+
/* Director closure pendiente 5 followup -- fire the SSE event
|
|
3666
|
+
* so the panel SVG can clear the paused-for-approval marker on
|
|
3667
|
+
* the originating agent node. Best-effort: no runtime / no
|
|
3668
|
+
* agent_slug means nobody is listening for this event anyway. */
|
|
3669
|
+
if (ctx.runtime && pending.agent_slug) {
|
|
3670
|
+
ctx.runtime.emitExternal({
|
|
3671
|
+
kind: 'agent_run_paused_resolved',
|
|
3672
|
+
slug: pending.agent_slug,
|
|
3673
|
+
trace_id: traceId,
|
|
3674
|
+
decision: 'rejected',
|
|
3675
|
+
at: Date.now(),
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
3678
|
+
sendJson(res, 200, {
|
|
3679
|
+
ok: true,
|
|
3680
|
+
trace_id: traceId,
|
|
3681
|
+
verb_id: pending.verb.id,
|
|
3682
|
+
decision: 'rejected',
|
|
3683
|
+
});
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
/* Approve path: execute the bound verb via its captured legacy
|
|
3687
|
+
* alias, mirroring handleNac3Approve. */
|
|
3688
|
+
if (!pending.legacy_alias) {
|
|
3689
|
+
sendJson(res, 500, { ok: false, error: 'pending row has no legacy alias' });
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
let result;
|
|
3693
|
+
try {
|
|
3694
|
+
result = await pending.legacy_run(pending.legacy_alias, pending.args, ctx.projectRoot === pending.ctx.projectRoot ? pending.ctx : { projectRoot: ctx.projectRoot });
|
|
3695
|
+
}
|
|
3696
|
+
catch (err) {
|
|
3697
|
+
await logApproval({
|
|
3698
|
+
ts: new Date().toISOString(),
|
|
3699
|
+
trace_id: traceId,
|
|
3700
|
+
verb_id: pending.verb.id,
|
|
3701
|
+
decision: 'approve',
|
|
3702
|
+
args_sample: pending.args,
|
|
3703
|
+
policy: pending.verb.policy,
|
|
3704
|
+
reason: 'handler threw: ' + (err instanceof Error ? err.message : String(err)),
|
|
3705
|
+
});
|
|
3706
|
+
sendJson(res, 500, {
|
|
3707
|
+
ok: false,
|
|
3708
|
+
error: 'handler threw on approved verb: ' + (err instanceof Error ? err.message : String(err)),
|
|
3709
|
+
});
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
await logApproval({
|
|
3713
|
+
ts: new Date().toISOString(),
|
|
3714
|
+
trace_id: traceId,
|
|
3715
|
+
verb_id: pending.verb.id,
|
|
3716
|
+
decision: 'approve',
|
|
3717
|
+
args_sample: pending.args,
|
|
3718
|
+
policy: pending.verb.policy,
|
|
3719
|
+
reason,
|
|
3720
|
+
});
|
|
3721
|
+
/* Director closure pendiente 5 followup -- fire the SSE event so
|
|
3722
|
+
* the panel SVG can clear the paused-for-approval marker on the
|
|
3723
|
+
* originating agent node. */
|
|
3724
|
+
if (ctx.runtime && pending.agent_slug) {
|
|
3725
|
+
ctx.runtime.emitExternal({
|
|
3726
|
+
kind: 'agent_run_paused_resolved',
|
|
3727
|
+
slug: pending.agent_slug,
|
|
3728
|
+
trace_id: traceId,
|
|
3729
|
+
decision: 'approved',
|
|
3730
|
+
at: Date.now(),
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3733
|
+
sendJson(res, 200, {
|
|
3734
|
+
ok: true,
|
|
3735
|
+
trace_id: traceId,
|
|
3736
|
+
verb_id: pending.verb.id,
|
|
3737
|
+
decision: 'approved',
|
|
3738
|
+
is_error: result.is_error ?? false,
|
|
3739
|
+
result: result.result,
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
/* ===================================================================
|
|
3743
|
+
* Agent activity log + bridge inspector + chat-per-agent
|
|
3744
|
+
* (visibility sprint -- alpha.59z.153).
|
|
3745
|
+
*
|
|
3746
|
+
* Three endpoints that surface what the agents are actually doing
|
|
3747
|
+
* so the operator is not staring at a silent SVG:
|
|
3748
|
+
*
|
|
3749
|
+
* GET /api/agent/activity?since=<iso?>&limit=<n?>
|
|
3750
|
+
* -> recent timestamped events: agent runs (started/finished),
|
|
3751
|
+
* bridge sends, dispatch invokes, approval decisions.
|
|
3752
|
+
* Merge of 4 jsonl tails + 1 dir scan; sorted by ts desc.
|
|
3753
|
+
*
|
|
3754
|
+
* GET /api/agent/bridge-tail?pair_id=<from__to?>&limit=<n?>
|
|
3755
|
+
* -> messages out of bridge.json filtered + sorted desc.
|
|
3756
|
+
* When pair_id absent, lists all pairs with counts.
|
|
3757
|
+
*
|
|
3758
|
+
* POST /api/agent/inject {slug, text, dispatch?}
|
|
3759
|
+
* -> append a user-role message to the agent's active
|
|
3760
|
+
* conversation file + optionally dispatch a run.
|
|
3761
|
+
*
|
|
3762
|
+
* All three are best-effort: missing files are normal (no agents
|
|
3763
|
+
* yet) -- they return ok:true with empty arrays, not 404.
|
|
3764
|
+
* =============================================================== */
|
|
3765
|
+
async function tailJsonl(file, limit) {
|
|
3766
|
+
try {
|
|
3767
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
3768
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
3769
|
+
const slice = lines.length > limit ? lines.slice(lines.length - limit) : lines;
|
|
3770
|
+
const out = [];
|
|
3771
|
+
for (const line of slice) {
|
|
3772
|
+
try {
|
|
3773
|
+
out.push(JSON.parse(line));
|
|
3774
|
+
}
|
|
3775
|
+
catch { /* skip corrupt line */ }
|
|
3776
|
+
}
|
|
3777
|
+
return out;
|
|
3778
|
+
}
|
|
3779
|
+
catch {
|
|
3780
|
+
return [];
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
function agentsRootDefault(ctx) {
|
|
3784
|
+
if (ctx.runtime)
|
|
3785
|
+
return ctx.runtime.getAgentsRoot();
|
|
3786
|
+
/* Mirror the runtime default. YF_CONFIG_DIR is the same env var
|
|
3787
|
+
* approval_queue / dispatch_log honor; tests inject it to scope
|
|
3788
|
+
* the entire ~/.yujin-forge tree to a tmpdir. */
|
|
3789
|
+
if (process.env.YF_CONFIG_DIR)
|
|
3790
|
+
return process.env.YF_CONFIG_DIR;
|
|
3791
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
3792
|
+
return path.join(home, '.yujin-forge');
|
|
3793
|
+
}
|
|
3794
|
+
async function handleAgentActivity(req, res, ctx) {
|
|
3795
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
3796
|
+
const limit = clampInt(url.searchParams.get('limit'), 200, 1, 2000);
|
|
3797
|
+
const since = url.searchParams.get('since') || '';
|
|
3798
|
+
/* Slice 1 N-projects sprint -- optional project filter. Empty
|
|
3799
|
+
* = all projects (default; back-compat with single-project
|
|
3800
|
+
* callers). Explicit slug filters events by project_slug. */
|
|
3801
|
+
const projectFilter = (url.searchParams.get('project_slug') || '').trim();
|
|
3802
|
+
const events = [];
|
|
3803
|
+
const agentsRoot = agentsRootDefault(ctx);
|
|
3804
|
+
const yfRoot = path.join(agentsRoot);
|
|
3805
|
+
/* 1. nac3 dispatch log -- every verb invoke / deny / panel-dispatch */
|
|
3806
|
+
const dispatchLog = path.join(yfRoot, 'nac3_log.jsonl');
|
|
3807
|
+
for (const e of await tailJsonl(dispatchLog, limit)) {
|
|
3808
|
+
const r = e;
|
|
3809
|
+
if (!r.ts)
|
|
3810
|
+
continue;
|
|
3811
|
+
const label = r.kind === 'invoke' ? ((r.result_ok ? 'ok' : 'err') + ' ' + (r.verb_id ?? '?'))
|
|
3812
|
+
: r.kind === 'verb-not-found' ? ('unknown verb ' + (r.verb_id ?? '?'))
|
|
3813
|
+
: r.kind === 'policy-deny' ? ('policy denied ' + (r.verb_id ?? '?'))
|
|
3814
|
+
: r.kind === 'panel-handler-error' ? ('panel error ' + (r.verb_id ?? '?'))
|
|
3815
|
+
: String(r.kind ?? 'event');
|
|
3816
|
+
events.push({
|
|
3817
|
+
ts: String(r.ts),
|
|
3818
|
+
kind: 'dispatch.' + String(r.kind ?? 'unknown'),
|
|
3819
|
+
text: label,
|
|
3820
|
+
trace_id: r.trace_id ? String(r.trace_id) : undefined,
|
|
3821
|
+
extra: {
|
|
3822
|
+
duration_ms: r.duration_ms,
|
|
3823
|
+
policy: r.policy,
|
|
3824
|
+
side_effects_declared: r.side_effects_declared,
|
|
3825
|
+
},
|
|
3826
|
+
});
|
|
3827
|
+
}
|
|
3828
|
+
/* 2. approvals log -- R8 modal decisions */
|
|
3829
|
+
const approvalLog = path.join(yfRoot, 'nac3_approvals.jsonl');
|
|
3830
|
+
for (const e of await tailJsonl(approvalLog, limit)) {
|
|
3831
|
+
const r = e;
|
|
3832
|
+
if (!r.ts)
|
|
3833
|
+
continue;
|
|
3834
|
+
events.push({
|
|
3835
|
+
ts: String(r.ts),
|
|
3836
|
+
kind: 'approval.' + String(r.decision ?? 'event'),
|
|
3837
|
+
text: 'R8 ' + String(r.decision ?? '?') + ' on ' + String(r.verb_id ?? '?'),
|
|
3838
|
+
trace_id: r.trace_id ? String(r.trace_id) : undefined,
|
|
3839
|
+
extra: { policy: r.policy, reason: r.reason },
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
/* 3. agent runs -- scan ~/.yujin-forge/agents/by-slug/<slug>/runs/*.json */
|
|
3843
|
+
const bySlug = path.join(yfRoot, 'agents', 'by-slug');
|
|
3844
|
+
let slugs = [];
|
|
3845
|
+
try {
|
|
3846
|
+
slugs = await fs.readdir(bySlug);
|
|
3847
|
+
}
|
|
3848
|
+
catch { /* no agents yet -- ok */ }
|
|
3849
|
+
for (const slug of slugs) {
|
|
3850
|
+
const runsDir = path.join(bySlug, slug, 'runs');
|
|
3851
|
+
let runFiles = [];
|
|
3852
|
+
try {
|
|
3853
|
+
runFiles = (await fs.readdir(runsDir)).filter((n) => n.endsWith('.json'));
|
|
3854
|
+
}
|
|
3855
|
+
catch {
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
/* Newest first; we cap at limit per agent so a noisy one
|
|
3859
|
+
* does not drown others. */
|
|
3860
|
+
runFiles.sort();
|
|
3861
|
+
const cap = runFiles.slice(-Math.min(50, limit));
|
|
3862
|
+
for (const fn of cap) {
|
|
3863
|
+
try {
|
|
3864
|
+
const raw = await fs.readFile(path.join(runsDir, fn), 'utf-8');
|
|
3865
|
+
const r = JSON.parse(raw);
|
|
3866
|
+
const startTs = r.started_at ? new Date(Number(r.started_at)).toISOString() : '';
|
|
3867
|
+
const endTs = r.ended_at ? new Date(Number(r.ended_at)).toISOString() : startTs;
|
|
3868
|
+
const runProject = (typeof r.project_slug === 'string' && r.project_slug) ? r.project_slug : 'self';
|
|
3869
|
+
events.push({
|
|
3870
|
+
ts: startTs || endTs,
|
|
3871
|
+
kind: 'agent.run_started',
|
|
3872
|
+
slug,
|
|
3873
|
+
text: '[' + runProject + '] ' + slug + ' started: ' + String(r.trigger_reason ?? '').slice(0, 120),
|
|
3874
|
+
project_slug: runProject,
|
|
3875
|
+
});
|
|
3876
|
+
if (r.ended_at) {
|
|
3877
|
+
events.push({
|
|
3878
|
+
ts: endTs,
|
|
3879
|
+
kind: 'agent.run_finished',
|
|
3880
|
+
slug,
|
|
3881
|
+
text: '[' + runProject + '] ' + slug + ' ' + String(r.status ?? '?')
|
|
3882
|
+
+ ' (in=' + (r.tokens_in ?? 0) + ' out=' + (r.tokens_out ?? 0)
|
|
3883
|
+
+ ' tools=' + (r.tool_calls ?? 0) + ')',
|
|
3884
|
+
project_slug: runProject,
|
|
3885
|
+
extra: {
|
|
3886
|
+
status: r.status,
|
|
3887
|
+
tokens_in: r.tokens_in,
|
|
3888
|
+
tokens_out: r.tokens_out,
|
|
3889
|
+
tool_calls: r.tool_calls,
|
|
3890
|
+
kill_reason: r.kill_reason,
|
|
3891
|
+
last_reply_summary: typeof r.last_reply_summary === 'string'
|
|
3892
|
+
? String(r.last_reply_summary).slice(0, 240)
|
|
3893
|
+
: undefined,
|
|
3894
|
+
},
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
catch { /* skip */ }
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
/* 4. bridge messages -- scan every shard under
|
|
3902
|
+
* ~/.yujin-forge/bridge/ (bridge.json + bridge-{slug}.json).
|
|
3903
|
+
* Each shard's messages tag themselves with project_slug
|
|
3904
|
+
* derived from the filename so the activity feed knows which
|
|
3905
|
+
* project a message belongs to. */
|
|
3906
|
+
const bridgeDir = path.join(yfRoot, 'bridge');
|
|
3907
|
+
let bridgeShards = [];
|
|
3908
|
+
try {
|
|
3909
|
+
bridgeShards = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
|
|
3910
|
+
}
|
|
3911
|
+
catch { /* no bridge dir -- ok */ }
|
|
3912
|
+
for (const fname of bridgeShards) {
|
|
3913
|
+
const projectFromFile = fname === 'bridge.json'
|
|
3914
|
+
? 'self'
|
|
3915
|
+
: fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
|
|
3916
|
+
try {
|
|
3917
|
+
const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
|
|
3918
|
+
const b = JSON.parse(raw);
|
|
3919
|
+
const msgs = Array.isArray(b.messages) ? b.messages : [];
|
|
3920
|
+
/* Take last `limit` per shard to bound memory while not
|
|
3921
|
+
* losing recent activity. */
|
|
3922
|
+
const slice = msgs.length > limit ? msgs.slice(msgs.length - limit) : msgs;
|
|
3923
|
+
for (const m of slice) {
|
|
3924
|
+
const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
|
|
3925
|
+
: (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
|
|
3926
|
+
const msgProject = (typeof m.project_slug === 'string' && m.project_slug)
|
|
3927
|
+
? m.project_slug
|
|
3928
|
+
: projectFromFile;
|
|
3929
|
+
events.push({
|
|
3930
|
+
ts,
|
|
3931
|
+
kind: 'bridge.message',
|
|
3932
|
+
from: String(m.from_slug ?? m.from ?? '?'),
|
|
3933
|
+
to: String(m.to_slug ?? m.to ?? '?'),
|
|
3934
|
+
text: '[' + msgProject + '] '
|
|
3935
|
+
+ String(m.from_slug ?? m.from ?? '?') + ' -> ' + String(m.to_slug ?? m.to ?? '?')
|
|
3936
|
+
+ ': ' + String(m.payload ?? '').slice(0, 160),
|
|
3937
|
+
project_slug: msgProject,
|
|
3938
|
+
extra: { tokens: m.tokens_used, status: m.status, id: m.id },
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
catch { /* skip unreadable shard */ }
|
|
3943
|
+
}
|
|
3944
|
+
/* Sort desc by ts (ISO strings sort correctly). Filter by since
|
|
3945
|
+
* + project_filter. */
|
|
3946
|
+
events.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
|
|
3947
|
+
let filtered = since ? events.filter((e) => e.ts > since) : events;
|
|
3948
|
+
if (projectFilter) {
|
|
3949
|
+
filtered = filtered.filter((e) => e.project_slug === projectFilter
|
|
3950
|
+
/* Events without a project_slug (legacy or non-project-
|
|
3951
|
+
* tagged kinds like dispatch.*) appear under 'self' so a
|
|
3952
|
+
* filter for the default project still sees them. */
|
|
3953
|
+
|| (!e.project_slug && projectFilter === 'self'));
|
|
3954
|
+
}
|
|
3955
|
+
const final = filtered.slice(0, limit);
|
|
3956
|
+
sendJson(res, 200, { ok: true, count: final.length, project_filter: projectFilter || null, events: final });
|
|
3957
|
+
}
|
|
3958
|
+
async function handleAgentBridgeTail(req, res, ctx) {
|
|
3959
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
3960
|
+
const limit = clampInt(url.searchParams.get('limit'), 100, 1, 1000);
|
|
3961
|
+
const pairId = (url.searchParams.get('pair_id') || '').trim();
|
|
3962
|
+
/* Slice 1 N-projects sprint -- optional project filter. Empty
|
|
3963
|
+
* means "scan every shard". 'self' means only the default
|
|
3964
|
+
* (legacy bridge.json). */
|
|
3965
|
+
const projectFilter = (url.searchParams.get('project_slug') || '').trim();
|
|
3966
|
+
const yfRoot = agentsRootDefault(ctx);
|
|
3967
|
+
const bridgeDir = path.join(yfRoot, 'bridge');
|
|
3968
|
+
/* Discover every shard file, then read whichever ones match
|
|
3969
|
+
* the filter. */
|
|
3970
|
+
let shardFiles = [];
|
|
3971
|
+
try {
|
|
3972
|
+
shardFiles = (await fs.readdir(bridgeDir)).filter((n) => n.endsWith('.json') && (n === 'bridge.json' || n.startsWith('bridge-')));
|
|
3973
|
+
}
|
|
3974
|
+
catch {
|
|
3975
|
+
sendJson(res, 200, { ok: true, pairs: [], messages: [], pair_state: {}, project_filter: projectFilter || null });
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
/* Load every shard. msgs carries an extra `_project_slug` for
|
|
3979
|
+
* downstream filtering. pair_state aggregated per-shard. */
|
|
3980
|
+
const allMsgs = [];
|
|
3981
|
+
const allPairState = {};
|
|
3982
|
+
for (const fname of shardFiles) {
|
|
3983
|
+
const project = fname === 'bridge.json'
|
|
3984
|
+
? 'self'
|
|
3985
|
+
: fname.slice('bridge-'.length, fname.length - '.json'.length) || 'self';
|
|
3986
|
+
if (projectFilter && projectFilter !== project)
|
|
3987
|
+
continue;
|
|
3988
|
+
try {
|
|
3989
|
+
const raw = await fs.readFile(path.join(bridgeDir, fname), 'utf-8');
|
|
3990
|
+
const b = JSON.parse(raw);
|
|
3991
|
+
const ms = Array.isArray(b.messages) ? b.messages : [];
|
|
3992
|
+
for (const m of ms)
|
|
3993
|
+
allMsgs.push({ ...m, _project_slug: project });
|
|
3994
|
+
if (b.pair_state) {
|
|
3995
|
+
for (const [pid, st] of Object.entries(b.pair_state)) {
|
|
3996
|
+
allPairState[pid + '@' + project] = { project_slug: project, state: st };
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
}
|
|
4000
|
+
catch { /* skip unreadable shard */ }
|
|
4001
|
+
}
|
|
4002
|
+
/* When pair_id absent: aggregate by pair (cross-shard). The
|
|
4003
|
+
* project_slug is part of the visible pair card so two pairs
|
|
4004
|
+
* with the same from/to but different projects don't merge. */
|
|
4005
|
+
if (!pairId) {
|
|
4006
|
+
const pairs = {};
|
|
4007
|
+
for (const m of allMsgs) {
|
|
4008
|
+
const fromSlug = String(m.from_slug ?? m.from ?? '?');
|
|
4009
|
+
const toSlug = String(m.to_slug ?? m.to ?? '?');
|
|
4010
|
+
const proj = m._project_slug;
|
|
4011
|
+
const key = fromSlug + '__' + toSlug + (proj === 'self' ? '' : '@' + proj);
|
|
4012
|
+
const ts = m.sent_at ? new Date(Number(m.sent_at)).toISOString()
|
|
4013
|
+
: (m.created_at ? new Date(Number(m.created_at)).toISOString() : '');
|
|
4014
|
+
if (!pairs[key]) {
|
|
4015
|
+
pairs[key] = { from: fromSlug, to: toSlug, count: 0, last_ts: ts, project_slug: proj };
|
|
4016
|
+
}
|
|
4017
|
+
pairs[key].count++;
|
|
4018
|
+
if (ts > pairs[key].last_ts)
|
|
4019
|
+
pairs[key].last_ts = ts;
|
|
4020
|
+
}
|
|
4021
|
+
const list = Object.entries(pairs).map(([k, v]) => ({ pair_id: k, ...v }))
|
|
4022
|
+
.sort((a, b) => (a.last_ts < b.last_ts ? 1 : -1));
|
|
4023
|
+
sendJson(res, 200, { ok: true, pairs: list, project_filter: projectFilter || null });
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
/* With pair_id: parse out optional @project suffix; filter
|
|
4027
|
+
* messages newest first, capped. */
|
|
4028
|
+
const atIdx = pairId.lastIndexOf('@');
|
|
4029
|
+
const pairCore = atIdx >= 0 ? pairId.slice(0, atIdx) : pairId;
|
|
4030
|
+
const pairProject = atIdx >= 0 ? pairId.slice(atIdx + 1) : 'self';
|
|
4031
|
+
const filtered = allMsgs.filter((m) => {
|
|
4032
|
+
const fromSlug = String(m.from_slug ?? m.from ?? '?');
|
|
4033
|
+
const toSlug = String(m.to_slug ?? m.to ?? '?');
|
|
4034
|
+
return (fromSlug + '__' + toSlug) === pairCore && m._project_slug === pairProject;
|
|
4035
|
+
});
|
|
4036
|
+
const slice = filtered.length > limit ? filtered.slice(filtered.length - limit) : filtered;
|
|
4037
|
+
slice.sort((a, b) => Number(b.sent_at ?? 0) - Number(a.sent_at ?? 0));
|
|
4038
|
+
/* Find matching pair_state from the per-shard map. */
|
|
4039
|
+
const pairStateKey = Object.keys(allPairState).find((k) => k.endsWith('@' + pairProject))
|
|
4040
|
+
|| null;
|
|
4041
|
+
sendJson(res, 200, {
|
|
4042
|
+
ok: true,
|
|
4043
|
+
pair_id: pairId,
|
|
4044
|
+
project_slug: pairProject,
|
|
4045
|
+
count: slice.length,
|
|
4046
|
+
messages: slice,
|
|
4047
|
+
pair_state: pairStateKey ? allPairState[pairStateKey].state : null,
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
async function handleAgentInject(req, res, ctx) {
|
|
4051
|
+
let body;
|
|
4052
|
+
try {
|
|
4053
|
+
body = JSON.parse(await readBody(req));
|
|
4054
|
+
}
|
|
4055
|
+
catch {
|
|
4056
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
4057
|
+
return;
|
|
4058
|
+
}
|
|
4059
|
+
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
|
|
4060
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
4061
|
+
if (!slug) {
|
|
4062
|
+
sendJson(res, 400, { ok: false, error: 'slug required' });
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
if (!text) {
|
|
4066
|
+
sendJson(res, 400, { ok: false, error: 'text required' });
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
const doDispatch = body.dispatch !== false;
|
|
4070
|
+
/* Slice 1 N-projects sprint -- explicit project_slug routes
|
|
4071
|
+
* the user message to that project's conversation/run instance.
|
|
4072
|
+
* Defaults to 'self' for legacy single-project behaviour. */
|
|
4073
|
+
const project_slug = (typeof body.project_slug === 'string' && body.project_slug.trim())
|
|
4074
|
+
? body.project_slug.trim()
|
|
4075
|
+
: 'self';
|
|
4076
|
+
const yfRoot = agentsRootDefault(ctx);
|
|
4077
|
+
const convDir = path.join(yfRoot, 'agents', 'by-slug', slug, 'conversations');
|
|
4078
|
+
/* Pick the most recently modified conversation file, or create
|
|
4079
|
+
* one named "manual" if none exists yet. */
|
|
4080
|
+
let convFile = path.join(convDir, 'manual.json');
|
|
4081
|
+
try {
|
|
4082
|
+
const files = (await fs.readdir(convDir)).filter((n) => n.endsWith('.json'));
|
|
4083
|
+
if (files.length > 0) {
|
|
4084
|
+
const stats = await Promise.all(files.map(async (n) => ({
|
|
4085
|
+
name: n,
|
|
4086
|
+
mtime: (await fs.stat(path.join(convDir, n))).mtimeMs,
|
|
4087
|
+
})));
|
|
4088
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
4089
|
+
convFile = path.join(convDir, stats[0].name);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
catch {
|
|
4093
|
+
/* convDir does not exist yet -- mkdir below + start manual. */
|
|
4094
|
+
}
|
|
4095
|
+
let messages = [];
|
|
4096
|
+
try {
|
|
4097
|
+
const raw = await fs.readFile(convFile, 'utf-8');
|
|
4098
|
+
const parsed = JSON.parse(raw);
|
|
4099
|
+
if (parsed && Array.isArray(parsed.messages))
|
|
4100
|
+
messages = parsed.messages;
|
|
4101
|
+
}
|
|
4102
|
+
catch { /* fresh */ }
|
|
4103
|
+
messages.push({ role: 'user', content: text });
|
|
4104
|
+
try {
|
|
4105
|
+
await fs.mkdir(convDir, { recursive: true });
|
|
4106
|
+
await fs.writeFile(convFile, JSON.stringify({ version: 1, messages }, null, 2), 'utf-8');
|
|
4107
|
+
}
|
|
4108
|
+
catch (err) {
|
|
4109
|
+
sendJson(res, 500, { ok: false, error: 'persist failed: ' + (err instanceof Error ? err.message : String(err)) });
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
if (doDispatch) {
|
|
4113
|
+
if (!ctx.runtime) {
|
|
4114
|
+
sendJson(res, 200, {
|
|
4115
|
+
ok: true, persisted: convFile, dispatched: false,
|
|
4116
|
+
note: 'runtime not wired -- message saved but no run fired',
|
|
4117
|
+
});
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
try {
|
|
4121
|
+
const run = await ctx.runtime.dispatchAgent(slug, 'user-injected: ' + text.slice(0, 80), { project_slug });
|
|
4122
|
+
sendJson(res, 200, {
|
|
4123
|
+
ok: true, persisted: convFile, dispatched: true,
|
|
4124
|
+
run_status: run.status, run_id: run.id, last_reply: run.last_reply_summary,
|
|
4125
|
+
project_slug: run.project_slug ?? project_slug,
|
|
4126
|
+
});
|
|
4127
|
+
}
|
|
4128
|
+
catch (err) {
|
|
4129
|
+
sendJson(res, 200, {
|
|
4130
|
+
ok: true, persisted: convFile, dispatched: false,
|
|
4131
|
+
error: err instanceof Error ? err.message : String(err),
|
|
4132
|
+
});
|
|
4133
|
+
}
|
|
4134
|
+
return;
|
|
4135
|
+
}
|
|
4136
|
+
sendJson(res, 200, { ok: true, persisted: convFile, dispatched: false });
|
|
4137
|
+
}
|
|
4138
|
+
function clampInt(raw, fallback, lo, hi) {
|
|
4139
|
+
const n = raw != null ? Number(raw) : fallback;
|
|
4140
|
+
if (!Number.isFinite(n))
|
|
4141
|
+
return fallback;
|
|
4142
|
+
return Math.max(lo, Math.min(hi, Math.floor(n)));
|
|
4143
|
+
}
|
|
3244
4144
|
/** Serve a legal doc (privacy policy / limited responsibility) as
|
|
3245
4145
|
* text/markdown. Reads from dist/docs/<basename> (copied at build
|
|
3246
4146
|
* time by scripts/copy_qa_assets.mjs). Names are static + sanitised
|