@runcore-sh/runcore 0.1.7 → 0.1.9
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/access/manifest.d.ts +59 -0
- package/dist/access/manifest.d.ts.map +1 -0
- package/dist/access/manifest.js +251 -0
- package/dist/access/manifest.js.map +1 -0
- package/dist/activity/log.d.ts +1 -1
- package/dist/activity/log.d.ts.map +1 -1
- package/dist/agents/autonomous.d.ts.map +1 -1
- package/dist/agents/autonomous.js +38 -0
- package/dist/agents/autonomous.js.map +1 -1
- package/dist/agents/governance.d.ts +70 -0
- package/dist/agents/governance.d.ts.map +1 -0
- package/dist/agents/governance.js +220 -0
- package/dist/agents/governance.js.map +1 -0
- package/dist/agents/governed-spawn.d.ts +83 -0
- package/dist/agents/governed-spawn.d.ts.map +1 -0
- package/dist/agents/governed-spawn.js +186 -0
- package/dist/agents/governed-spawn.js.map +1 -0
- package/dist/agents/heartbeat.d.ts +91 -0
- package/dist/agents/heartbeat.d.ts.map +1 -0
- package/dist/agents/heartbeat.js +323 -0
- package/dist/agents/heartbeat.js.map +1 -0
- package/dist/agents/index.d.ts +4 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +6 -1
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/spawn-policy.d.ts +45 -0
- package/dist/agents/spawn-policy.d.ts.map +1 -0
- package/dist/agents/spawn-policy.js +202 -0
- package/dist/agents/spawn-policy.js.map +1 -0
- package/dist/alert.d.ts +16 -0
- package/dist/alert.d.ts.map +1 -0
- package/dist/alert.js +70 -0
- package/dist/alert.js.map +1 -0
- package/dist/cli.js +35 -27
- package/dist/cli.js.map +1 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.d.ts.map +1 -1
- package/dist/credentials/store.js +14 -3
- package/dist/credentials/store.js.map +1 -1
- package/dist/crystallizer.d.ts +56 -0
- package/dist/crystallizer.d.ts.map +1 -0
- package/dist/crystallizer.js +159 -0
- package/dist/crystallizer.js.map +1 -0
- package/dist/distiller.d.ts +48 -0
- package/dist/distiller.d.ts.map +1 -0
- package/dist/distiller.js +140 -0
- package/dist/distiller.js.map +1 -0
- package/dist/google/auth.d.ts +2 -0
- package/dist/google/auth.d.ts.map +1 -1
- package/dist/google/auth.js +2 -0
- package/dist/google/auth.js.map +1 -1
- package/dist/integrations/gate.d.ts +40 -0
- package/dist/integrations/gate.d.ts.map +1 -0
- package/dist/integrations/gate.js +100 -0
- package/dist/integrations/gate.js.map +1 -0
- package/dist/lib/audit.d.ts +43 -0
- package/dist/lib/audit.d.ts.map +1 -0
- package/dist/lib/audit.js +120 -0
- package/dist/lib/audit.js.map +1 -0
- package/dist/lib/brain-io.d.ts.map +1 -1
- package/dist/lib/brain-io.js +52 -0
- package/dist/lib/brain-io.js.map +1 -1
- package/dist/lib/dpapi.d.ts +14 -0
- package/dist/lib/dpapi.d.ts.map +1 -0
- package/dist/lib/dpapi.js +104 -0
- package/dist/lib/dpapi.js.map +1 -0
- package/dist/lib/glob-match.d.ts +22 -0
- package/dist/lib/glob-match.d.ts.map +1 -0
- package/dist/lib/glob-match.js +64 -0
- package/dist/lib/glob-match.js.map +1 -0
- package/dist/lib/locked.d.ts +40 -0
- package/dist/lib/locked.d.ts.map +1 -0
- package/dist/lib/locked.js +130 -0
- package/dist/lib/locked.js.map +1 -0
- package/dist/llm/complete.d.ts.map +1 -1
- package/dist/llm/complete.js +5 -2
- package/dist/llm/complete.js.map +1 -1
- package/dist/llm/fetch-guard.d.ts +16 -0
- package/dist/llm/fetch-guard.d.ts.map +1 -0
- package/dist/llm/fetch-guard.js +61 -0
- package/dist/llm/fetch-guard.js.map +1 -0
- package/dist/llm/guard.d.ts +40 -0
- package/dist/llm/guard.d.ts.map +1 -0
- package/dist/llm/guard.js +88 -0
- package/dist/llm/guard.js.map +1 -0
- package/dist/llm/membrane.d.ts +46 -0
- package/dist/llm/membrane.d.ts.map +1 -0
- package/dist/llm/membrane.js +123 -0
- package/dist/llm/membrane.js.map +1 -0
- package/dist/llm/providers/index.d.ts +5 -1
- package/dist/llm/providers/index.d.ts.map +1 -1
- package/dist/llm/providers/index.js +8 -1
- package/dist/llm/providers/index.js.map +1 -1
- package/dist/llm/redact.d.ts +39 -0
- package/dist/llm/redact.d.ts.map +1 -0
- package/dist/llm/redact.js +155 -0
- package/dist/llm/redact.js.map +1 -0
- package/dist/llm/sensitive-registry.d.ts +33 -0
- package/dist/llm/sensitive-registry.d.ts.map +1 -0
- package/dist/llm/sensitive-registry.js +106 -0
- package/dist/llm/sensitive-registry.js.map +1 -0
- package/dist/mcp-server.d.ts +11 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +520 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/mdns.d.ts +17 -0
- package/dist/mdns.d.ts.map +1 -0
- package/dist/mdns.js +110 -0
- package/dist/mdns.js.map +1 -0
- package/dist/nerve/push.d.ts +26 -0
- package/dist/nerve/push.d.ts.map +1 -0
- package/dist/nerve/push.js +170 -0
- package/dist/nerve/push.js.map +1 -0
- package/dist/nerve/state.d.ts +35 -0
- package/dist/nerve/state.d.ts.map +1 -0
- package/dist/nerve/state.js +257 -0
- package/dist/nerve/state.js.map +1 -0
- package/dist/resend/inbox.d.ts +23 -0
- package/dist/resend/inbox.d.ts.map +1 -0
- package/dist/resend/inbox.js +198 -0
- package/dist/resend/inbox.js.map +1 -0
- package/dist/resend/webhooks.d.ts +30 -0
- package/dist/resend/webhooks.d.ts.map +1 -0
- package/dist/resend/webhooks.js +244 -0
- package/dist/resend/webhooks.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +585 -16
- package/dist/server.js.map +1 -1
- package/dist/settings.d.ts +14 -1
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +35 -1
- package/dist/settings.js.map +1 -1
- package/dist/updater.d.ts +32 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +145 -0
- package/dist/updater.js.map +1 -0
- package/dist/vault/policy.d.ts +42 -0
- package/dist/vault/policy.d.ts.map +1 -0
- package/dist/vault/policy.js +159 -0
- package/dist/vault/policy.js.map +1 -0
- package/dist/vault/store.d.ts +6 -0
- package/dist/vault/store.d.ts.map +1 -1
- package/dist/vault/store.js +15 -5
- package/dist/vault/store.js.map +1 -1
- package/dist/vault/transfer.d.ts +33 -0
- package/dist/vault/transfer.d.ts.map +1 -0
- package/dist/vault/transfer.js +187 -0
- package/dist/vault/transfer.js.map +1 -0
- package/dist/voucher.d.ts +39 -0
- package/dist/voucher.d.ts.map +1 -0
- package/dist/voucher.js +105 -0
- package/dist/voucher.js.map +1 -0
- package/dist/webhooks/handlers.d.ts +10 -0
- package/dist/webhooks/handlers.d.ts.map +1 -1
- package/dist/webhooks/handlers.js +53 -0
- package/dist/webhooks/handlers.js.map +1 -1
- package/dist/webhooks/index.d.ts +2 -2
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +2 -2
- package/dist/webhooks/index.js.map +1 -1
- package/dist/webhooks/verify.d.ts +8 -0
- package/dist/webhooks/verify.d.ts.map +1 -1
- package/dist/webhooks/verify.js +56 -0
- package/dist/webhooks/verify.js.map +1 -1
- package/package.json +8 -2
- package/public/board.html +8 -3
- package/public/browser.html +8 -3
- package/public/library.html +8 -3
- package/public/observatory.html +8 -3
- package/public/ops.html +8 -3
- package/public/registry.html +627 -0
- package/public/roadmap.html +975 -0
package/dist/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const PKG_ROOT = join(__dirname, "..");
|
|
|
16
16
|
import { writeFileSync } from "node:fs";
|
|
17
17
|
import { initInstanceName, getInstanceName, getInstanceNameLower, resolveEnv, getAlertEmailFrom } from "./instance.js";
|
|
18
18
|
import { readBrainFile, writeBrainFile, appendBrainLine } from "./lib/brain-io.js";
|
|
19
|
+
import { runWithAuditContext } from "./lib/audit.js";
|
|
19
20
|
import { Brain } from "./brain.js";
|
|
20
21
|
import { FileSystemLongTermMemory } from "./memory/file-backed.js";
|
|
21
22
|
import { createLogger } from "./utils/logger.js";
|
|
@@ -25,7 +26,13 @@ import { ensurePairingCode, getStatus, pair, authenticate, getRecoveryQuestion,
|
|
|
25
26
|
import { getProvider } from "./llm/providers/index.js";
|
|
26
27
|
import { withStreamRetry } from "./llm/retry.js";
|
|
27
28
|
import { LLMError } from "./llm/errors.js";
|
|
28
|
-
import {
|
|
29
|
+
import { PrivateModeError, isPrivateMode, checkOllamaHealth } from "./llm/guard.js";
|
|
30
|
+
import { installFetchGuard } from "./llm/fetch-guard.js";
|
|
31
|
+
import { SensitiveRegistry } from "./llm/sensitive-registry.js";
|
|
32
|
+
import { PrivacyMembrane } from "./llm/membrane.js";
|
|
33
|
+
import { setActiveMembrane, rehydrateResponse } from "./llm/redact.js";
|
|
34
|
+
import { loadSettings, getSettings, updateSettings, resolveProvider, resolveChatModel, resolveUtilityModel, getPulseSettings, getMeshConfig, } from "./settings.js";
|
|
35
|
+
import { startMdns, stopMdns } from "./mdns.js";
|
|
29
36
|
import { ingestDirectory } from "./files/ingest.js";
|
|
30
37
|
import { processIngestFolder } from "./files/ingest-folder.js";
|
|
31
38
|
import { saveSession, loadSession } from "./sessions/store.js";
|
|
@@ -42,7 +49,9 @@ import { isSttAvailable, transcribe } from "./stt/client.js";
|
|
|
42
49
|
import { startAvatarSidecar, stopAvatarSidecar, isAvatarAvailable } from "./avatar/sidecar.js";
|
|
43
50
|
import { preparePhoto, generateVideo, getCachedVideo, cacheVideo, clearVideoCache } from "./avatar/client.js";
|
|
44
51
|
import { getTtsConfig, getSttConfig, getAvatarConfig } from "./settings.js";
|
|
45
|
-
import { loadVault, listVaultKeys, setVaultKey, deleteVaultKey, getDashReadableVault, getVaultEntries } from "./vault/store.js";
|
|
52
|
+
import { loadVault, listVaultKeys, setVaultKey, deleteVaultKey, getDashReadableVault, getVaultEntries, hydrateEnv as rehydrateVaultEnv } from "./vault/store.js";
|
|
53
|
+
import { exportVault, importVault, verifyExport } from "./vault/transfer.js";
|
|
54
|
+
import { getIntegrationStatus, isIntegrationEnabled } from "./integrations/gate.js";
|
|
46
55
|
import { makeCall } from "./twilio/call.js";
|
|
47
56
|
import { isGoogleConfigured, isGoogleAuthenticated, getAuthUrl, exchangeCode, } from "./google/auth.js";
|
|
48
57
|
import { isCalendarAvailable, getTodaySchedule, getUpcomingEvents, listEvents, searchEvents, getFreeBusy, createEvent, updateEvent, deleteEvent } from "./google/calendar.js";
|
|
@@ -100,6 +109,7 @@ import { listChannels, getChannelInfo, joinChannel, getChannelHistory } from "./
|
|
|
100
109
|
// Slack types no longer needed — providers handle their own type mapping
|
|
101
110
|
import { getClient as getWhatsAppClient, isWhatsAppConfigured } from "./channels/whatsapp.js";
|
|
102
111
|
import { parseFormBody, processIncomingMessage, emptyTwimlResponse, replyTwiml, twilioProvider } from "./webhooks/twilio.js";
|
|
112
|
+
import { resendProvider } from "./resend/webhooks.js";
|
|
103
113
|
import { handleWhatsAppMessage } from "./services/whatsapp.js";
|
|
104
114
|
import { initLLMCache, shutdownLLMCache, getCacheDiagnostics } from "./cache/llm-cache.js";
|
|
105
115
|
import { mountWebhookAdmin, createWebhookRoute, verifyWebhookRequest } from "./webhooks/mount.js";
|
|
@@ -122,7 +132,8 @@ function pickStreamFn() {
|
|
|
122
132
|
return withStreamRetry((options) => provider.streamChat(options), { maxRetries: 3, baseDelayMs: 1_000, maxDelayMs: 30_000 });
|
|
123
133
|
}
|
|
124
134
|
// --- Config ---
|
|
125
|
-
const PORT = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "
|
|
135
|
+
const PORT = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "0", 10);
|
|
136
|
+
let actualPort = PORT;
|
|
126
137
|
const SIDECAR_PORT = resolveEnv("SEARCH_PORT") ?? "3578";
|
|
127
138
|
const BRAIN_DIR = join(process.cwd(), "brain");
|
|
128
139
|
const SKILLS_DIR = join(process.cwd(), "skills");
|
|
@@ -177,7 +188,7 @@ async function getOrCreateChatSession(sessionId, name) {
|
|
|
177
188
|
// Read custom personality instructions (empty string if file doesn't exist)
|
|
178
189
|
let personality = "";
|
|
179
190
|
try {
|
|
180
|
-
personality = (await readBrainFile(PERSONALITY_PATH)).trim();
|
|
191
|
+
personality = (await runWithAuditContext({ caller: "http:init:personality", channel: "http" }, () => readBrainFile(PERSONALITY_PATH))).trim();
|
|
181
192
|
}
|
|
182
193
|
catch { }
|
|
183
194
|
// Fetch current projects for system prompt injection
|
|
@@ -362,6 +373,17 @@ app.get("/", async (c) => {
|
|
|
362
373
|
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "index.html"));
|
|
363
374
|
return c.html(html);
|
|
364
375
|
});
|
|
376
|
+
// --- Nerve endpoint (PWA) ---
|
|
377
|
+
app.get("/nerve", async (c) => {
|
|
378
|
+
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "nerve", "index.html"));
|
|
379
|
+
return c.html(html);
|
|
380
|
+
});
|
|
381
|
+
// --- Audit context middleware ---
|
|
382
|
+
// Tags all brain file reads within HTTP handlers with the route info.
|
|
383
|
+
app.use("/api/*", async (c, next) => {
|
|
384
|
+
const caller = `http:${c.req.method} ${c.req.path}`;
|
|
385
|
+
return runWithAuditContext({ caller, channel: "http" }, () => next());
|
|
386
|
+
});
|
|
365
387
|
// --- Tracing middleware ---
|
|
366
388
|
app.use("/api/*", tracingMiddleware());
|
|
367
389
|
// --- Metrics middleware ---
|
|
@@ -389,7 +411,7 @@ const webhookInitStart = performance.now();
|
|
|
389
411
|
// Phase 1: Batch-register all webhook providers (deferred from module imports to avoid
|
|
390
412
|
// 5 individual logActivity calls during startup — now a single batch call).
|
|
391
413
|
const registerStart = performance.now();
|
|
392
|
-
registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider]);
|
|
414
|
+
registerProviders([githubProvider, slackEventsProvider, slackCommandsProvider, slackInteractionsProvider, twilioProvider, resendProvider]);
|
|
393
415
|
const registerMs = performance.now() - registerStart;
|
|
394
416
|
// Phase 2: Configure webhook providers (secrets resolved from env vars)
|
|
395
417
|
const configStart = performance.now();
|
|
@@ -399,6 +421,7 @@ setProviderConfigs([
|
|
|
399
421
|
{ name: "slack-interactions", secret: "SLACK_SIGNING_SECRET", signatureHeader: "x-slack-signature", algorithm: "slack-v0", path: "/api/slack/interactions" },
|
|
400
422
|
{ name: "twilio", secret: "TWILIO_AUTH_TOKEN", signatureHeader: "x-twilio-signature", algorithm: "twilio", path: "/api/twilio/whatsapp" },
|
|
401
423
|
{ name: "github", secret: "GITHUB_WEBHOOK_SECRET", signatureHeader: "x-hub-signature-256", algorithm: "hmac-sha256-hex", path: "/api/github/webhooks" },
|
|
424
|
+
{ name: "resend", secret: "RESEND_WEBHOOK_SECRET", signatureHeader: "svix-signature", algorithm: "custom", path: "/api/resend/webhooks" },
|
|
402
425
|
]);
|
|
403
426
|
const configMs = performance.now() - configStart;
|
|
404
427
|
// Phase 3: Mount admin routes
|
|
@@ -422,6 +445,7 @@ app.get("/api/status", async (c) => {
|
|
|
422
445
|
...status,
|
|
423
446
|
provider: resolveProvider(),
|
|
424
447
|
airplaneMode: settings.airplaneMode,
|
|
448
|
+
privateMode: settings.privateMode,
|
|
425
449
|
safeWordMode: settings.safeWordMode,
|
|
426
450
|
search: isSearchAvailable(),
|
|
427
451
|
tts: isTtsAvailable(),
|
|
@@ -579,6 +603,70 @@ app.delete("/api/vault/:name", async (c) => {
|
|
|
579
603
|
await deleteVaultKey(name, key);
|
|
580
604
|
return c.json({ ok: true });
|
|
581
605
|
});
|
|
606
|
+
// Export vault to portable passphrase-encrypted file
|
|
607
|
+
app.post("/api/vault/export", async (c) => {
|
|
608
|
+
const sessionId = c.req.query("sessionId");
|
|
609
|
+
if (!sessionId)
|
|
610
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
611
|
+
const session = validateSession(sessionId);
|
|
612
|
+
if (!session)
|
|
613
|
+
return c.json({ error: "Invalid or expired session" }, 401);
|
|
614
|
+
const body = await c.req.json();
|
|
615
|
+
if (!body.passphrase || body.passphrase.length < 8) {
|
|
616
|
+
return c.json({ error: "Passphrase required (min 8 characters)" }, 400);
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const result = await exportVault(body.passphrase);
|
|
620
|
+
return c.json({ ok: true, filePath: result.filePath, stats: result.stats });
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
return c.json({ error: e.message }, 500);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
// Import vault from portable passphrase-encrypted file
|
|
627
|
+
app.post("/api/vault/import", async (c) => {
|
|
628
|
+
const sessionId = c.req.query("sessionId");
|
|
629
|
+
if (!sessionId)
|
|
630
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
631
|
+
const session = validateSession(sessionId);
|
|
632
|
+
if (!session)
|
|
633
|
+
return c.json({ error: "Invalid or expired session" }, 401);
|
|
634
|
+
const key = sessionKeys.get(sessionId);
|
|
635
|
+
if (!key)
|
|
636
|
+
return c.json({ error: "Session key not found" }, 401);
|
|
637
|
+
const body = await c.req.json();
|
|
638
|
+
if (!body.filePath || !body.passphrase) {
|
|
639
|
+
return c.json({ error: "filePath and passphrase required" }, 400);
|
|
640
|
+
}
|
|
641
|
+
const strategy = body.strategy ?? "skip";
|
|
642
|
+
try {
|
|
643
|
+
const result = await importVault(body.filePath, body.passphrase, strategy, key);
|
|
644
|
+
return c.json({ ok: true, stats: result.stats });
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
return c.json({ error: e.message }, 400);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
// Verify a vault export file without importing
|
|
651
|
+
app.post("/api/vault/verify-export", async (c) => {
|
|
652
|
+
const sessionId = c.req.query("sessionId");
|
|
653
|
+
if (!sessionId)
|
|
654
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
655
|
+
const session = validateSession(sessionId);
|
|
656
|
+
if (!session)
|
|
657
|
+
return c.json({ error: "Invalid or expired session" }, 401);
|
|
658
|
+
const body = await c.req.json();
|
|
659
|
+
if (!body.filePath || !body.passphrase) {
|
|
660
|
+
return c.json({ error: "filePath and passphrase required" }, 400);
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const result = await verifyExport(body.filePath, body.passphrase);
|
|
664
|
+
return c.json({ ok: true, message: result.message, stats: result.stats });
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
return c.json({ error: e.message }, 400);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
582
670
|
// --- Google OAuth2 routes ---
|
|
583
671
|
// Initiate Google OAuth flow — redirects to Google consent screen
|
|
584
672
|
// No session required: this just redirects to Google, no sensitive data returned
|
|
@@ -1178,6 +1266,43 @@ app.put("/api/settings", async (c) => {
|
|
|
1178
1266
|
},
|
|
1179
1267
|
});
|
|
1180
1268
|
});
|
|
1269
|
+
// --- Integration admin routes ---
|
|
1270
|
+
app.get("/api/admin/integrations", async (c) => {
|
|
1271
|
+
const settings = getSettings();
|
|
1272
|
+
const integrations = settings.integrations ?? { enabled: true };
|
|
1273
|
+
const status = getIntegrationStatus();
|
|
1274
|
+
return c.json({
|
|
1275
|
+
enabled: integrations.enabled ?? true,
|
|
1276
|
+
services: status,
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
app.post("/api/admin/integrations", async (c) => {
|
|
1280
|
+
const body = await c.req.json();
|
|
1281
|
+
const patch = {
|
|
1282
|
+
integrations: {},
|
|
1283
|
+
};
|
|
1284
|
+
if (typeof body.enabled === "boolean") {
|
|
1285
|
+
patch.integrations.enabled = body.enabled;
|
|
1286
|
+
}
|
|
1287
|
+
if (body.services && typeof body.services === "object") {
|
|
1288
|
+
patch.integrations.services = body.services;
|
|
1289
|
+
}
|
|
1290
|
+
const updated = await updateSettings(patch);
|
|
1291
|
+
// Re-hydrate env with new gates — clear integration vars first, then re-hydrate
|
|
1292
|
+
const status = getIntegrationStatus();
|
|
1293
|
+
rehydrateVaultEnv();
|
|
1294
|
+
const credStore = getCredentialStore();
|
|
1295
|
+
if (credStore)
|
|
1296
|
+
await credStore.hydrate();
|
|
1297
|
+
return c.json({
|
|
1298
|
+
enabled: updated.integrations?.enabled ?? true,
|
|
1299
|
+
services: status.map((s) => ({
|
|
1300
|
+
...s,
|
|
1301
|
+
// Re-check after settings update
|
|
1302
|
+
enabled: isIntegrationEnabled(s.service),
|
|
1303
|
+
})),
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1181
1306
|
// --- Voice routes ---
|
|
1182
1307
|
// Voice status: which voice features are available?
|
|
1183
1308
|
app.get("/api/voice-status", async (c) => {
|
|
@@ -1386,7 +1511,16 @@ app.post("/api/branch", async (c) => {
|
|
|
1386
1511
|
];
|
|
1387
1512
|
const activeProvider = resolveProvider();
|
|
1388
1513
|
const activeChatModel = resolveChatModel();
|
|
1389
|
-
|
|
1514
|
+
let stream_fn;
|
|
1515
|
+
try {
|
|
1516
|
+
stream_fn = pickStreamFn();
|
|
1517
|
+
}
|
|
1518
|
+
catch (err) {
|
|
1519
|
+
if (err instanceof PrivateModeError) {
|
|
1520
|
+
return c.json({ error: err.message }, 503);
|
|
1521
|
+
}
|
|
1522
|
+
throw err;
|
|
1523
|
+
}
|
|
1390
1524
|
const reqSignal = c.req.raw.signal;
|
|
1391
1525
|
return streamSSE(c, async (stream) => {
|
|
1392
1526
|
// Send branch trace metadata so the UI can track lineage
|
|
@@ -1407,21 +1541,42 @@ app.post("/api/branch", async (c) => {
|
|
|
1407
1541
|
}
|
|
1408
1542
|
const onAbort = () => resolve();
|
|
1409
1543
|
reqSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1544
|
+
// Token buffer for split-placeholder rehydration
|
|
1545
|
+
let tokenBuf = "";
|
|
1546
|
+
const flushBuf = () => {
|
|
1547
|
+
if (!tokenBuf)
|
|
1548
|
+
return;
|
|
1549
|
+
const rehydrated = rehydrateResponse(tokenBuf);
|
|
1550
|
+
tokenBuf = "";
|
|
1551
|
+
stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
|
|
1552
|
+
};
|
|
1410
1553
|
stream_fn({
|
|
1411
1554
|
messages,
|
|
1412
1555
|
model: activeChatModel,
|
|
1413
1556
|
signal: reqSignal,
|
|
1414
1557
|
onToken: (token) => {
|
|
1415
|
-
|
|
1558
|
+
tokenBuf += token;
|
|
1559
|
+
// Hold if buffer ends with partial placeholder: << ... (no closing >>)
|
|
1560
|
+
const lastOpen = tokenBuf.lastIndexOf("<<");
|
|
1561
|
+
if (lastOpen !== -1 && tokenBuf.indexOf(">>", lastOpen) === -1)
|
|
1562
|
+
return;
|
|
1563
|
+
flushBuf();
|
|
1416
1564
|
},
|
|
1417
1565
|
onDone: () => {
|
|
1566
|
+
flushBuf(); // flush remainder
|
|
1418
1567
|
reqSignal?.removeEventListener("abort", onAbort);
|
|
1419
1568
|
stream.writeSSE({ data: JSON.stringify({ done: true }) }).catch(() => { });
|
|
1420
1569
|
resolve();
|
|
1421
1570
|
},
|
|
1422
|
-
onError: (err) => {
|
|
1571
|
+
onError: async (err) => {
|
|
1572
|
+
flushBuf();
|
|
1423
1573
|
reqSignal?.removeEventListener("abort", onAbort);
|
|
1424
|
-
|
|
1574
|
+
let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
|
|
1575
|
+
if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
|
|
1576
|
+
const health = await checkOllamaHealth();
|
|
1577
|
+
if (!health.ok)
|
|
1578
|
+
errorMsg += " — Check that Ollama is running. " + health.message;
|
|
1579
|
+
}
|
|
1425
1580
|
stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
|
|
1426
1581
|
resolve();
|
|
1427
1582
|
},
|
|
@@ -2076,6 +2231,22 @@ app.post("/api/slack/commands", createWebhookRoute({ provider: "slack-commands"
|
|
|
2076
2231
|
// Slack interactions: routed through the generic webhook system.
|
|
2077
2232
|
// The slack-interactions provider handles extracting JSON from the form "payload" field.
|
|
2078
2233
|
app.post("/api/slack/interactions", createWebhookRoute({ provider: "slack-interactions" }));
|
|
2234
|
+
// --- Resend inbound email ---
|
|
2235
|
+
// Resend webhook: receive inbound emails via Svix-signed webhooks (direct path).
|
|
2236
|
+
// Uses generic webhook route with Svix signature verification.
|
|
2237
|
+
app.post("/api/resend/webhooks", createWebhookRoute({ provider: "resend" }));
|
|
2238
|
+
// Resend inbox: manually trigger inbox check (pulls from Worker KV).
|
|
2239
|
+
app.post("/api/resend/check-inbox", async (c) => {
|
|
2240
|
+
const sessionId = c.req.query("sessionId");
|
|
2241
|
+
if (!sessionId)
|
|
2242
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
2243
|
+
const session = validateSession(sessionId);
|
|
2244
|
+
if (!session)
|
|
2245
|
+
return c.json({ error: "Invalid or expired session" }, 401);
|
|
2246
|
+
const { forceCheckResendInbox } = await import("./resend/inbox.js");
|
|
2247
|
+
const count = await forceCheckResendInbox();
|
|
2248
|
+
return c.json({ ok: true, processed: count });
|
|
2249
|
+
});
|
|
2079
2250
|
// --- WhatsApp routes (Twilio-backed) ---
|
|
2080
2251
|
// WhatsApp status: check if configured
|
|
2081
2252
|
app.get("/api/whatsapp/status", (c) => {
|
|
@@ -2147,6 +2318,59 @@ app.post("/api/relay/whatsapp", async (c) => {
|
|
|
2147
2318
|
const result = await handleWhatsAppMessage(from, body, payload.ProfileName);
|
|
2148
2319
|
return c.json({ ok: result.ok, reply: result.reply, error: result.error });
|
|
2149
2320
|
});
|
|
2321
|
+
// Resend relay: receive pre-verified inbound emails from the Cloudflare Worker.
|
|
2322
|
+
// The Worker has already verified Resend's Svix signature and fetched the full
|
|
2323
|
+
// email body — this endpoint verifies the relay's own HMAC-SHA256 signature.
|
|
2324
|
+
app.post("/api/relay/resend", async (c) => {
|
|
2325
|
+
const rawBody = await c.req.text();
|
|
2326
|
+
const headers = {};
|
|
2327
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
2328
|
+
headers[key.toLowerCase()] = value;
|
|
2329
|
+
});
|
|
2330
|
+
const relaySecret = process.env.RELAY_SECRET ?? "";
|
|
2331
|
+
const verification = verifyRelaySignature(rawBody, headers, relaySecret);
|
|
2332
|
+
if (!verification.valid) {
|
|
2333
|
+
return c.json({ error: verification.error ?? "Invalid relay signature" }, 401);
|
|
2334
|
+
}
|
|
2335
|
+
let payload;
|
|
2336
|
+
try {
|
|
2337
|
+
payload = JSON.parse(rawBody);
|
|
2338
|
+
}
|
|
2339
|
+
catch {
|
|
2340
|
+
return c.json({ error: "Invalid JSON" }, 400);
|
|
2341
|
+
}
|
|
2342
|
+
if (payload.type !== "email.received" || !payload.body?.trim()) {
|
|
2343
|
+
return c.json({ ok: true, message: "No actionable content" });
|
|
2344
|
+
}
|
|
2345
|
+
logActivity({
|
|
2346
|
+
source: "resend",
|
|
2347
|
+
summary: `Inbound email relayed from ${payload.from}: "${payload.subject}"`,
|
|
2348
|
+
});
|
|
2349
|
+
// Import processInboundEmail dynamically to avoid circular deps at module level
|
|
2350
|
+
const { processInboundEmail, sendResendReply } = await import("./resend/webhooks.js");
|
|
2351
|
+
const reply = await processInboundEmail({
|
|
2352
|
+
from: payload.from,
|
|
2353
|
+
subject: payload.subject,
|
|
2354
|
+
body: payload.body,
|
|
2355
|
+
date: payload.created_at || new Date().toISOString(),
|
|
2356
|
+
});
|
|
2357
|
+
if (!reply) {
|
|
2358
|
+
return c.json({ ok: true, message: "No reply generated" });
|
|
2359
|
+
}
|
|
2360
|
+
const sent = await sendResendReply({
|
|
2361
|
+
to: payload.from,
|
|
2362
|
+
subject: payload.subject,
|
|
2363
|
+
body: reply,
|
|
2364
|
+
inReplyTo: payload.message_id,
|
|
2365
|
+
});
|
|
2366
|
+
logActivity({
|
|
2367
|
+
source: "resend",
|
|
2368
|
+
summary: sent
|
|
2369
|
+
? `Replied to ${payload.from}: "${payload.subject}" (${reply.length} chars)`
|
|
2370
|
+
: `Failed to reply to ${payload.from}: "${payload.subject}"`,
|
|
2371
|
+
});
|
|
2372
|
+
return c.json({ ok: true, sent, replyLength: reply.length });
|
|
2373
|
+
});
|
|
2150
2374
|
// WhatsApp send: send a message (requires sessionId)
|
|
2151
2375
|
app.post("/api/whatsapp/send", async (c) => {
|
|
2152
2376
|
const sessionId = c.req.query("sessionId");
|
|
@@ -3046,6 +3270,92 @@ app.get("/browser", async (c) => {
|
|
|
3046
3270
|
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "browser.html"));
|
|
3047
3271
|
return c.html(html);
|
|
3048
3272
|
});
|
|
3273
|
+
// Serve registry.html (service & capability dashboard)
|
|
3274
|
+
app.get("/registry", async (c) => {
|
|
3275
|
+
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "registry.html"));
|
|
3276
|
+
return c.html(html);
|
|
3277
|
+
});
|
|
3278
|
+
// Serve roadmap.html (strategic roadmap & rearview)
|
|
3279
|
+
app.get("/roadmap", async (c) => {
|
|
3280
|
+
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "roadmap.html"));
|
|
3281
|
+
return c.html(html);
|
|
3282
|
+
});
|
|
3283
|
+
// Roadmap API — parse brain/operations/roadmap.yaml and return as JSON
|
|
3284
|
+
app.get("/api/roadmap", async (c) => {
|
|
3285
|
+
try {
|
|
3286
|
+
const raw = await readBrainFile(join(process.cwd(), "brain", "operations", "roadmap.yaml"));
|
|
3287
|
+
const parsed = parseRoadmapYaml(raw);
|
|
3288
|
+
return c.json(parsed);
|
|
3289
|
+
}
|
|
3290
|
+
catch (err) {
|
|
3291
|
+
return c.json({ error: "Failed to load roadmap.yaml" }, 500);
|
|
3292
|
+
}
|
|
3293
|
+
});
|
|
3294
|
+
// Roadmap rearview API — recent git commits grouped by hour
|
|
3295
|
+
app.get("/api/roadmap/recent", async (c) => {
|
|
3296
|
+
const hours = parseInt(c.req.query("hours") || "24", 10);
|
|
3297
|
+
if (isNaN(hours) || hours < 1 || hours > 168) {
|
|
3298
|
+
return c.json({ error: "hours must be between 1 and 168" }, 400);
|
|
3299
|
+
}
|
|
3300
|
+
try {
|
|
3301
|
+
const { execSync } = await import("child_process");
|
|
3302
|
+
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
|
3303
|
+
const raw = execSync(`git log --after="${since}" --format="%H||%an||%ai||%s" --no-merges`, { cwd: process.cwd(), encoding: "utf-8", timeout: 10000 }).trim();
|
|
3304
|
+
if (!raw)
|
|
3305
|
+
return c.json({ commits: [], groups: [], hours, total: 0 });
|
|
3306
|
+
const commits = raw.split("\n").map((line) => {
|
|
3307
|
+
const [hash, author, date, ...msgParts] = line.split("||");
|
|
3308
|
+
const message = msgParts.join("||");
|
|
3309
|
+
const isAuto = /^\[(?:dash|agent)\]/i.test(message);
|
|
3310
|
+
return {
|
|
3311
|
+
hash: hash.slice(0, 8),
|
|
3312
|
+
fullHash: hash,
|
|
3313
|
+
author,
|
|
3314
|
+
date,
|
|
3315
|
+
message,
|
|
3316
|
+
autonomous: isAuto,
|
|
3317
|
+
tag: isAuto ? "dash" : "human",
|
|
3318
|
+
};
|
|
3319
|
+
});
|
|
3320
|
+
// Group by hour bucket
|
|
3321
|
+
const groups = {};
|
|
3322
|
+
for (const commit of commits) {
|
|
3323
|
+
const d = new Date(commit.date);
|
|
3324
|
+
const hourKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:00`;
|
|
3325
|
+
if (!groups[hourKey])
|
|
3326
|
+
groups[hourKey] = [];
|
|
3327
|
+
groups[hourKey].push(commit);
|
|
3328
|
+
}
|
|
3329
|
+
const grouped = Object.entries(groups)
|
|
3330
|
+
.map(([hour, items]) => ({ hour, commits: items, count: items.length }))
|
|
3331
|
+
.sort((a, b) => b.hour.localeCompare(a.hour));
|
|
3332
|
+
return c.json({ commits, groups: grouped, hours, total: commits.length });
|
|
3333
|
+
}
|
|
3334
|
+
catch (err) {
|
|
3335
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3336
|
+
return c.json({ error: "Failed to read git log: " + msg }, 500);
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
// Serve personal.html (placeholder — instances populate this)
|
|
3340
|
+
app.get("/personal", async (c) => {
|
|
3341
|
+
try {
|
|
3342
|
+
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "personal.html"));
|
|
3343
|
+
return c.html(html);
|
|
3344
|
+
}
|
|
3345
|
+
catch {
|
|
3346
|
+
return c.text("Personal page not configured for this instance.", 404);
|
|
3347
|
+
}
|
|
3348
|
+
});
|
|
3349
|
+
// Serve life.html (placeholder — instances populate this)
|
|
3350
|
+
app.get("/life", async (c) => {
|
|
3351
|
+
try {
|
|
3352
|
+
const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "life.html"));
|
|
3353
|
+
return c.html(html);
|
|
3354
|
+
}
|
|
3355
|
+
catch {
|
|
3356
|
+
return c.text("Life page not configured for this instance.", 404);
|
|
3357
|
+
}
|
|
3358
|
+
});
|
|
3049
3359
|
// Browse API — fetch a URL and return what the agent sees (stripped text)
|
|
3050
3360
|
app.get("/api/browse", async (c) => {
|
|
3051
3361
|
const url = c.req.query("url");
|
|
@@ -3159,6 +3469,7 @@ app.get("/api/ops/health", async (c) => {
|
|
|
3159
3469
|
},
|
|
3160
3470
|
provider: resolveProvider(),
|
|
3161
3471
|
airplaneMode: settings.airplaneMode,
|
|
3472
|
+
privateMode: settings.privateMode,
|
|
3162
3473
|
models: settings.models,
|
|
3163
3474
|
sidecars: {
|
|
3164
3475
|
search: isSidecarAvailable(),
|
|
@@ -3393,6 +3704,83 @@ app.get("/api/pulse/history", (c) => {
|
|
|
3393
3704
|
}
|
|
3394
3705
|
return c.json(integrator.getVoltageHistory());
|
|
3395
3706
|
});
|
|
3707
|
+
// --- Nerve API (three-dot goo) ---
|
|
3708
|
+
import { getNerveState } from "./nerve/state.js";
|
|
3709
|
+
import { initPush, getVapidPublicKey, addSubscription, checkAndNotify, startPushMonitor, stopPushMonitor } from "./nerve/push.js";
|
|
3710
|
+
// State endpoint — three dots
|
|
3711
|
+
app.get("/api/nerve/state", async (c) => {
|
|
3712
|
+
const state = await getNerveState();
|
|
3713
|
+
return c.json(state);
|
|
3714
|
+
});
|
|
3715
|
+
// VAPID public key for push subscription
|
|
3716
|
+
app.get("/api/nerve/vapid-key", (c) => {
|
|
3717
|
+
try {
|
|
3718
|
+
return c.json({ key: getVapidPublicKey() });
|
|
3719
|
+
}
|
|
3720
|
+
catch {
|
|
3721
|
+
return c.json({ error: "Push not initialized" }, 503);
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3724
|
+
// Store push subscription from a nerve endpoint
|
|
3725
|
+
app.post("/api/nerve/subscribe", async (c) => {
|
|
3726
|
+
const body = await c.req.json();
|
|
3727
|
+
const { subscription, label } = body;
|
|
3728
|
+
if (!subscription?.endpoint || !subscription?.keys) {
|
|
3729
|
+
return c.json({ error: "Invalid subscription" }, 400);
|
|
3730
|
+
}
|
|
3731
|
+
const id = await addSubscription(subscription, label);
|
|
3732
|
+
return c.json({ id });
|
|
3733
|
+
});
|
|
3734
|
+
// SSE stream — real-time nerve state updates
|
|
3735
|
+
app.get("/api/nerve/stream", async (c) => {
|
|
3736
|
+
return streamSSE(c, async (stream) => {
|
|
3737
|
+
// Send initial state
|
|
3738
|
+
const initial = await getNerveState();
|
|
3739
|
+
await stream.writeSSE({ event: "state", data: JSON.stringify(initial) });
|
|
3740
|
+
// Poll every 5 seconds and send updates
|
|
3741
|
+
let lastJson = JSON.stringify(initial);
|
|
3742
|
+
const interval = setInterval(async () => {
|
|
3743
|
+
try {
|
|
3744
|
+
const state = await getNerveState();
|
|
3745
|
+
const json = JSON.stringify(state);
|
|
3746
|
+
if (json !== lastJson) {
|
|
3747
|
+
lastJson = json;
|
|
3748
|
+
await stream.writeSSE({ event: "state", data: json });
|
|
3749
|
+
// Check if push notifications should fire
|
|
3750
|
+
await checkAndNotify(state).catch(() => { });
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
catch { /* stream may be closed */ }
|
|
3754
|
+
}, 5000);
|
|
3755
|
+
// Keep alive
|
|
3756
|
+
const keepAlive = setInterval(async () => {
|
|
3757
|
+
try {
|
|
3758
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
3759
|
+
}
|
|
3760
|
+
catch { /* ok */ }
|
|
3761
|
+
}, 30000);
|
|
3762
|
+
stream.onAbort(() => {
|
|
3763
|
+
clearInterval(interval);
|
|
3764
|
+
clearInterval(keepAlive);
|
|
3765
|
+
});
|
|
3766
|
+
// Hold the stream open
|
|
3767
|
+
await new Promise(() => { });
|
|
3768
|
+
});
|
|
3769
|
+
});
|
|
3770
|
+
// Update: accept a pending major update
|
|
3771
|
+
app.post("/api/nerve/accept-update", async (c) => {
|
|
3772
|
+
try {
|
|
3773
|
+
const { acceptMajorUpdate } = await import("./updater.js");
|
|
3774
|
+
const { clearPendingUpdate } = await import("./nerve/state.js");
|
|
3775
|
+
clearPendingUpdate();
|
|
3776
|
+
// This will restart the process after updating
|
|
3777
|
+
await acceptMajorUpdate();
|
|
3778
|
+
return c.json({ ok: true, message: "Updating and restarting..." });
|
|
3779
|
+
}
|
|
3780
|
+
catch (err) {
|
|
3781
|
+
return c.json({ error: err.message }, 500);
|
|
3782
|
+
}
|
|
3783
|
+
});
|
|
3396
3784
|
// Chat: streamed response (or learn command)
|
|
3397
3785
|
app.post("/api/chat", async (c) => {
|
|
3398
3786
|
const body = await c.req.json();
|
|
@@ -4091,7 +4479,16 @@ app.post("/api/chat", async (c) => {
|
|
|
4091
4479
|
ctx.messages[lastIdx] = { role: "user", content: userContent };
|
|
4092
4480
|
}
|
|
4093
4481
|
}
|
|
4094
|
-
|
|
4482
|
+
let stream_fn;
|
|
4483
|
+
try {
|
|
4484
|
+
stream_fn = pickStreamFn();
|
|
4485
|
+
}
|
|
4486
|
+
catch (err) {
|
|
4487
|
+
if (err instanceof PrivateModeError) {
|
|
4488
|
+
return c.json({ error: err.message }, 503);
|
|
4489
|
+
}
|
|
4490
|
+
throw err;
|
|
4491
|
+
}
|
|
4095
4492
|
const activeProvider = resolveProvider();
|
|
4096
4493
|
const activeChatModel = resolveChatModel();
|
|
4097
4494
|
const reqSignal = c.req.raw.signal;
|
|
@@ -4118,15 +4515,30 @@ app.post("/api/chat", async (c) => {
|
|
|
4118
4515
|
resolve();
|
|
4119
4516
|
};
|
|
4120
4517
|
reqSignal?.addEventListener("abort", onAbort, { once: true });
|
|
4518
|
+
// Token buffer for split-placeholder rehydration
|
|
4519
|
+
let tokenBuf2 = "";
|
|
4520
|
+
const flushBuf2 = () => {
|
|
4521
|
+
if (!tokenBuf2)
|
|
4522
|
+
return;
|
|
4523
|
+
const rehydrated = rehydrateResponse(tokenBuf2);
|
|
4524
|
+
tokenBuf2 = "";
|
|
4525
|
+
stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
|
|
4526
|
+
};
|
|
4121
4527
|
stream_fn({
|
|
4122
4528
|
messages: ctx.messages,
|
|
4123
4529
|
model: activeChatModel,
|
|
4124
4530
|
signal: reqSignal,
|
|
4125
4531
|
onToken: (token) => {
|
|
4126
4532
|
fullResponse += token;
|
|
4127
|
-
|
|
4533
|
+
tokenBuf2 += token;
|
|
4534
|
+
// Hold if buffer ends with partial placeholder
|
|
4535
|
+
const lastOpen = tokenBuf2.lastIndexOf("<<");
|
|
4536
|
+
if (lastOpen !== -1 && tokenBuf2.indexOf(">>", lastOpen) === -1)
|
|
4537
|
+
return;
|
|
4538
|
+
flushBuf2();
|
|
4128
4539
|
},
|
|
4129
4540
|
onDone: () => {
|
|
4541
|
+
flushBuf2(); // flush remainder
|
|
4130
4542
|
reqSignal?.removeEventListener("abort", onAbort);
|
|
4131
4543
|
// Process action blocks BEFORE sending done — ensures SSE events reach client before stream closes.
|
|
4132
4544
|
// ALWAYS strip [AGENT_REQUEST] blocks from response, even if they can't be parsed.
|
|
@@ -4347,14 +4759,20 @@ app.post("/api/chat", async (c) => {
|
|
|
4347
4759
|
resolve();
|
|
4348
4760
|
}
|
|
4349
4761
|
},
|
|
4350
|
-
onError: (err) => {
|
|
4762
|
+
onError: async (err) => {
|
|
4763
|
+
flushBuf2();
|
|
4351
4764
|
reqSignal?.removeEventListener("abort", onAbort);
|
|
4352
4765
|
// If this error is from an abort, save partial and exit quietly
|
|
4353
4766
|
if (reqSignal?.aborted) {
|
|
4354
4767
|
savePartial();
|
|
4355
4768
|
}
|
|
4356
4769
|
else {
|
|
4357
|
-
|
|
4770
|
+
let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
|
|
4771
|
+
if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
|
|
4772
|
+
const health = await checkOllamaHealth();
|
|
4773
|
+
if (!health.ok)
|
|
4774
|
+
errorMsg += " — Check that Ollama is running. " + health.message;
|
|
4775
|
+
}
|
|
4358
4776
|
stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
|
|
4359
4777
|
}
|
|
4360
4778
|
resolve(); // Still resolve so stream closes
|
|
@@ -4375,6 +4793,13 @@ async function start() {
|
|
|
4375
4793
|
});
|
|
4376
4794
|
// Load settings (airplane mode, model selection)
|
|
4377
4795
|
const settings = await loadSettings();
|
|
4796
|
+
// Install fetch guard before any routes or outbound calls
|
|
4797
|
+
installFetchGuard();
|
|
4798
|
+
// Initialize PrivacyMembrane for reversible redaction
|
|
4799
|
+
const sensitiveRegistry = new SensitiveRegistry();
|
|
4800
|
+
await sensitiveRegistry.load(BRAIN_DIR);
|
|
4801
|
+
const membrane = new PrivacyMembrane(sensitiveRegistry);
|
|
4802
|
+
setActiveMembrane(membrane);
|
|
4378
4803
|
// Run independent initialization in parallel: LLM cache, auth, and sidecars
|
|
4379
4804
|
const [, pairingCode, sidecarResults] = await Promise.all([
|
|
4380
4805
|
// LLM response cache (file-backed in .core/cache/, 1-hour TTL)
|
|
@@ -4491,6 +4916,17 @@ async function start() {
|
|
|
4491
4916
|
log.info(`Boot tension: ${todoCount} todo(s) found — pulse should fire`);
|
|
4492
4917
|
}
|
|
4493
4918
|
}
|
|
4919
|
+
// Initialize nerve push notifications (VAPID keys + subscriptions)
|
|
4920
|
+
try {
|
|
4921
|
+
await initPush();
|
|
4922
|
+
startPushMonitor(getNerveState, 30_000);
|
|
4923
|
+
log.info("Nerve push notifications ready (monitoring every 30s)");
|
|
4924
|
+
}
|
|
4925
|
+
catch (err) {
|
|
4926
|
+
log.warn("Nerve push init failed — push notifications disabled", {
|
|
4927
|
+
error: err instanceof Error ? err.message : String(err),
|
|
4928
|
+
});
|
|
4929
|
+
}
|
|
4494
4930
|
// Start weekly backlog review timer (runs every Friday — DASH-59)
|
|
4495
4931
|
startBacklogReviewTimer(queueProvider.getStore());
|
|
4496
4932
|
// Start daily morning briefing timer (DASH-44)
|
|
@@ -4601,7 +5037,21 @@ async function start() {
|
|
|
4601
5037
|
log.info(`${getInstanceName()} — Local Chat starting`);
|
|
4602
5038
|
// Show LLM provider based on settings
|
|
4603
5039
|
const provider = resolveProvider();
|
|
4604
|
-
if (settings.
|
|
5040
|
+
if (settings.privateMode) {
|
|
5041
|
+
log.info("Mode: PRIVATE (network-isolated), LLM: Ollama only — cloud providers blocked");
|
|
5042
|
+
// Fail loudly at startup if Ollama isn't available in private mode
|
|
5043
|
+
try {
|
|
5044
|
+
const { assertOllamaAvailable } = await import("./llm/guard.js");
|
|
5045
|
+
await assertOllamaAvailable();
|
|
5046
|
+
log.info("Ollama: reachable ✓");
|
|
5047
|
+
}
|
|
5048
|
+
catch (err) {
|
|
5049
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5050
|
+
log.error(msg);
|
|
5051
|
+
throw new Error("Cannot start in privateMode without Ollama. " + msg);
|
|
5052
|
+
}
|
|
5053
|
+
}
|
|
5054
|
+
else if (settings.airplaneMode) {
|
|
4605
5055
|
log.info("Mode: Airplane (local only), LLM: Ollama");
|
|
4606
5056
|
}
|
|
4607
5057
|
else {
|
|
@@ -4767,10 +5217,46 @@ async function start() {
|
|
|
4767
5217
|
return reply.trim();
|
|
4768
5218
|
});
|
|
4769
5219
|
log.info(`${getInstanceName()} email handler registered — emails with '${getInstanceName()}' in subject will be auto-replied`);
|
|
4770
|
-
|
|
4771
|
-
|
|
5220
|
+
await new Promise((resolve) => {
|
|
5221
|
+
const server = serve({ fetch: app.fetch, port: PORT }, () => {
|
|
5222
|
+
const addr = server.address();
|
|
5223
|
+
if (typeof addr === "object" && addr) {
|
|
5224
|
+
actualPort = addr.port;
|
|
5225
|
+
}
|
|
5226
|
+
log.info(`Listening on http://localhost:${actualPort}`);
|
|
5227
|
+
// Show LAN IP for phone access
|
|
5228
|
+
try {
|
|
5229
|
+
import("node:os").then(({ networkInterfaces }) => {
|
|
5230
|
+
const nets = networkInterfaces();
|
|
5231
|
+
for (const name of Object.keys(nets)) {
|
|
5232
|
+
for (const net of nets[name] ?? []) {
|
|
5233
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
5234
|
+
log.info(`Nerve (phone): http://${net.address}:${actualPort}/nerve`);
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
}
|
|
5238
|
+
});
|
|
5239
|
+
}
|
|
5240
|
+
catch { /* ok */ }
|
|
5241
|
+
// Announce on LAN if mesh.lanAnnounce is enabled
|
|
5242
|
+
if (getMeshConfig().lanAnnounce) {
|
|
5243
|
+
try {
|
|
5244
|
+
startMdns(actualPort);
|
|
5245
|
+
}
|
|
5246
|
+
catch (err) {
|
|
5247
|
+
log.warn("mDNS announcement failed — discovery disabled", {
|
|
5248
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5249
|
+
});
|
|
5250
|
+
}
|
|
5251
|
+
}
|
|
5252
|
+
resolve();
|
|
5253
|
+
});
|
|
4772
5254
|
});
|
|
4773
5255
|
}
|
|
5256
|
+
/** Returns the port the server is actually listening on (resolves port 0). */
|
|
5257
|
+
export function getActualPort() {
|
|
5258
|
+
return actualPort;
|
|
5259
|
+
}
|
|
4774
5260
|
export { start };
|
|
4775
5261
|
const isDirectRun = process.argv[1]?.replace(/\\/g, "/").includes("server");
|
|
4776
5262
|
if (isDirectRun) {
|
|
@@ -4779,6 +5265,87 @@ if (isDirectRun) {
|
|
|
4779
5265
|
process.exit(1);
|
|
4780
5266
|
});
|
|
4781
5267
|
}
|
|
5268
|
+
function parseRoadmapYaml(raw) {
|
|
5269
|
+
const result = { phases: [], streams: [], milestones: [] };
|
|
5270
|
+
const lines = raw.split("\n");
|
|
5271
|
+
let section = null;
|
|
5272
|
+
let currentObj = null;
|
|
5273
|
+
function flush() {
|
|
5274
|
+
if (currentObj && section && section !== "layout") {
|
|
5275
|
+
result[section].push(currentObj);
|
|
5276
|
+
currentObj = null;
|
|
5277
|
+
}
|
|
5278
|
+
}
|
|
5279
|
+
for (const line of lines) {
|
|
5280
|
+
const trimmed = line.trimEnd();
|
|
5281
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
5282
|
+
continue;
|
|
5283
|
+
// Top-level section headers
|
|
5284
|
+
if (/^layout:\s*$/.test(trimmed)) {
|
|
5285
|
+
flush();
|
|
5286
|
+
section = "layout";
|
|
5287
|
+
result.layout = {};
|
|
5288
|
+
continue;
|
|
5289
|
+
}
|
|
5290
|
+
if (/^phases:\s*$/.test(trimmed)) {
|
|
5291
|
+
flush();
|
|
5292
|
+
section = "phases";
|
|
5293
|
+
continue;
|
|
5294
|
+
}
|
|
5295
|
+
if (/^streams:\s*$/.test(trimmed)) {
|
|
5296
|
+
flush();
|
|
5297
|
+
section = "streams";
|
|
5298
|
+
continue;
|
|
5299
|
+
}
|
|
5300
|
+
if (/^milestones:\s*$/.test(trimmed)) {
|
|
5301
|
+
flush();
|
|
5302
|
+
section = "milestones";
|
|
5303
|
+
continue;
|
|
5304
|
+
}
|
|
5305
|
+
if (!section)
|
|
5306
|
+
continue;
|
|
5307
|
+
// Layout is a flat object, not an array
|
|
5308
|
+
if (section === "layout") {
|
|
5309
|
+
const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
|
|
5310
|
+
if (kvMatch && result.layout) {
|
|
5311
|
+
result.layout[kvMatch[1]] = kvMatch[2].replace(/^["']|["']$/g, "").trim();
|
|
5312
|
+
}
|
|
5313
|
+
continue;
|
|
5314
|
+
}
|
|
5315
|
+
// New list item: " - key: value"
|
|
5316
|
+
const newItemMatch = trimmed.match(/^\s+-\s+(\w+)\s*:\s*(.*)/);
|
|
5317
|
+
if (newItemMatch) {
|
|
5318
|
+
flush();
|
|
5319
|
+
currentObj = {};
|
|
5320
|
+
const key = newItemMatch[1];
|
|
5321
|
+
const val = newItemMatch[2].replace(/^["']|["']$/g, "").trim();
|
|
5322
|
+
if (val.startsWith("[")) {
|
|
5323
|
+
// Inline array: [a, b, c]
|
|
5324
|
+
currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
|
|
5325
|
+
}
|
|
5326
|
+
else {
|
|
5327
|
+
currentObj[key] = val;
|
|
5328
|
+
}
|
|
5329
|
+
continue;
|
|
5330
|
+
}
|
|
5331
|
+
// Continuation key: " key: value"
|
|
5332
|
+
if (currentObj) {
|
|
5333
|
+
const kvMatch = trimmed.match(/^\s+(\w+)\s*:\s*(.*)/);
|
|
5334
|
+
if (kvMatch) {
|
|
5335
|
+
const key = kvMatch[1];
|
|
5336
|
+
const val = kvMatch[2].replace(/^["']|["']$/g, "").trim();
|
|
5337
|
+
if (val.startsWith("[")) {
|
|
5338
|
+
currentObj[key] = val.slice(1, -1).split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
|
|
5339
|
+
}
|
|
5340
|
+
else {
|
|
5341
|
+
currentObj[key] = val;
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
flush();
|
|
5347
|
+
return result;
|
|
5348
|
+
}
|
|
4782
5349
|
// Graceful shutdown: agent pool first (drains queue, terminates agents, cleans resources),
|
|
4783
5350
|
// then sidecars, timers, and monitors.
|
|
4784
5351
|
async function gracefulShutdown(signal) {
|
|
@@ -4812,6 +5379,8 @@ async function gracefulShutdown(signal) {
|
|
|
4812
5379
|
stopInsightsTimer();
|
|
4813
5380
|
stopOpenLoopScanner();
|
|
4814
5381
|
stopCreditMonitor();
|
|
5382
|
+
stopPushMonitor();
|
|
5383
|
+
stopMdns();
|
|
4815
5384
|
stopAvatarSidecar();
|
|
4816
5385
|
stopTtsSidecar();
|
|
4817
5386
|
stopSttSidecar();
|