openryoko 0.9.4-ryoko.2
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/bin/jimmy.d.ts +3 -0
- package/dist/bin/jimmy.d.ts.map +1 -0
- package/dist/bin/jimmy.js +149 -0
- package/dist/bin/jimmy.js.map +1 -0
- package/dist/package.json +54 -0
- package/dist/src/cli/__tests__/migrate.test.d.ts +2 -0
- package/dist/src/cli/__tests__/migrate.test.d.ts.map +1 -0
- package/dist/src/cli/__tests__/migrate.test.js +79 -0
- package/dist/src/cli/__tests__/migrate.test.js.map +1 -0
- package/dist/src/cli/chrome-allow.d.ts +5 -0
- package/dist/src/cli/chrome-allow.d.ts.map +1 -0
- package/dist/src/cli/chrome-allow.js +250 -0
- package/dist/src/cli/chrome-allow.js.map +1 -0
- package/dist/src/cli/create.d.ts +2 -0
- package/dist/src/cli/create.d.ts.map +1 -0
- package/dist/src/cli/create.js +72 -0
- package/dist/src/cli/create.js.map +1 -0
- package/dist/src/cli/instances.d.ts +14 -0
- package/dist/src/cli/instances.d.ts.map +1 -0
- package/dist/src/cli/instances.js +43 -0
- package/dist/src/cli/instances.js.map +1 -0
- package/dist/src/cli/list.d.ts +2 -0
- package/dist/src/cli/list.d.ts.map +1 -0
- package/dist/src/cli/list.js +38 -0
- package/dist/src/cli/list.js.map +1 -0
- package/dist/src/cli/migrate.d.ts +5 -0
- package/dist/src/cli/migrate.d.ts.map +1 -0
- package/dist/src/cli/migrate.js +222 -0
- package/dist/src/cli/migrate.js.map +1 -0
- package/dist/src/cli/nuke.d.ts +2 -0
- package/dist/src/cli/nuke.d.ts.map +1 -0
- package/dist/src/cli/nuke.js +91 -0
- package/dist/src/cli/nuke.js.map +1 -0
- package/dist/src/cli/remove.d.ts +4 -0
- package/dist/src/cli/remove.d.ts.map +1 -0
- package/dist/src/cli/remove.js +47 -0
- package/dist/src/cli/remove.js.map +1 -0
- package/dist/src/cli/setup.d.ts +4 -0
- package/dist/src/cli/setup.d.ts.map +1 -0
- package/dist/src/cli/setup.js +491 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/skills.d.ts +28 -0
- package/dist/src/cli/skills.d.ts.map +1 -0
- package/dist/src/cli/skills.js +284 -0
- package/dist/src/cli/skills.js.map +1 -0
- package/dist/src/cli/start.d.ts +5 -0
- package/dist/src/cli/start.d.ts.map +1 -0
- package/dist/src/cli/start.js +34 -0
- package/dist/src/cli/start.js.map +1 -0
- package/dist/src/cli/status.d.ts +2 -0
- package/dist/src/cli/status.d.ts.map +1 -0
- package/dist/src/cli/status.js +69 -0
- package/dist/src/cli/status.js.map +1 -0
- package/dist/src/cli/stop.d.ts +2 -0
- package/dist/src/cli/stop.d.ts.map +1 -0
- package/dist/src/cli/stop.js +11 -0
- package/dist/src/cli/stop.js.map +1 -0
- package/dist/src/connectors/cron/index.d.ts +21 -0
- package/dist/src/connectors/cron/index.d.ts.map +1 -0
- package/dist/src/connectors/cron/index.js +75 -0
- package/dist/src/connectors/cron/index.js.map +1 -0
- package/dist/src/connectors/discord/format.d.ts +3 -0
- package/dist/src/connectors/discord/format.d.ts.map +1 -0
- package/dist/src/connectors/discord/format.js +33 -0
- package/dist/src/connectors/discord/format.js.map +1 -0
- package/dist/src/connectors/discord/index.d.ts +47 -0
- package/dist/src/connectors/discord/index.d.ts.map +1 -0
- package/dist/src/connectors/discord/index.js +308 -0
- package/dist/src/connectors/discord/index.js.map +1 -0
- package/dist/src/connectors/discord/remote.d.ts +33 -0
- package/dist/src/connectors/discord/remote.d.ts.map +1 -0
- package/dist/src/connectors/discord/remote.js +89 -0
- package/dist/src/connectors/discord/remote.js.map +1 -0
- package/dist/src/connectors/discord/threads.d.ts +5 -0
- package/dist/src/connectors/discord/threads.d.ts.map +1 -0
- package/dist/src/connectors/discord/threads.js +22 -0
- package/dist/src/connectors/discord/threads.js.map +1 -0
- package/dist/src/connectors/slack/__tests__/active-threads.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/active-threads.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/active-threads.test.js +57 -0
- package/dist/src/connectors/slack/__tests__/active-threads.test.js.map +1 -0
- package/dist/src/connectors/slack/__tests__/format.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/format.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/format.test.js +115 -0
- package/dist/src/connectors/slack/__tests__/format.test.js.map +1 -0
- package/dist/src/connectors/slack/__tests__/speaker.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/speaker.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/speaker.test.js +59 -0
- package/dist/src/connectors/slack/__tests__/speaker.test.js.map +1 -0
- package/dist/src/connectors/slack/__tests__/triage-prompt.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/triage-prompt.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/triage-prompt.test.js +102 -0
- package/dist/src/connectors/slack/__tests__/triage-prompt.test.js.map +1 -0
- package/dist/src/connectors/slack/__tests__/triage.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/triage.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/triage.test.js +88 -0
- package/dist/src/connectors/slack/__tests__/triage.test.js.map +1 -0
- package/dist/src/connectors/slack/active-threads.d.ts +39 -0
- package/dist/src/connectors/slack/active-threads.d.ts.map +1 -0
- package/dist/src/connectors/slack/active-threads.js +71 -0
- package/dist/src/connectors/slack/active-threads.js.map +1 -0
- package/dist/src/connectors/slack/format.d.ts +17 -0
- package/dist/src/connectors/slack/format.d.ts.map +1 -0
- package/dist/src/connectors/slack/format.js +85 -0
- package/dist/src/connectors/slack/format.js.map +1 -0
- package/dist/src/connectors/slack/index.d.ts +51 -0
- package/dist/src/connectors/slack/index.d.ts.map +1 -0
- package/dist/src/connectors/slack/index.js +562 -0
- package/dist/src/connectors/slack/index.js.map +1 -0
- package/dist/src/connectors/slack/speaker.d.ts +39 -0
- package/dist/src/connectors/slack/speaker.d.ts.map +1 -0
- package/dist/src/connectors/slack/speaker.js +30 -0
- package/dist/src/connectors/slack/speaker.js.map +1 -0
- package/dist/src/connectors/slack/threads.d.ts +12 -0
- package/dist/src/connectors/slack/threads.d.ts.map +1 -0
- package/dist/src/connectors/slack/threads.js +41 -0
- package/dist/src/connectors/slack/threads.js.map +1 -0
- package/dist/src/connectors/slack/threads.test.d.ts +2 -0
- package/dist/src/connectors/slack/threads.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/threads.test.js +79 -0
- package/dist/src/connectors/slack/threads.test.js.map +1 -0
- package/dist/src/connectors/slack/triage-prompt.d.ts +50 -0
- package/dist/src/connectors/slack/triage-prompt.d.ts.map +1 -0
- package/dist/src/connectors/slack/triage-prompt.js +127 -0
- package/dist/src/connectors/slack/triage-prompt.js.map +1 -0
- package/dist/src/connectors/slack/triage.d.ts +41 -0
- package/dist/src/connectors/slack/triage.d.ts.map +1 -0
- package/dist/src/connectors/slack/triage.js +120 -0
- package/dist/src/connectors/slack/triage.js.map +1 -0
- package/dist/src/connectors/telegram/__tests__/connector.test.d.ts +2 -0
- package/dist/src/connectors/telegram/__tests__/connector.test.d.ts.map +1 -0
- package/dist/src/connectors/telegram/__tests__/connector.test.js +257 -0
- package/dist/src/connectors/telegram/__tests__/connector.test.js.map +1 -0
- package/dist/src/connectors/telegram/__tests__/format.test.d.ts +2 -0
- package/dist/src/connectors/telegram/__tests__/format.test.d.ts.map +1 -0
- package/dist/src/connectors/telegram/__tests__/format.test.js +79 -0
- package/dist/src/connectors/telegram/__tests__/format.test.js.map +1 -0
- package/dist/src/connectors/telegram/__tests__/threads.test.d.ts +2 -0
- package/dist/src/connectors/telegram/__tests__/threads.test.d.ts.map +1 -0
- package/dist/src/connectors/telegram/__tests__/threads.test.js +51 -0
- package/dist/src/connectors/telegram/__tests__/threads.test.js.map +1 -0
- package/dist/src/connectors/telegram/format.d.ts +12 -0
- package/dist/src/connectors/telegram/format.d.ts.map +1 -0
- package/dist/src/connectors/telegram/format.js +61 -0
- package/dist/src/connectors/telegram/format.js.map +1 -0
- package/dist/src/connectors/telegram/index.d.ts +28 -0
- package/dist/src/connectors/telegram/index.d.ts.map +1 -0
- package/dist/src/connectors/telegram/index.js +213 -0
- package/dist/src/connectors/telegram/index.js.map +1 -0
- package/dist/src/connectors/telegram/threads.d.ts +29 -0
- package/dist/src/connectors/telegram/threads.d.ts.map +1 -0
- package/dist/src/connectors/telegram/threads.js +26 -0
- package/dist/src/connectors/telegram/threads.js.map +1 -0
- package/dist/src/connectors/whatsapp/__tests__/format.test.d.ts +2 -0
- package/dist/src/connectors/whatsapp/__tests__/format.test.d.ts.map +1 -0
- package/dist/src/connectors/whatsapp/__tests__/format.test.js +70 -0
- package/dist/src/connectors/whatsapp/__tests__/format.test.js.map +1 -0
- package/dist/src/connectors/whatsapp/format.d.ts +12 -0
- package/dist/src/connectors/whatsapp/format.d.ts.map +1 -0
- package/dist/src/connectors/whatsapp/format.js +53 -0
- package/dist/src/connectors/whatsapp/format.js.map +1 -0
- package/dist/src/connectors/whatsapp/index.d.ts +42 -0
- package/dist/src/connectors/whatsapp/index.d.ts.map +1 -0
- package/dist/src/connectors/whatsapp/index.js +279 -0
- package/dist/src/connectors/whatsapp/index.js.map +1 -0
- package/dist/src/cron/jobs.d.ts +5 -0
- package/dist/src/cron/jobs.d.ts.map +1 -0
- package/dist/src/cron/jobs.js +23 -0
- package/dist/src/cron/jobs.js.map +1 -0
- package/dist/src/cron/runner.d.ts +4 -0
- package/dist/src/cron/runner.d.ts.map +1 -0
- package/dist/src/cron/runner.js +88 -0
- package/dist/src/cron/runner.js.map +1 -0
- package/dist/src/cron/scheduler.d.ts +8 -0
- package/dist/src/cron/scheduler.d.ts.map +1 -0
- package/dist/src/cron/scheduler.js +64 -0
- package/dist/src/cron/scheduler.js.map +1 -0
- package/dist/src/engines/__tests__/gemini.test.d.ts +2 -0
- package/dist/src/engines/__tests__/gemini.test.d.ts.map +1 -0
- package/dist/src/engines/__tests__/gemini.test.js +404 -0
- package/dist/src/engines/__tests__/gemini.test.js.map +1 -0
- package/dist/src/engines/claude.d.ts +20 -0
- package/dist/src/engines/claude.d.ts.map +1 -0
- package/dist/src/engines/claude.js +520 -0
- package/dist/src/engines/claude.js.map +1 -0
- package/dist/src/engines/codex.d.ts +15 -0
- package/dist/src/engines/codex.d.ts.map +1 -0
- package/dist/src/engines/codex.js +351 -0
- package/dist/src/engines/codex.js.map +1 -0
- package/dist/src/engines/gemini.d.ts +32 -0
- package/dist/src/engines/gemini.d.ts.map +1 -0
- package/dist/src/engines/gemini.js +328 -0
- package/dist/src/engines/gemini.js.map +1 -0
- package/dist/src/engines/mock.d.ts +16 -0
- package/dist/src/engines/mock.d.ts.map +1 -0
- package/dist/src/engines/mock.js +47 -0
- package/dist/src/engines/mock.js.map +1 -0
- package/dist/src/gateway/__tests__/api-last-n.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/api-last-n.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/api-last-n.test.js +32 -0
- package/dist/src/gateway/__tests__/api-last-n.test.js.map +1 -0
- package/dist/src/gateway/__tests__/costs.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/costs.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/costs.test.js +32 -0
- package/dist/src/gateway/__tests__/costs.test.js.map +1 -0
- package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/org-hierarchy.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/org-hierarchy.test.js +161 -0
- package/dist/src/gateway/__tests__/org-hierarchy.test.js.map +1 -0
- package/dist/src/gateway/__tests__/org-update.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/org-update.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/org-update.test.js +99 -0
- package/dist/src/gateway/__tests__/org-update.test.js.map +1 -0
- package/dist/src/gateway/__tests__/org.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/org.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/org.test.js +77 -0
- package/dist/src/gateway/__tests__/org.test.js.map +1 -0
- package/dist/src/gateway/__tests__/services.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/services.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/services.test.js +153 -0
- package/dist/src/gateway/__tests__/services.test.js.map +1 -0
- package/dist/src/gateway/api.d.ts +19 -0
- package/dist/src/gateway/api.d.ts.map +1 -0
- package/dist/src/gateway/api.js +2261 -0
- package/dist/src/gateway/api.js.map +1 -0
- package/dist/src/gateway/budgets.d.ts +16 -0
- package/dist/src/gateway/budgets.d.ts.map +1 -0
- package/dist/src/gateway/budgets.js +39 -0
- package/dist/src/gateway/budgets.js.map +1 -0
- package/dist/src/gateway/costs.d.ts +19 -0
- package/dist/src/gateway/costs.d.ts.map +1 -0
- package/dist/src/gateway/costs.js +39 -0
- package/dist/src/gateway/costs.js.map +1 -0
- package/dist/src/gateway/daemon-entry.d.ts +2 -0
- package/dist/src/gateway/daemon-entry.d.ts.map +1 -0
- package/dist/src/gateway/daemon-entry.js +12 -0
- package/dist/src/gateway/daemon-entry.js.map +1 -0
- package/dist/src/gateway/files.d.ts +6 -0
- package/dist/src/gateway/files.d.ts.map +1 -0
- package/dist/src/gateway/files.js +442 -0
- package/dist/src/gateway/files.js.map +1 -0
- package/dist/src/gateway/goals.d.ts +11 -0
- package/dist/src/gateway/goals.d.ts.map +1 -0
- package/dist/src/gateway/goals.js +72 -0
- package/dist/src/gateway/goals.js.map +1 -0
- package/dist/src/gateway/lifecycle.d.ts +10 -0
- package/dist/src/gateway/lifecycle.d.ts.map +1 -0
- package/dist/src/gateway/lifecycle.js +152 -0
- package/dist/src/gateway/lifecycle.js.map +1 -0
- package/dist/src/gateway/org-hierarchy.d.ts +5 -0
- package/dist/src/gateway/org-hierarchy.d.ts.map +1 -0
- package/dist/src/gateway/org-hierarchy.js +174 -0
- package/dist/src/gateway/org-hierarchy.js.map +1 -0
- package/dist/src/gateway/org.d.ts +17 -0
- package/dist/src/gateway/org.d.ts.map +1 -0
- package/dist/src/gateway/org.js +136 -0
- package/dist/src/gateway/org.js.map +1 -0
- package/dist/src/gateway/server.d.ts +4 -0
- package/dist/src/gateway/server.d.ts.map +1 -0
- package/dist/src/gateway/server.js +764 -0
- package/dist/src/gateway/server.js.map +1 -0
- package/dist/src/gateway/services.d.ts +28 -0
- package/dist/src/gateway/services.d.ts.map +1 -0
- package/dist/src/gateway/services.js +112 -0
- package/dist/src/gateway/services.js.map +1 -0
- package/dist/src/gateway/watcher.d.ts +14 -0
- package/dist/src/gateway/watcher.d.ts.map +1 -0
- package/dist/src/gateway/watcher.js +111 -0
- package/dist/src/gateway/watcher.js.map +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/mcp/gateway-server.d.ts +13 -0
- package/dist/src/mcp/gateway-server.d.ts.map +1 -0
- package/dist/src/mcp/gateway-server.js +376 -0
- package/dist/src/mcp/gateway-server.js.map +1 -0
- package/dist/src/mcp/resolver.d.ts +19 -0
- package/dist/src/mcp/resolver.d.ts.map +1 -0
- package/dist/src/mcp/resolver.js +159 -0
- package/dist/src/mcp/resolver.js.map +1 -0
- package/dist/src/sessions/__tests__/callbacks.test.d.ts +2 -0
- package/dist/src/sessions/__tests__/callbacks.test.d.ts.map +1 -0
- package/dist/src/sessions/__tests__/callbacks.test.js +154 -0
- package/dist/src/sessions/__tests__/callbacks.test.js.map +1 -0
- package/dist/src/sessions/callbacks.d.ts +31 -0
- package/dist/src/sessions/callbacks.d.ts.map +1 -0
- package/dist/src/sessions/callbacks.js +112 -0
- package/dist/src/sessions/callbacks.js.map +1 -0
- package/dist/src/sessions/context.d.ts +36 -0
- package/dist/src/sessions/context.d.ts.map +1 -0
- package/dist/src/sessions/context.js +741 -0
- package/dist/src/sessions/context.js.map +1 -0
- package/dist/src/sessions/fork.d.ts +30 -0
- package/dist/src/sessions/fork.d.ts.map +1 -0
- package/dist/src/sessions/fork.js +178 -0
- package/dist/src/sessions/fork.js.map +1 -0
- package/dist/src/sessions/manager.d.ts +27 -0
- package/dist/src/sessions/manager.d.ts.map +1 -0
- package/dist/src/sessions/manager.js +751 -0
- package/dist/src/sessions/manager.js.map +1 -0
- package/dist/src/sessions/queue.d.ts +36 -0
- package/dist/src/sessions/queue.d.ts.map +1 -0
- package/dist/src/sessions/queue.js +101 -0
- package/dist/src/sessions/queue.js.map +1 -0
- package/dist/src/sessions/queue.test.d.ts +2 -0
- package/dist/src/sessions/queue.test.d.ts.map +1 -0
- package/dist/src/sessions/queue.test.js +28 -0
- package/dist/src/sessions/queue.test.js.map +1 -0
- package/dist/src/sessions/registry.d.ts +116 -0
- package/dist/src/sessions/registry.d.ts.map +1 -0
- package/dist/src/sessions/registry.js +480 -0
- package/dist/src/sessions/registry.js.map +1 -0
- package/dist/src/sessions/registry.test.d.ts +2 -0
- package/dist/src/sessions/registry.test.d.ts.map +1 -0
- package/dist/src/sessions/registry.test.js +41 -0
- package/dist/src/sessions/registry.test.js.map +1 -0
- package/dist/src/shared/__tests__/rateLimit.test.d.ts +2 -0
- package/dist/src/shared/__tests__/rateLimit.test.d.ts.map +1 -0
- package/dist/src/shared/__tests__/rateLimit.test.js +108 -0
- package/dist/src/shared/__tests__/rateLimit.test.js.map +1 -0
- package/dist/src/shared/__tests__/usageAwareness.test.d.ts +2 -0
- package/dist/src/shared/__tests__/usageAwareness.test.d.ts.map +1 -0
- package/dist/src/shared/__tests__/usageAwareness.test.js +58 -0
- package/dist/src/shared/__tests__/usageAwareness.test.js.map +1 -0
- package/dist/src/shared/config.d.ts +3 -0
- package/dist/src/shared/config.d.ts.map +1 -0
- package/dist/src/shared/config.js +11 -0
- package/dist/src/shared/config.js.map +1 -0
- package/dist/src/shared/effort.d.ts +17 -0
- package/dist/src/shared/effort.d.ts.map +1 -0
- package/dist/src/shared/effort.js +44 -0
- package/dist/src/shared/effort.js.map +1 -0
- package/dist/src/shared/logger.d.ts +12 -0
- package/dist/src/shared/logger.d.ts.map +1 -0
- package/dist/src/shared/logger.js +35 -0
- package/dist/src/shared/logger.js.map +1 -0
- package/dist/src/shared/paths.d.ts +31 -0
- package/dist/src/shared/paths.d.ts.map +1 -0
- package/dist/src/shared/paths.js +92 -0
- package/dist/src/shared/paths.js.map +1 -0
- package/dist/src/shared/rateLimit.d.ts +20 -0
- package/dist/src/shared/rateLimit.d.ts.map +1 -0
- package/dist/src/shared/rateLimit.js +61 -0
- package/dist/src/shared/rateLimit.js.map +1 -0
- package/dist/src/shared/types.d.ts +448 -0
- package/dist/src/shared/types.d.ts.map +1 -0
- package/dist/src/shared/types.js +4 -0
- package/dist/src/shared/types.js.map +1 -0
- package/dist/src/shared/usageAwareness.d.ts +10 -0
- package/dist/src/shared/usageAwareness.d.ts.map +1 -0
- package/dist/src/shared/usageAwareness.js +69 -0
- package/dist/src/shared/usageAwareness.js.map +1 -0
- package/dist/src/shared/version.d.ts +15 -0
- package/dist/src/shared/version.d.ts.map +1 -0
- package/dist/src/shared/version.js +56 -0
- package/dist/src/shared/version.js.map +1 -0
- package/dist/src/stt/stt.d.ts +24 -0
- package/dist/src/stt/stt.d.ts.map +1 -0
- package/dist/src/stt/stt.js +203 -0
- package/dist/src/stt/stt.js.map +1 -0
- package/dist/web/404.html +1 -0
- package/dist/web/__next.__PAGE__.txt +9 -0
- package/dist/web/__next._full.txt +19 -0
- package/dist/web/__next._head.txt +6 -0
- package/dist/web/__next._index.txt +6 -0
- package/dist/web/__next._tree.txt +2 -0
- package/dist/web/_next/static/chunks/00yn.jp4tr9l6.js +62 -0
- package/dist/web/_next/static/chunks/0398g9fy2i2l..js +5 -0
- package/dist/web/_next/static/chunks/03~yq9q893hmn.js +1 -0
- package/dist/web/_next/static/chunks/07t-qdutaizlf.js +5 -0
- package/dist/web/_next/static/chunks/0_pxvi.u4tzw1.js +5 -0
- package/dist/web/_next/static/chunks/0dtbl8648b3u9.js +7 -0
- package/dist/web/_next/static/chunks/0e3pdjkorr5zl.js +2 -0
- package/dist/web/_next/static/chunks/0ewpxy-mzj3j3.js +1 -0
- package/dist/web/_next/static/chunks/0ihbb7~gq42g~.js +5 -0
- package/dist/web/_next/static/chunks/0j4uz-g9x1_~s.js +1 -0
- package/dist/web/_next/static/chunks/0mlo32yrc7ziu.css +1 -0
- package/dist/web/_next/static/chunks/0ru56x83x4rkk.js +1 -0
- package/dist/web/_next/static/chunks/0svvev77pb2gu.js +1 -0
- package/dist/web/_next/static/chunks/0tf9kpygstdls.js +1 -0
- package/dist/web/_next/static/chunks/0wzpea61sn2-k.js +5 -0
- package/dist/web/_next/static/chunks/0y3dnnq~kxyz_.js +1 -0
- package/dist/web/_next/static/chunks/0y~qqhmf2o7pt.js +1 -0
- package/dist/web/_next/static/chunks/0~6m1p5hnsx2f.js +1 -0
- package/dist/web/_next/static/chunks/10-liamt-ipcu.js +1 -0
- package/dist/web/_next/static/chunks/10l08hq.acixl.js +5 -0
- package/dist/web/_next/static/chunks/11rbefjn.e-3-.js +1 -0
- package/dist/web/_next/static/chunks/11rodb4s1x4of.js +1 -0
- package/dist/web/_next/static/chunks/12_uxtnrqhoo1.js +1 -0
- package/dist/web/_next/static/chunks/12h4dws.tz7yb.js +5 -0
- package/dist/web/_next/static/chunks/13juizw904g.e.js +1 -0
- package/dist/web/_next/static/chunks/14ge_q22f0n.f.js +1 -0
- package/dist/web/_next/static/chunks/16b0jvgccyt2i.js +5 -0
- package/dist/web/_next/static/chunks/17-u9c_weg12-.js +31 -0
- package/dist/web/_next/static/chunks/turbopack-0dt987bw_1nyf.js +1 -0
- package/dist/web/_next/static/media/apple-icon.0.wpl4.d-5bkf.jpg +0 -0
- package/dist/web/_next/static/media/icon.0.wpl4.d-5bkf.jpg +0 -0
- package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_buildManifest.js +11 -0
- package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_clientMiddlewareManifest.js +1 -0
- package/dist/web/_next/static/uwjABwQIFELOHhVlstvOe/_ssgManifest.js +1 -0
- package/dist/web/_not-found/__next._full.txt +17 -0
- package/dist/web/_not-found/__next._head.txt +6 -0
- package/dist/web/_not-found/__next._index.txt +6 -0
- package/dist/web/_not-found/__next._not-found.__PAGE__.txt +5 -0
- package/dist/web/_not-found/__next._not-found.txt +5 -0
- package/dist/web/_not-found/__next._tree.txt +2 -0
- package/dist/web/_not-found.html +1 -0
- package/dist/web/_not-found.txt +17 -0
- package/dist/web/apple-icon.jpg +0 -0
- package/dist/web/chat/__next._full.txt +21 -0
- package/dist/web/chat/__next._head.txt +6 -0
- package/dist/web/chat/__next._index.txt +6 -0
- package/dist/web/chat/__next._tree.txt +2 -0
- package/dist/web/chat/__next.chat.__PAGE__.txt +9 -0
- package/dist/web/chat/__next.chat.txt +5 -0
- package/dist/web/chat.html +1 -0
- package/dist/web/chat.txt +21 -0
- package/dist/web/cron/__next._full.txt +21 -0
- package/dist/web/cron/__next._head.txt +6 -0
- package/dist/web/cron/__next._index.txt +6 -0
- package/dist/web/cron/__next._tree.txt +2 -0
- package/dist/web/cron/__next.cron.__PAGE__.txt +9 -0
- package/dist/web/cron/__next.cron.txt +5 -0
- package/dist/web/cron.html +1 -0
- package/dist/web/cron.txt +21 -0
- package/dist/web/icon.jpg +0 -0
- package/dist/web/index.html +1 -0
- package/dist/web/index.txt +19 -0
- package/dist/web/kanban/__next._full.txt +21 -0
- package/dist/web/kanban/__next._head.txt +6 -0
- package/dist/web/kanban/__next._index.txt +6 -0
- package/dist/web/kanban/__next._tree.txt +2 -0
- package/dist/web/kanban/__next.kanban.__PAGE__.txt +9 -0
- package/dist/web/kanban/__next.kanban.txt +5 -0
- package/dist/web/kanban.html +1 -0
- package/dist/web/kanban.txt +21 -0
- package/dist/web/logs/__next._full.txt +21 -0
- package/dist/web/logs/__next._head.txt +6 -0
- package/dist/web/logs/__next._index.txt +6 -0
- package/dist/web/logs/__next._tree.txt +2 -0
- package/dist/web/logs/__next.logs.__PAGE__.txt +9 -0
- package/dist/web/logs/__next.logs.txt +5 -0
- package/dist/web/logs.html +7 -0
- package/dist/web/logs.txt +21 -0
- package/dist/web/org/__next._full.txt +21 -0
- package/dist/web/org/__next._head.txt +6 -0
- package/dist/web/org/__next._index.txt +6 -0
- package/dist/web/org/__next._tree.txt +2 -0
- package/dist/web/org/__next.org.__PAGE__.txt +9 -0
- package/dist/web/org/__next.org.txt +5 -0
- package/dist/web/org.html +1 -0
- package/dist/web/org.txt +21 -0
- package/dist/web/sessions/__next._full.txt +18 -0
- package/dist/web/sessions/__next._head.txt +6 -0
- package/dist/web/sessions/__next._index.txt +6 -0
- package/dist/web/sessions/__next._tree.txt +2 -0
- package/dist/web/sessions/__next.sessions.__PAGE__.txt +6 -0
- package/dist/web/sessions/__next.sessions.txt +5 -0
- package/dist/web/sessions.html +1 -0
- package/dist/web/sessions.txt +18 -0
- package/dist/web/settings/__next._full.txt +21 -0
- package/dist/web/settings/__next._head.txt +6 -0
- package/dist/web/settings/__next._index.txt +6 -0
- package/dist/web/settings/__next._tree.txt +2 -0
- package/dist/web/settings/__next.settings.__PAGE__.txt +9 -0
- package/dist/web/settings/__next.settings.txt +5 -0
- package/dist/web/settings.html +1 -0
- package/dist/web/settings.txt +21 -0
- package/dist/web/skills/__next._full.txt +21 -0
- package/dist/web/skills/__next._head.txt +6 -0
- package/dist/web/skills/__next._index.txt +6 -0
- package/dist/web/skills/__next._tree.txt +2 -0
- package/dist/web/skills/__next.skills.__PAGE__.txt +9 -0
- package/dist/web/skills/__next.skills.txt +5 -0
- package/dist/web/skills.html +1 -0
- package/dist/web/skills.txt +21 -0
- package/package.json +54 -0
- package/template/AGENTS.md +285 -0
- package/template/CLAUDE.md +285 -0
- package/template/config.default.yaml +71 -0
- package/template/docs/architecture.md +74 -0
- package/template/docs/connectors.md +72 -0
- package/template/docs/cron.md +137 -0
- package/template/docs/mcp.md +108 -0
- package/template/docs/org.md +105 -0
- package/template/docs/overview.md +39 -0
- package/template/docs/self-modification.md +65 -0
- package/template/docs/skills.md +58 -0
- package/template/knowledge/.gitkeep +0 -0
- package/template/migrations/.gitkeep +0 -0
- package/template/migrations/0.1.0/MIGRATION.md +25 -0
- package/template/migrations/0.2.0/MIGRATION.md +41 -0
- package/template/migrations/0.2.0/files/config.default.yaml +30 -0
- package/template/migrations/0.3.0/MIGRATION.md +57 -0
- package/template/migrations/0.3.0/files/config.default.yaml +62 -0
- package/template/migrations/0.3.0/files/docs/mcp.md +108 -0
- package/template/migrations/0.7.3/MIGRATION.md +63 -0
- package/template/migrations/0.7.3/files/AGENTS.md +188 -0
- package/template/migrations/0.7.3/files/CLAUDE.md +127 -0
- package/template/migrations/0.7.5/MIGRATION.md +78 -0
- package/template/migrations/0.8.0/MIGRATION.md +73 -0
- package/template/migrations/0.9.0/MIGRATION.md +82 -0
- package/template/skills/cron-manager/SKILL.md +127 -0
- package/template/skills/find-and-install/SKILL.md +92 -0
- package/template/skills/management/SKILL.md +226 -0
- package/template/skills/migrate/SKILL.md +154 -0
- package/template/skills/new/SKILL.md +19 -0
- package/template/skills/onboarding/SKILL.md +106 -0
- package/template/skills/self-heal/SKILL.md +114 -0
- package/template/skills/skill-creator/SKILL.md +112 -0
- package/template/skills/status/SKILL.md +19 -0
- package/template/skills/sync/SKILL.md +67 -0
- package/template/skills.json +3 -0
|
@@ -0,0 +1,2261 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import { isInterruptibleEngine } from "../shared/types.js";
|
|
7
|
+
import { buildContext } from "../sessions/context.js";
|
|
8
|
+
import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
|
|
9
|
+
import { forkEngineSession } from "../sessions/fork.js";
|
|
10
|
+
import { CONFIG_PATH, CRON_RUNS, ORG_DIR, SKILLS_DIR, LOGS_DIR, TMP_DIR, FILES_DIR, } from "../shared/paths.js";
|
|
11
|
+
import { logger } from "../shared/logger.js";
|
|
12
|
+
import { getSttStatus, downloadModel, transcribe as sttTranscribe, resolveLanguages, WHISPER_LANGUAGES } from "../stt/stt.js";
|
|
13
|
+
import { JINN_HOME } from "../shared/paths.js";
|
|
14
|
+
import { resolveEffort } from "../shared/effort.js";
|
|
15
|
+
import { computeNextRetryDelayMs, computeRateLimitDeadlineMs, detectRateLimit } from "../shared/rateLimit.js";
|
|
16
|
+
import { getClaudeExpectedResetAt, recordClaudeRateLimit } from "../shared/usageAwareness.js";
|
|
17
|
+
import { loadJobs, saveJobs } from "../cron/jobs.js";
|
|
18
|
+
import { reloadScheduler } from "../cron/scheduler.js";
|
|
19
|
+
import { runCronJob } from "../cron/runner.js";
|
|
20
|
+
import QRCode from "qrcode";
|
|
21
|
+
import { handleFilesRequest } from "./files.js";
|
|
22
|
+
import { notifyParentSession, notifyRateLimited, notifyRateLimitResumed, notifyDiscordChannel } from "../sessions/callbacks.js";
|
|
23
|
+
import { loadInstances } from "../cli/instances.js";
|
|
24
|
+
export function resumePendingWebQueueItems(context) {
|
|
25
|
+
const pending = listAllPendingQueueItems();
|
|
26
|
+
if (pending.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
let resumed = 0;
|
|
29
|
+
for (const item of pending) {
|
|
30
|
+
let session = getSession(item.sessionId);
|
|
31
|
+
if (!session) {
|
|
32
|
+
cancelQueueItem(item.id);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (session.source !== "web")
|
|
36
|
+
continue;
|
|
37
|
+
session = maybeRevertEngineOverride(session);
|
|
38
|
+
const config = context.getConfig();
|
|
39
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
40
|
+
if (!engine) {
|
|
41
|
+
cancelQueueItem(item.id);
|
|
42
|
+
updateSession(session.id, { status: "error", lastActivity: new Date().toISOString(), lastError: `Engine "${session.engine}" not available` });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Ensure the session is in a runnable state
|
|
46
|
+
updateSession(session.id, { status: "running", lastActivity: new Date().toISOString(), lastError: null });
|
|
47
|
+
dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id });
|
|
48
|
+
resumed++;
|
|
49
|
+
}
|
|
50
|
+
if (resumed > 0) {
|
|
51
|
+
logger.info(`Re-dispatched ${resumed} pending web queue item(s) after gateway restart`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function maybeRevertEngineOverride(session) {
|
|
55
|
+
const meta = (session.transportMeta || {});
|
|
56
|
+
const override = meta["engineOverride"];
|
|
57
|
+
if (!override)
|
|
58
|
+
return session;
|
|
59
|
+
const originalEngine = typeof override.originalEngine === "string" ? override.originalEngine : null;
|
|
60
|
+
const originalEngineSessionId = typeof override.originalEngineSessionId === "string"
|
|
61
|
+
? override.originalEngineSessionId
|
|
62
|
+
: null;
|
|
63
|
+
const syncSince = typeof override.syncSince === "string" ? override.syncSince : null;
|
|
64
|
+
const untilIso = typeof override.until === "string" ? override.until : null;
|
|
65
|
+
if (!originalEngine || !untilIso)
|
|
66
|
+
return session;
|
|
67
|
+
const until = new Date(untilIso);
|
|
68
|
+
if (Number.isNaN(until.getTime()))
|
|
69
|
+
return session;
|
|
70
|
+
if (until.getTime() > Date.now())
|
|
71
|
+
return session;
|
|
72
|
+
const engineSessionsRaw = meta["engineSessions"];
|
|
73
|
+
const engineSessions = (engineSessionsRaw && typeof engineSessionsRaw === "object" && !Array.isArray(engineSessionsRaw))
|
|
74
|
+
? { ...engineSessionsRaw }
|
|
75
|
+
: {};
|
|
76
|
+
// Preserve the current engine session ID under its engine key
|
|
77
|
+
if (session.engine && session.engineSessionId) {
|
|
78
|
+
engineSessions[String(session.engine)] = session.engineSessionId;
|
|
79
|
+
}
|
|
80
|
+
const restoredSessionId = originalEngineSessionId
|
|
81
|
+
?? (typeof engineSessions[originalEngine] === "string" ? engineSessions[originalEngine] : null);
|
|
82
|
+
const nextMeta = { ...meta, engineSessions };
|
|
83
|
+
if (originalEngine === "claude" && syncSince && session.engine !== "claude") {
|
|
84
|
+
nextMeta["claudeSyncSince"] = syncSince;
|
|
85
|
+
}
|
|
86
|
+
delete nextMeta["engineOverride"];
|
|
87
|
+
return updateSession(session.id, {
|
|
88
|
+
engine: originalEngine,
|
|
89
|
+
engineSessionId: restoredSessionId,
|
|
90
|
+
transportMeta: nextMeta,
|
|
91
|
+
lastError: null,
|
|
92
|
+
}) ?? session;
|
|
93
|
+
}
|
|
94
|
+
function dispatchWebSessionRun(session, prompt, engine, config, context, opts) {
|
|
95
|
+
const run = async () => {
|
|
96
|
+
await context.sessionManager.getQueue().enqueue(session.sessionKey || session.sourceRef, async () => {
|
|
97
|
+
context.emit("session:started", { sessionId: session.id });
|
|
98
|
+
await runWebSession(session, prompt, engine, config, context, opts?.attachments);
|
|
99
|
+
}, opts?.queueItemId);
|
|
100
|
+
};
|
|
101
|
+
const launch = () => {
|
|
102
|
+
run().catch((err) => {
|
|
103
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
logger.error(`Web session ${session.id} dispatch error: ${errMsg}`);
|
|
105
|
+
updateSession(session.id, {
|
|
106
|
+
status: "error",
|
|
107
|
+
lastActivity: new Date().toISOString(),
|
|
108
|
+
lastError: errMsg,
|
|
109
|
+
});
|
|
110
|
+
context.emit("session:completed", {
|
|
111
|
+
sessionId: session.id,
|
|
112
|
+
result: null,
|
|
113
|
+
error: errMsg,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
if (opts?.delayMs && opts.delayMs > 0) {
|
|
118
|
+
setTimeout(launch, opts.delayMs);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
launch();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function readBody(req) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
128
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
129
|
+
req.on("error", reject);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function readBodyRaw(req) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
136
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function readJsonBody(req, res) {
|
|
141
|
+
const raw = await readBody(req);
|
|
142
|
+
try {
|
|
143
|
+
return { ok: true, body: JSON.parse(raw) };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
badRequest(res, "Invalid JSON in request body");
|
|
147
|
+
return { ok: false };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Resolve an array of file IDs to local filesystem paths for engine consumption. */
|
|
151
|
+
function resolveAttachmentPaths(fileIds) {
|
|
152
|
+
if (!Array.isArray(fileIds))
|
|
153
|
+
return [];
|
|
154
|
+
const paths = [];
|
|
155
|
+
for (const id of fileIds) {
|
|
156
|
+
if (typeof id !== "string" || !id.trim())
|
|
157
|
+
continue;
|
|
158
|
+
const meta = getFile(id);
|
|
159
|
+
if (!meta) {
|
|
160
|
+
logger.warn(`Attachment file not found: ${id}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const filePath = path.join(FILES_DIR, meta.id, meta.filename);
|
|
164
|
+
if (fs.existsSync(filePath)) {
|
|
165
|
+
paths.push(filePath);
|
|
166
|
+
}
|
|
167
|
+
else if (meta.path && fs.existsSync(meta.path)) {
|
|
168
|
+
paths.push(meta.path);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
logger.warn(`Attachment file missing on disk: ${id} (${meta.filename})`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return paths;
|
|
175
|
+
}
|
|
176
|
+
function json(res, data, status = 200) {
|
|
177
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
178
|
+
res.end(JSON.stringify(data));
|
|
179
|
+
}
|
|
180
|
+
function notFound(res) {
|
|
181
|
+
json(res, { error: "Not found" }, 404);
|
|
182
|
+
}
|
|
183
|
+
function badRequest(res, message) {
|
|
184
|
+
json(res, { error: message }, 400);
|
|
185
|
+
}
|
|
186
|
+
function serverError(res, message) {
|
|
187
|
+
json(res, { error: message }, 500);
|
|
188
|
+
}
|
|
189
|
+
const SANITIZED_KEYS = new Set(["token", "botToken", "signingSecret", "appToken"]);
|
|
190
|
+
function deepMerge(target, source) {
|
|
191
|
+
const result = { ...target };
|
|
192
|
+
for (const key of Object.keys(source)) {
|
|
193
|
+
const sv = source[key];
|
|
194
|
+
const tv = target[key];
|
|
195
|
+
// Skip sanitized secret placeholders — keep original value
|
|
196
|
+
if (SANITIZED_KEYS.has(key) && sv === "***")
|
|
197
|
+
continue;
|
|
198
|
+
if (Array.isArray(sv)) {
|
|
199
|
+
// For arrays (e.g. instances), preserve secrets from matching items
|
|
200
|
+
if (Array.isArray(tv)) {
|
|
201
|
+
result[key] = sv.map((item) => {
|
|
202
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
203
|
+
const srcItem = item;
|
|
204
|
+
// Find matching target item by id
|
|
205
|
+
const matchTarget = tv.find((t) => t && typeof t === "object" && t.id === srcItem.id);
|
|
206
|
+
if (matchTarget)
|
|
207
|
+
return deepMerge(matchTarget, srcItem);
|
|
208
|
+
}
|
|
209
|
+
return item;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
result[key] = sv;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (sv && typeof sv === "object" && !Array.isArray(sv) && tv && typeof tv === "object" && !Array.isArray(tv)) {
|
|
217
|
+
result[key] = deepMerge(tv, sv);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
result[key] = sv;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
function matchRoute(pattern, pathname) {
|
|
226
|
+
const patternParts = pattern.split("/");
|
|
227
|
+
const pathParts = pathname.split("/");
|
|
228
|
+
if (patternParts.length !== pathParts.length)
|
|
229
|
+
return null;
|
|
230
|
+
const params = {};
|
|
231
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
232
|
+
if (patternParts[i].startsWith(":")) {
|
|
233
|
+
params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i]);
|
|
234
|
+
}
|
|
235
|
+
else if (patternParts[i] !== pathParts[i]) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return params;
|
|
240
|
+
}
|
|
241
|
+
function serializeSession(session, context) {
|
|
242
|
+
const queue = context.sessionManager.getQueue();
|
|
243
|
+
const queueDepth = queue.getPendingCount(session.sessionKey || session.sourceRef);
|
|
244
|
+
const transportState = queue.getTransportState(session.sessionKey || session.sourceRef, session.status);
|
|
245
|
+
return {
|
|
246
|
+
...session,
|
|
247
|
+
queueDepth,
|
|
248
|
+
transportState,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function checkInstanceHealth(port) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const req = http.request({ hostname: "localhost", port, path: "/api/status", timeout: 2000 }, (res) => {
|
|
254
|
+
resolve(res.statusCode === 200);
|
|
255
|
+
res.resume();
|
|
256
|
+
});
|
|
257
|
+
req.on("error", () => resolve(false));
|
|
258
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
259
|
+
req.end();
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
export async function handleApiRequest(req, res, context) {
|
|
263
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
264
|
+
const pathname = url.pathname;
|
|
265
|
+
const method = req.method || "GET";
|
|
266
|
+
try {
|
|
267
|
+
// GET /api/status
|
|
268
|
+
if (method === "GET" && pathname === "/api/status") {
|
|
269
|
+
const config = context.getConfig();
|
|
270
|
+
const sessions = listSessions();
|
|
271
|
+
const running = sessions.filter((s) => s.status === "running").length;
|
|
272
|
+
const connectors = Object.fromEntries(Array.from(context.connectors.values()).map((connector) => [connector.name, connector.getHealth()]));
|
|
273
|
+
return json(res, {
|
|
274
|
+
status: "ok",
|
|
275
|
+
uptime: Math.floor((Date.now() - context.startTime) / 1000),
|
|
276
|
+
port: config.gateway.port || 7777,
|
|
277
|
+
engines: {
|
|
278
|
+
default: config.engines.default,
|
|
279
|
+
claude: { model: config.engines.claude.model, available: true },
|
|
280
|
+
codex: { model: config.engines.codex.model, available: true },
|
|
281
|
+
...(config.engines.gemini ? { gemini: { model: config.engines.gemini.model, available: true } } : {}),
|
|
282
|
+
},
|
|
283
|
+
sessions: { total: sessions.length, running, active: running },
|
|
284
|
+
connectors,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// GET /api/instances
|
|
288
|
+
if (method === "GET" && pathname === "/api/instances") {
|
|
289
|
+
const instances = loadInstances();
|
|
290
|
+
const currentPort = context.getConfig().gateway.port || 7777;
|
|
291
|
+
const results = await Promise.all(instances.map(async (inst) => ({
|
|
292
|
+
name: inst.name,
|
|
293
|
+
port: inst.port,
|
|
294
|
+
running: inst.port === currentPort ? true : await checkInstanceHealth(inst.port),
|
|
295
|
+
current: inst.port === currentPort,
|
|
296
|
+
})));
|
|
297
|
+
return json(res, results);
|
|
298
|
+
}
|
|
299
|
+
// GET /api/sessions
|
|
300
|
+
if (method === "GET" && pathname === "/api/sessions") {
|
|
301
|
+
const sessions = listSessions();
|
|
302
|
+
return json(res, sessions.map((session) => serializeSession(session, context)));
|
|
303
|
+
}
|
|
304
|
+
// GET /api/sessions/interrupted — list sessions that can be resumed after a restart
|
|
305
|
+
if (method === "GET" && pathname === "/api/sessions/interrupted") {
|
|
306
|
+
const { getInterruptedSessions } = await import("../sessions/registry.js");
|
|
307
|
+
const interrupted = getInterruptedSessions();
|
|
308
|
+
return json(res, interrupted.map((session) => serializeSession(session, context)));
|
|
309
|
+
}
|
|
310
|
+
// GET /api/sessions/:id
|
|
311
|
+
let params = matchRoute("/api/sessions/:id", pathname);
|
|
312
|
+
if (method === "GET" && params) {
|
|
313
|
+
const session = getSession(params.id);
|
|
314
|
+
if (!session)
|
|
315
|
+
return notFound(res);
|
|
316
|
+
let messages = getMessages(params.id);
|
|
317
|
+
// Backfill from Claude Code's JSONL transcript if our DB has no messages
|
|
318
|
+
if (messages.length === 0 && session.engineSessionId) {
|
|
319
|
+
const transcriptMessages = loadTranscriptMessages(session.engineSessionId);
|
|
320
|
+
if (transcriptMessages.length > 0) {
|
|
321
|
+
for (const tm of transcriptMessages) {
|
|
322
|
+
insertMessage(params.id, tm.role, tm.content);
|
|
323
|
+
}
|
|
324
|
+
messages = getMessages(params.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Support ?last=N to return only the N most recent messages
|
|
328
|
+
const lastN = parseInt(url.searchParams.get("last") || "0", 10);
|
|
329
|
+
if (lastN > 0 && messages.length > lastN) {
|
|
330
|
+
messages = messages.slice(-lastN);
|
|
331
|
+
}
|
|
332
|
+
return json(res, { ...serializeSession(session, context), messages });
|
|
333
|
+
}
|
|
334
|
+
// PUT /api/sessions/:id
|
|
335
|
+
params = matchRoute("/api/sessions/:id", pathname);
|
|
336
|
+
if (method === "PUT" && params) {
|
|
337
|
+
const session = getSession(params.id);
|
|
338
|
+
if (!session)
|
|
339
|
+
return notFound(res);
|
|
340
|
+
const _parsed = await readJsonBody(req, res);
|
|
341
|
+
if (!_parsed.ok)
|
|
342
|
+
return;
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
344
|
+
const body = _parsed.body;
|
|
345
|
+
const updates = {};
|
|
346
|
+
if (body.title !== undefined) {
|
|
347
|
+
if (typeof body.title !== "string")
|
|
348
|
+
return badRequest(res, "title must be a string");
|
|
349
|
+
const trimmed = body.title.trim();
|
|
350
|
+
if (!trimmed)
|
|
351
|
+
return badRequest(res, "title must not be empty");
|
|
352
|
+
updates.title = trimmed.slice(0, 200);
|
|
353
|
+
}
|
|
354
|
+
if (Object.keys(updates).length === 0)
|
|
355
|
+
return badRequest(res, "no valid fields to update");
|
|
356
|
+
const updated = updateSession(params.id, updates);
|
|
357
|
+
if (!updated)
|
|
358
|
+
return notFound(res);
|
|
359
|
+
context.emit("session:updated", { sessionId: params.id });
|
|
360
|
+
return json(res, serializeSession(updated, context));
|
|
361
|
+
}
|
|
362
|
+
// DELETE /api/sessions/:id
|
|
363
|
+
params = matchRoute("/api/sessions/:id", pathname);
|
|
364
|
+
if (method === "DELETE" && params) {
|
|
365
|
+
const session = getSession(params.id);
|
|
366
|
+
if (!session)
|
|
367
|
+
return notFound(res);
|
|
368
|
+
// Kill any live engine process for this session before deleting it.
|
|
369
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
370
|
+
if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
|
|
371
|
+
logger.info(`Killing live engine process for deleted session ${params.id}`);
|
|
372
|
+
engine.kill(params.id);
|
|
373
|
+
}
|
|
374
|
+
const deleted = deleteSession(params.id);
|
|
375
|
+
if (!deleted)
|
|
376
|
+
return notFound(res);
|
|
377
|
+
logger.info(`Session deleted: ${params.id}`);
|
|
378
|
+
context.emit("session:deleted", { sessionId: params.id });
|
|
379
|
+
return json(res, { status: "deleted" });
|
|
380
|
+
}
|
|
381
|
+
// POST /api/sessions/:id/stop
|
|
382
|
+
params = matchRoute("/api/sessions/:id/stop", pathname);
|
|
383
|
+
if (method === "POST" && params) {
|
|
384
|
+
const session = getSession(params.id);
|
|
385
|
+
if (!session)
|
|
386
|
+
return notFound(res);
|
|
387
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
388
|
+
if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
|
|
389
|
+
engine.kill(params.id, "Interrupted by user");
|
|
390
|
+
}
|
|
391
|
+
context.sessionManager.getQueue().clearQueue(session.sessionKey || session.sourceRef || session.id);
|
|
392
|
+
updateSession(params.id, { status: "idle", lastActivity: new Date().toISOString(), lastError: null });
|
|
393
|
+
context.emit("session:stopped", { sessionId: params.id });
|
|
394
|
+
return json(res, { status: "stopped", sessionId: params.id });
|
|
395
|
+
}
|
|
396
|
+
// POST /api/sessions/:id/reset — clear stuck session state (stale engine IDs, errors)
|
|
397
|
+
params = matchRoute("/api/sessions/:id/reset", pathname);
|
|
398
|
+
if (method === "POST" && params) {
|
|
399
|
+
const session = getSession(params.id);
|
|
400
|
+
if (!session)
|
|
401
|
+
return notFound(res);
|
|
402
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
403
|
+
if (engine && isInterruptibleEngine(engine) && engine.isAlive(params.id)) {
|
|
404
|
+
engine.kill(params.id, "Interrupted by reset");
|
|
405
|
+
}
|
|
406
|
+
context.sessionManager.getQueue().clearQueue(session.sessionKey || session.sourceRef || session.id);
|
|
407
|
+
const meta = { ...(session.transportMeta || {}) };
|
|
408
|
+
delete meta["engineSessions"];
|
|
409
|
+
delete meta["engineOverride"];
|
|
410
|
+
updateSession(params.id, {
|
|
411
|
+
status: "idle",
|
|
412
|
+
engineSessionId: null,
|
|
413
|
+
lastActivity: new Date().toISOString(),
|
|
414
|
+
lastError: null,
|
|
415
|
+
transportMeta: meta,
|
|
416
|
+
});
|
|
417
|
+
logger.info(`Session ${params.id} reset via API (cleared engineSessions, engineOverride, engineSessionId, lastError)`);
|
|
418
|
+
context.emit("session:updated", { sessionId: params.id });
|
|
419
|
+
return json(res, { status: "reset", sessionId: params.id });
|
|
420
|
+
}
|
|
421
|
+
// POST /api/sessions/:id/duplicate — duplicate a session (snapshot fork)
|
|
422
|
+
params = matchRoute("/api/sessions/:id/duplicate", pathname);
|
|
423
|
+
if (method === "POST" && params) {
|
|
424
|
+
const source = getSession(params.id);
|
|
425
|
+
if (!source)
|
|
426
|
+
return notFound(res);
|
|
427
|
+
if (!source.engineSessionId) {
|
|
428
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
429
|
+
res.end(JSON.stringify({ error: "Session has no engine session ID — cannot duplicate" }));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
let newSessionId = null;
|
|
433
|
+
try {
|
|
434
|
+
// 1. Duplicate session + messages in the registry
|
|
435
|
+
const { session: newSession, messageCount } = duplicateSession(params.id);
|
|
436
|
+
newSessionId = newSession.id;
|
|
437
|
+
// 2. Fork the engine session (Claude/Codex/Gemini)
|
|
438
|
+
const forkResult = forkEngineSession(source.engine, source.engineSessionId, JINN_HOME);
|
|
439
|
+
// 3. Store the new engine session ID
|
|
440
|
+
updateSession(newSession.id, { engineSessionId: forkResult.engineSessionId });
|
|
441
|
+
const result = getSession(newSession.id);
|
|
442
|
+
logger.info(`Session duplicated: ${params.id} → ${newSession.id} (engine: ${forkResult.engineSessionId}, ${messageCount} messages)`);
|
|
443
|
+
context.emit("session:created", { sessionId: newSession.id });
|
|
444
|
+
return json(res, serializeSession(result, context));
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
// Clean up orphaned session if the engine fork failed after DB insert
|
|
448
|
+
if (newSessionId) {
|
|
449
|
+
try {
|
|
450
|
+
deleteSession(newSessionId);
|
|
451
|
+
}
|
|
452
|
+
catch { /* best effort */ }
|
|
453
|
+
}
|
|
454
|
+
logger.error(`Failed to duplicate session ${params.id}: ${err.message}`);
|
|
455
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
456
|
+
res.end(JSON.stringify({ error: `Duplicate failed: ${err.message}` }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// DELETE /api/sessions/:id/queue/:itemId — cancel specific item
|
|
461
|
+
const queueItemParams = matchRoute("/api/sessions/:id/queue/:itemId", pathname);
|
|
462
|
+
if (method === "DELETE" && queueItemParams) {
|
|
463
|
+
const session = getSession(queueItemParams.id);
|
|
464
|
+
if (!session)
|
|
465
|
+
return notFound(res);
|
|
466
|
+
const cancelled = cancelQueueItem(queueItemParams.itemId);
|
|
467
|
+
if (!cancelled) {
|
|
468
|
+
res.writeHead(409, { "Content-Type": "application/json" });
|
|
469
|
+
res.end(JSON.stringify({ error: "Item not found or already running" }));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
context.emit("queue:updated", { sessionId: queueItemParams.id, sessionKey: session.sessionKey });
|
|
473
|
+
return json(res, { status: "cancelled", itemId: queueItemParams.itemId });
|
|
474
|
+
}
|
|
475
|
+
// GET /api/sessions/:id/queue
|
|
476
|
+
params = matchRoute("/api/sessions/:id/queue", pathname);
|
|
477
|
+
if (method === "GET" && params) {
|
|
478
|
+
const session = getSession(params.id);
|
|
479
|
+
if (!session)
|
|
480
|
+
return notFound(res);
|
|
481
|
+
const items = getQueueItems(session.sessionKey || session.sourceRef || session.id);
|
|
482
|
+
return json(res, items);
|
|
483
|
+
}
|
|
484
|
+
// DELETE /api/sessions/:id/queue — clear all pending
|
|
485
|
+
params = matchRoute("/api/sessions/:id/queue", pathname);
|
|
486
|
+
if (method === "DELETE" && params) {
|
|
487
|
+
const session = getSession(params.id);
|
|
488
|
+
if (!session)
|
|
489
|
+
return notFound(res);
|
|
490
|
+
const sessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
491
|
+
context.sessionManager.getQueue().clearQueue(sessionKey);
|
|
492
|
+
const cancelled = cancelAllPendingQueueItems(sessionKey);
|
|
493
|
+
context.emit("queue:updated", { sessionId: params.id, sessionKey, depth: 0 });
|
|
494
|
+
return json(res, { status: "cleared", cancelled });
|
|
495
|
+
}
|
|
496
|
+
// POST /api/sessions/:id/queue/pause
|
|
497
|
+
params = matchRoute("/api/sessions/:id/queue/pause", pathname);
|
|
498
|
+
if (method === "POST" && params) {
|
|
499
|
+
const session = getSession(params.id);
|
|
500
|
+
if (!session)
|
|
501
|
+
return notFound(res);
|
|
502
|
+
const sessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
503
|
+
context.sessionManager.getQueue().pauseQueue(sessionKey);
|
|
504
|
+
context.emit("queue:updated", { sessionId: params.id, sessionKey, paused: true });
|
|
505
|
+
return json(res, { status: "paused", sessionId: params.id });
|
|
506
|
+
}
|
|
507
|
+
// POST /api/sessions/:id/queue/resume
|
|
508
|
+
params = matchRoute("/api/sessions/:id/queue/resume", pathname);
|
|
509
|
+
if (method === "POST" && params) {
|
|
510
|
+
const session = getSession(params.id);
|
|
511
|
+
if (!session)
|
|
512
|
+
return notFound(res);
|
|
513
|
+
const sessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
514
|
+
context.sessionManager.getQueue().resumeQueue(sessionKey);
|
|
515
|
+
context.emit("queue:updated", { sessionId: params.id, sessionKey, paused: false });
|
|
516
|
+
return json(res, { status: "resumed", sessionId: params.id });
|
|
517
|
+
}
|
|
518
|
+
// POST /api/sessions/bulk-delete
|
|
519
|
+
if (method === "POST" && pathname === "/api/sessions/bulk-delete") {
|
|
520
|
+
const _parsed = await readJsonBody(req, res);
|
|
521
|
+
if (!_parsed.ok)
|
|
522
|
+
return;
|
|
523
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
524
|
+
const body = _parsed.body;
|
|
525
|
+
const ids = body.ids;
|
|
526
|
+
if (!Array.isArray(ids) || ids.length === 0)
|
|
527
|
+
return badRequest(res, "ids array is required");
|
|
528
|
+
// Kill any live engine processes before deleting
|
|
529
|
+
for (const id of ids) {
|
|
530
|
+
const session = getSession(id);
|
|
531
|
+
if (!session)
|
|
532
|
+
continue;
|
|
533
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
534
|
+
if (engine && isInterruptibleEngine(engine) && engine.isAlive(id)) {
|
|
535
|
+
engine.kill(id);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const count = deleteSessions(ids);
|
|
539
|
+
for (const id of ids) {
|
|
540
|
+
context.emit("session:deleted", { sessionId: id });
|
|
541
|
+
}
|
|
542
|
+
logger.info(`Bulk deleted ${count} sessions`);
|
|
543
|
+
return json(res, { status: "deleted", count });
|
|
544
|
+
}
|
|
545
|
+
// GET /api/sessions/:id/children
|
|
546
|
+
params = matchRoute("/api/sessions/:id/children", pathname);
|
|
547
|
+
if (method === "GET" && params) {
|
|
548
|
+
const children = listSessions().filter((s) => s.parentSessionId === params.id);
|
|
549
|
+
return json(res, children.map((child) => serializeSession(child, context)));
|
|
550
|
+
}
|
|
551
|
+
// GET /api/sessions/:id/transcript — return raw Claude Code session transcript
|
|
552
|
+
params = matchRoute("/api/sessions/:id/transcript", pathname);
|
|
553
|
+
if (method === "GET" && params) {
|
|
554
|
+
const session = getSession(params.id);
|
|
555
|
+
if (!session)
|
|
556
|
+
return notFound(res);
|
|
557
|
+
if (!session.engineSessionId)
|
|
558
|
+
return json(res, []);
|
|
559
|
+
const entries = loadRawTranscript(session.engineSessionId);
|
|
560
|
+
return json(res, entries);
|
|
561
|
+
}
|
|
562
|
+
// POST /api/sessions/stub — create a session with a pre-populated assistant
|
|
563
|
+
// message but do NOT run the engine. Used for lazy onboarding.
|
|
564
|
+
if (method === "POST" && pathname === "/api/sessions/stub") {
|
|
565
|
+
const _parsed = await readJsonBody(req, res);
|
|
566
|
+
if (!_parsed.ok)
|
|
567
|
+
return;
|
|
568
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
569
|
+
const body = _parsed.body;
|
|
570
|
+
const greeting = body.greeting || "Hey! Say hi when you're ready to get started.";
|
|
571
|
+
const config = context.getConfig();
|
|
572
|
+
const engineName = body.engine || config.engines.default;
|
|
573
|
+
const sessionKey = `web:${Date.now()}`;
|
|
574
|
+
const session = createSession({
|
|
575
|
+
engine: engineName,
|
|
576
|
+
source: "web",
|
|
577
|
+
sourceRef: sessionKey,
|
|
578
|
+
connector: "web",
|
|
579
|
+
sessionKey,
|
|
580
|
+
replyContext: { source: "web" },
|
|
581
|
+
employee: body.employee,
|
|
582
|
+
title: body.title,
|
|
583
|
+
portalName: config.portal?.portalName,
|
|
584
|
+
});
|
|
585
|
+
insertMessage(session.id, "assistant", greeting);
|
|
586
|
+
logger.info(`Stub session created: ${session.id}`);
|
|
587
|
+
return json(res, serializeSession(session, context), 201);
|
|
588
|
+
}
|
|
589
|
+
// POST /api/sessions
|
|
590
|
+
if (method === "POST" && pathname === "/api/sessions") {
|
|
591
|
+
const _parsed = await readJsonBody(req, res);
|
|
592
|
+
if (!_parsed.ok)
|
|
593
|
+
return;
|
|
594
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
595
|
+
const body = _parsed.body;
|
|
596
|
+
const prompt = body.prompt || body.message;
|
|
597
|
+
if (!prompt)
|
|
598
|
+
return badRequest(res, "prompt or message is required");
|
|
599
|
+
const config = context.getConfig();
|
|
600
|
+
const engineName = body.engine || config.engines.default;
|
|
601
|
+
const sessionKey = `web:${Date.now()}`;
|
|
602
|
+
const session = createSession({
|
|
603
|
+
engine: engineName,
|
|
604
|
+
source: "web",
|
|
605
|
+
sourceRef: sessionKey,
|
|
606
|
+
connector: "web",
|
|
607
|
+
sessionKey,
|
|
608
|
+
replyContext: { source: "web" },
|
|
609
|
+
employee: body.employee,
|
|
610
|
+
parentSessionId: body.parentSessionId,
|
|
611
|
+
effortLevel: body.effortLevel,
|
|
612
|
+
prompt,
|
|
613
|
+
portalName: config.portal?.portalName,
|
|
614
|
+
});
|
|
615
|
+
logger.info(`Web session created: ${session.id}`);
|
|
616
|
+
insertMessage(session.id, "user", prompt);
|
|
617
|
+
// Run engine asynchronously — respond immediately, push result via WebSocket
|
|
618
|
+
const engine = context.sessionManager.getEngine(engineName);
|
|
619
|
+
if (!engine) {
|
|
620
|
+
updateSession(session.id, {
|
|
621
|
+
status: "error",
|
|
622
|
+
lastError: `Engine "${engineName}" not available`,
|
|
623
|
+
});
|
|
624
|
+
return json(res, { ...serializeSession({ ...session, status: "error", lastError: `Engine "${engineName}" not available` }, context) }, 201);
|
|
625
|
+
}
|
|
626
|
+
// Set status to "running" synchronously BEFORE returning the response.
|
|
627
|
+
// This prevents a race condition where the caller polls immediately and
|
|
628
|
+
// sees "idle" status before runWebSession has a chance to set "running".
|
|
629
|
+
updateSession(session.id, {
|
|
630
|
+
status: "running",
|
|
631
|
+
lastActivity: new Date().toISOString(),
|
|
632
|
+
});
|
|
633
|
+
session.status = "running";
|
|
634
|
+
const attachmentPaths = resolveAttachmentPaths(body.attachments);
|
|
635
|
+
const queueSessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
636
|
+
const queueItemId = enqueueQueueItem(session.id, queueSessionKey, prompt);
|
|
637
|
+
context.emit("queue:updated", { sessionId: session.id, sessionKey: queueSessionKey });
|
|
638
|
+
dispatchWebSessionRun(session, prompt, engine, config, context, { queueItemId, attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined });
|
|
639
|
+
return json(res, serializeSession(session, context), 201);
|
|
640
|
+
}
|
|
641
|
+
// POST /api/sessions/:id/message
|
|
642
|
+
params = matchRoute("/api/sessions/:id/message", pathname);
|
|
643
|
+
if (method === "POST" && params) {
|
|
644
|
+
let session = getSession(params.id);
|
|
645
|
+
if (!session)
|
|
646
|
+
return notFound(res);
|
|
647
|
+
session = maybeRevertEngineOverride(session);
|
|
648
|
+
const _parsed = await readJsonBody(req, res);
|
|
649
|
+
if (!_parsed.ok)
|
|
650
|
+
return;
|
|
651
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
652
|
+
const body = _parsed.body;
|
|
653
|
+
const prompt = body.message || body.prompt;
|
|
654
|
+
if (!prompt)
|
|
655
|
+
return badRequest(res, "message is required");
|
|
656
|
+
// Allow internal callers (e.g. child session callbacks) to specify a non-user role
|
|
657
|
+
const messageRole = body.role === "notification" ? "notification" : "user";
|
|
658
|
+
const isNotification = messageRole === "notification";
|
|
659
|
+
const config = context.getConfig();
|
|
660
|
+
const engine = context.sessionManager.getEngine(session.engine);
|
|
661
|
+
if (!engine)
|
|
662
|
+
return serverError(res, `Engine "${session.engine}" not available`);
|
|
663
|
+
// Persist the message immediately
|
|
664
|
+
insertMessage(session.id, messageRole, prompt);
|
|
665
|
+
// Emit notification event for UI display (renders as system banner, not user bubble)
|
|
666
|
+
if (isNotification) {
|
|
667
|
+
context.emit("session:notification", { sessionId: session.id, message: prompt });
|
|
668
|
+
// Don't return early — fall through to enqueue + dispatch so the engine
|
|
669
|
+
// (e.g. the COO) actually processes the notification and can respond.
|
|
670
|
+
}
|
|
671
|
+
if (!isNotification && session.status === "waiting") {
|
|
672
|
+
const expectedResetAt = getClaudeExpectedResetAt();
|
|
673
|
+
const resumeText = expectedResetAt
|
|
674
|
+
? expectedResetAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
|
|
675
|
+
: null;
|
|
676
|
+
const queuedText = `⏳ Still paused due to Claude usage limit${resumeText ? ` (resets ${resumeText})` : ""}. Your message is queued and will run automatically.`;
|
|
677
|
+
insertMessage(session.id, "notification", queuedText);
|
|
678
|
+
context.emit("session:notification", { sessionId: session.id, message: queuedText });
|
|
679
|
+
}
|
|
680
|
+
// If a turn is already running, check whether we should interrupt or queue.
|
|
681
|
+
// Notifications (child completion callbacks) should never interrupt — just queue.
|
|
682
|
+
if (session.status === "running") {
|
|
683
|
+
if (!isNotification && (config.sessions?.interruptOnNewMessage ?? true) && isInterruptibleEngine(engine) && engine.isAlive(session.id)) {
|
|
684
|
+
logger.info(`Interrupting running session ${session.id} for new message`);
|
|
685
|
+
engine.kill(session.id, "Interrupted: new message received");
|
|
686
|
+
// Wait briefly for the process to exit so the queue slot frees up
|
|
687
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
688
|
+
context.emit("session:interrupted", { sessionId: session.id, reason: "new message" });
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
context.emit("session:queued", { sessionId: session.id, message: prompt });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// If session was interrupted by a restart, clear the error and resume
|
|
695
|
+
if (session.status === "interrupted") {
|
|
696
|
+
logger.info(`Resuming interrupted session ${session.id} (engineSessionId: ${session.engineSessionId})`);
|
|
697
|
+
updateSession(session.id, {
|
|
698
|
+
status: "running",
|
|
699
|
+
lastActivity: new Date().toISOString(),
|
|
700
|
+
lastError: null,
|
|
701
|
+
});
|
|
702
|
+
context.emit("session:resumed", { sessionId: session.id });
|
|
703
|
+
}
|
|
704
|
+
// Clear any pending cancellation so the new message runs normally.
|
|
705
|
+
context.sessionManager.getQueue().clearCancelled(session.sessionKey || session.sourceRef || session.id);
|
|
706
|
+
const attachmentPaths = resolveAttachmentPaths(body.attachments);
|
|
707
|
+
const sessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
708
|
+
const queueItemId = enqueueQueueItem(session.id, sessionKey, prompt);
|
|
709
|
+
context.emit("queue:updated", { sessionId: session.id, sessionKey });
|
|
710
|
+
dispatchWebSessionRun(session, prompt, engine, config, context, { queueItemId, attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined });
|
|
711
|
+
return json(res, { status: "queued", sessionId: session.id });
|
|
712
|
+
}
|
|
713
|
+
// GET /api/cron
|
|
714
|
+
if (method === "GET" && pathname === "/api/cron") {
|
|
715
|
+
const jobs = loadJobs();
|
|
716
|
+
// Enrich with last run status
|
|
717
|
+
const enriched = jobs.map((job) => {
|
|
718
|
+
const runFile = path.join(CRON_RUNS, `${job.id}.jsonl`);
|
|
719
|
+
let lastRun = null;
|
|
720
|
+
if (fs.existsSync(runFile)) {
|
|
721
|
+
const lines = fs.readFileSync(runFile, "utf-8").trim().split("\n").filter(Boolean);
|
|
722
|
+
if (lines.length > 0) {
|
|
723
|
+
try {
|
|
724
|
+
lastRun = JSON.parse(lines[lines.length - 1]);
|
|
725
|
+
}
|
|
726
|
+
catch { }
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return { ...job, lastRun };
|
|
730
|
+
});
|
|
731
|
+
return json(res, enriched);
|
|
732
|
+
}
|
|
733
|
+
// GET /api/cron/:id/runs
|
|
734
|
+
params = matchRoute("/api/cron/:id/runs", pathname);
|
|
735
|
+
if (method === "GET" && params) {
|
|
736
|
+
const runFile = path.join(CRON_RUNS, `${params.id}.jsonl`);
|
|
737
|
+
if (!fs.existsSync(runFile))
|
|
738
|
+
return json(res, []);
|
|
739
|
+
const lines = fs
|
|
740
|
+
.readFileSync(runFile, "utf-8")
|
|
741
|
+
.trim()
|
|
742
|
+
.split("\n")
|
|
743
|
+
.filter(Boolean)
|
|
744
|
+
.map((l) => JSON.parse(l));
|
|
745
|
+
return json(res, lines);
|
|
746
|
+
}
|
|
747
|
+
// POST /api/cron — create new cron job
|
|
748
|
+
if (method === "POST" && pathname === "/api/cron") {
|
|
749
|
+
const _parsed = await readJsonBody(req, res);
|
|
750
|
+
if (!_parsed.ok)
|
|
751
|
+
return;
|
|
752
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
753
|
+
const body = _parsed.body;
|
|
754
|
+
const jobs = loadJobs();
|
|
755
|
+
const newJob = {
|
|
756
|
+
id: body.id || crypto.randomUUID(),
|
|
757
|
+
name: body.name || "untitled",
|
|
758
|
+
enabled: body.enabled ?? true,
|
|
759
|
+
schedule: body.schedule || "0 * * * *",
|
|
760
|
+
timezone: body.timezone,
|
|
761
|
+
engine: body.engine,
|
|
762
|
+
model: body.model,
|
|
763
|
+
employee: body.employee,
|
|
764
|
+
prompt: body.prompt || "",
|
|
765
|
+
delivery: body.delivery,
|
|
766
|
+
};
|
|
767
|
+
jobs.push(newJob);
|
|
768
|
+
saveJobs(jobs);
|
|
769
|
+
reloadScheduler(jobs);
|
|
770
|
+
return json(res, newJob, 201);
|
|
771
|
+
}
|
|
772
|
+
// PUT /api/cron/:id
|
|
773
|
+
params = matchRoute("/api/cron/:id", pathname);
|
|
774
|
+
if (method === "PUT" && params) {
|
|
775
|
+
const jobs = loadJobs();
|
|
776
|
+
const idx = jobs.findIndex((j) => j.id === params.id);
|
|
777
|
+
if (idx === -1)
|
|
778
|
+
return notFound(res);
|
|
779
|
+
const _parsed = await readJsonBody(req, res);
|
|
780
|
+
if (!_parsed.ok)
|
|
781
|
+
return;
|
|
782
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
783
|
+
const body = _parsed.body;
|
|
784
|
+
jobs[idx] = { ...jobs[idx], ...body, id: params.id };
|
|
785
|
+
saveJobs(jobs);
|
|
786
|
+
reloadScheduler(jobs);
|
|
787
|
+
return json(res, jobs[idx]);
|
|
788
|
+
}
|
|
789
|
+
// DELETE /api/cron/:id
|
|
790
|
+
params = matchRoute("/api/cron/:id", pathname);
|
|
791
|
+
if (method === "DELETE" && params) {
|
|
792
|
+
const jobs = loadJobs();
|
|
793
|
+
const idx = jobs.findIndex((j) => j.id === params.id);
|
|
794
|
+
if (idx === -1)
|
|
795
|
+
return notFound(res);
|
|
796
|
+
const removed = jobs.splice(idx, 1)[0];
|
|
797
|
+
saveJobs(jobs);
|
|
798
|
+
reloadScheduler(jobs);
|
|
799
|
+
return json(res, { deleted: removed.id, name: removed.name });
|
|
800
|
+
}
|
|
801
|
+
// POST /api/cron/:id/trigger — manually run a cron job now
|
|
802
|
+
params = matchRoute("/api/cron/:id/trigger", pathname);
|
|
803
|
+
if (method === "POST" && params) {
|
|
804
|
+
const jobs = loadJobs();
|
|
805
|
+
const job = jobs.find((j) => j.id === params.id);
|
|
806
|
+
if (!job)
|
|
807
|
+
return notFound(res);
|
|
808
|
+
logger.info(`Manual trigger for cron job "${job.name}" (${job.id})`);
|
|
809
|
+
// Fire and forget — respond immediately, run in background
|
|
810
|
+
runCronJob(job, context.sessionManager, context.getConfig(), context.connectors).catch((err) => logger.error(`Manual cron trigger failed for "${job.name}": ${err}`));
|
|
811
|
+
return json(res, {
|
|
812
|
+
triggered: true,
|
|
813
|
+
jobId: job.id,
|
|
814
|
+
name: job.name,
|
|
815
|
+
employee: job.employee,
|
|
816
|
+
message: `Cron job "${job.name}" triggered manually`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
// GET /api/org
|
|
820
|
+
if (method === "GET" && pathname === "/api/org") {
|
|
821
|
+
if (!fs.existsSync(ORG_DIR))
|
|
822
|
+
return json(res, { departments: [], employees: [], hierarchy: { root: null, sorted: [], warnings: [] } });
|
|
823
|
+
const entries = fs.readdirSync(ORG_DIR, { withFileTypes: true });
|
|
824
|
+
const departments = entries
|
|
825
|
+
.filter((e) => e.isDirectory())
|
|
826
|
+
.map((e) => e.name);
|
|
827
|
+
const { scanOrg } = await import("./org.js");
|
|
828
|
+
const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
|
|
829
|
+
const orgRegistry = scanOrg();
|
|
830
|
+
const hierarchy = resolveOrgHierarchy(orgRegistry);
|
|
831
|
+
const employees = hierarchy.sorted.map((name) => {
|
|
832
|
+
const node = hierarchy.nodes[name];
|
|
833
|
+
const emp = node.employee;
|
|
834
|
+
const { persona, ...rest } = emp;
|
|
835
|
+
return {
|
|
836
|
+
...rest,
|
|
837
|
+
parentName: node.parentName,
|
|
838
|
+
directReports: node.directReports,
|
|
839
|
+
depth: node.depth,
|
|
840
|
+
chain: node.chain,
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
return json(res, {
|
|
844
|
+
departments,
|
|
845
|
+
employees,
|
|
846
|
+
hierarchy: {
|
|
847
|
+
root: hierarchy.root,
|
|
848
|
+
sorted: hierarchy.sorted,
|
|
849
|
+
warnings: hierarchy.warnings,
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
// GET /api/org/employees/:name
|
|
854
|
+
params = matchRoute("/api/org/employees/:name", pathname);
|
|
855
|
+
if (method === "GET" && params) {
|
|
856
|
+
const { scanOrg } = await import("./org.js");
|
|
857
|
+
const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
|
|
858
|
+
const orgRegistry = scanOrg();
|
|
859
|
+
const emp = orgRegistry.get(params.name);
|
|
860
|
+
if (!emp)
|
|
861
|
+
return notFound(res);
|
|
862
|
+
const hierarchy = resolveOrgHierarchy(orgRegistry);
|
|
863
|
+
const node = hierarchy.nodes[params.name];
|
|
864
|
+
return json(res, {
|
|
865
|
+
...emp,
|
|
866
|
+
parentName: node?.parentName ?? null,
|
|
867
|
+
directReports: node?.directReports ?? [],
|
|
868
|
+
depth: node?.depth ?? 0,
|
|
869
|
+
chain: node?.chain ?? [params.name],
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
// PATCH /api/org/employees/:name — update employee fields (currently only alwaysNotify)
|
|
873
|
+
params = matchRoute("/api/org/employees/:name", pathname);
|
|
874
|
+
if (method === "PATCH" && params) {
|
|
875
|
+
const _parsed = await readJsonBody(req, res);
|
|
876
|
+
if (!_parsed.ok)
|
|
877
|
+
return;
|
|
878
|
+
const body = _parsed.body;
|
|
879
|
+
const { updateEmployeeYaml } = await import("./org.js");
|
|
880
|
+
const updated = updateEmployeeYaml(params.name, {
|
|
881
|
+
alwaysNotify: typeof body.alwaysNotify === "boolean" ? body.alwaysNotify : undefined,
|
|
882
|
+
});
|
|
883
|
+
if (!updated)
|
|
884
|
+
return notFound(res);
|
|
885
|
+
context.emit("org:updated", { employee: params.name });
|
|
886
|
+
return json(res, { status: "ok" });
|
|
887
|
+
}
|
|
888
|
+
// GET /api/org/services — list all cross-department services
|
|
889
|
+
if (method === "GET" && pathname === "/api/org/services") {
|
|
890
|
+
const { scanOrg } = await import("./org.js");
|
|
891
|
+
const { buildServiceRegistry } = await import("./services.js");
|
|
892
|
+
const orgRegistry = scanOrg();
|
|
893
|
+
const services = buildServiceRegistry(orgRegistry);
|
|
894
|
+
const result = Array.from(services.values()).map((entry) => ({
|
|
895
|
+
name: entry.declaration.name,
|
|
896
|
+
description: entry.declaration.description,
|
|
897
|
+
provider: {
|
|
898
|
+
name: entry.provider.name,
|
|
899
|
+
displayName: entry.provider.displayName,
|
|
900
|
+
department: entry.provider.department,
|
|
901
|
+
rank: entry.provider.rank,
|
|
902
|
+
},
|
|
903
|
+
}));
|
|
904
|
+
return json(res, { services: result });
|
|
905
|
+
}
|
|
906
|
+
// POST /api/org/cross-request — route a service request to the provider
|
|
907
|
+
if (method === "POST" && pathname === "/api/org/cross-request") {
|
|
908
|
+
const parsed = await readJsonBody(req, res);
|
|
909
|
+
if (!parsed.ok)
|
|
910
|
+
return;
|
|
911
|
+
const body = parsed.body;
|
|
912
|
+
const { fromEmployee, service, prompt, parentSessionId } = body;
|
|
913
|
+
if (!fromEmployee || !service || !prompt) {
|
|
914
|
+
return badRequest(res, "Missing required fields: fromEmployee, service, prompt");
|
|
915
|
+
}
|
|
916
|
+
const { scanOrg } = await import("./org.js");
|
|
917
|
+
const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
|
|
918
|
+
const { buildServiceRegistry, buildRoutePath, resolveManagerChain } = await import("./services.js");
|
|
919
|
+
const orgRegistry = scanOrg();
|
|
920
|
+
const requester = orgRegistry.get(fromEmployee);
|
|
921
|
+
if (!requester)
|
|
922
|
+
return notFound(res);
|
|
923
|
+
const services = buildServiceRegistry(orgRegistry);
|
|
924
|
+
const entry = services.get(service);
|
|
925
|
+
if (!entry) {
|
|
926
|
+
return json(res, { error: `Service "${service}" not found` }, 404);
|
|
927
|
+
}
|
|
928
|
+
const hierarchy = resolveOrgHierarchy(orgRegistry);
|
|
929
|
+
const route = buildRoutePath(fromEmployee, entry.provider.name, hierarchy);
|
|
930
|
+
const managers = resolveManagerChain(route, hierarchy);
|
|
931
|
+
const crossBrief = `## Cross-service request
|
|
932
|
+
|
|
933
|
+
**From**: ${requester.displayName} (${requester.department})
|
|
934
|
+
**Service**: ${service} — ${entry.declaration.description}
|
|
935
|
+
|
|
936
|
+
### Request
|
|
937
|
+
${prompt}
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
Handle this as a priority request from a colleague.`;
|
|
941
|
+
const config = context.getConfig();
|
|
942
|
+
const session = createSession({
|
|
943
|
+
engine: entry.provider.engine || config.engines.default,
|
|
944
|
+
model: entry.provider.model || undefined,
|
|
945
|
+
source: "cross-request",
|
|
946
|
+
sourceRef: `cross:${fromEmployee}:${service}`,
|
|
947
|
+
connector: "web",
|
|
948
|
+
sessionKey: `cross:${Date.now()}`,
|
|
949
|
+
replyContext: { source: "cross-request" },
|
|
950
|
+
employee: entry.provider.name,
|
|
951
|
+
parentSessionId: parentSessionId || undefined,
|
|
952
|
+
prompt: crossBrief,
|
|
953
|
+
portalName: config.portal?.portalName,
|
|
954
|
+
title: `Cross-request: ${fromEmployee} → ${service}`,
|
|
955
|
+
});
|
|
956
|
+
insertMessage(session.id, "user", crossBrief);
|
|
957
|
+
logger.info(`Cross-request session created: ${session.id} (${fromEmployee} → ${service} → ${entry.provider.name})`);
|
|
958
|
+
return json(res, {
|
|
959
|
+
sessionId: session.id,
|
|
960
|
+
provider: {
|
|
961
|
+
name: entry.provider.name,
|
|
962
|
+
displayName: entry.provider.displayName,
|
|
963
|
+
department: entry.provider.department,
|
|
964
|
+
},
|
|
965
|
+
route,
|
|
966
|
+
managers: managers.map((m) => m.employee.name),
|
|
967
|
+
service,
|
|
968
|
+
}, 201);
|
|
969
|
+
}
|
|
970
|
+
// GET /api/org/departments/:name/board
|
|
971
|
+
params = matchRoute("/api/org/departments/:name/board", pathname);
|
|
972
|
+
if (method === "GET" && params) {
|
|
973
|
+
const boardPath = path.join(ORG_DIR, params.name, "board.json");
|
|
974
|
+
if (!fs.existsSync(boardPath))
|
|
975
|
+
return notFound(res);
|
|
976
|
+
const board = JSON.parse(fs.readFileSync(boardPath, "utf-8"));
|
|
977
|
+
return json(res, board);
|
|
978
|
+
}
|
|
979
|
+
// PUT /api/org/departments/:name/board
|
|
980
|
+
if (method === "PUT" && matchRoute("/api/org/departments/:name/board", pathname)) {
|
|
981
|
+
const p = matchRoute("/api/org/departments/:name/board", pathname);
|
|
982
|
+
const boardPath = path.join(ORG_DIR, p.name, "board.json");
|
|
983
|
+
const deptDir = path.join(ORG_DIR, p.name);
|
|
984
|
+
if (!fs.existsSync(deptDir))
|
|
985
|
+
return notFound(res);
|
|
986
|
+
const _parsed = await readJsonBody(req, res);
|
|
987
|
+
if (!_parsed.ok)
|
|
988
|
+
return;
|
|
989
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
990
|
+
const body = _parsed.body;
|
|
991
|
+
fs.writeFileSync(boardPath, JSON.stringify(body, null, 2));
|
|
992
|
+
context.emit("board:updated", { department: p.name });
|
|
993
|
+
return json(res, { status: "ok" });
|
|
994
|
+
}
|
|
995
|
+
// GET /api/skills/search?q=<query> — search the skills.sh registry
|
|
996
|
+
if (method === "GET" && pathname === "/api/skills/search") {
|
|
997
|
+
const query = url.searchParams.get("q") || "";
|
|
998
|
+
if (!query)
|
|
999
|
+
return badRequest(res, "q parameter is required");
|
|
1000
|
+
try {
|
|
1001
|
+
const { execFileSync } = await import("node:child_process");
|
|
1002
|
+
const output = execFileSync("npx", ["skills", "find", query], {
|
|
1003
|
+
encoding: "utf-8",
|
|
1004
|
+
timeout: 30000,
|
|
1005
|
+
});
|
|
1006
|
+
const results = parseSkillsSearchOutput(output);
|
|
1007
|
+
return json(res, results);
|
|
1008
|
+
}
|
|
1009
|
+
catch (err) {
|
|
1010
|
+
const msg = err instanceof Error ? err.stderr || err.message : String(err);
|
|
1011
|
+
return json(res, { results: [], error: msg });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// GET /api/skills/manifest — return skills.json contents
|
|
1015
|
+
if (method === "GET" && pathname === "/api/skills/manifest") {
|
|
1016
|
+
const { readManifest } = await import("../cli/skills.js");
|
|
1017
|
+
return json(res, readManifest());
|
|
1018
|
+
}
|
|
1019
|
+
// POST /api/skills/install — install a skill from skills.sh
|
|
1020
|
+
if (method === "POST" && pathname === "/api/skills/install") {
|
|
1021
|
+
const _parsed = await readJsonBody(req, res);
|
|
1022
|
+
if (!_parsed.ok)
|
|
1023
|
+
return;
|
|
1024
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1025
|
+
const body = _parsed.body;
|
|
1026
|
+
const source = body.source;
|
|
1027
|
+
if (!source)
|
|
1028
|
+
return badRequest(res, "source is required");
|
|
1029
|
+
try {
|
|
1030
|
+
const { snapshotDirs, diffSnapshots, copySkillToInstance, upsertManifest, extractSkillName, findExistingSkill, } = await import("../cli/skills.js");
|
|
1031
|
+
const { execFileSync } = await import("node:child_process");
|
|
1032
|
+
const before = snapshotDirs();
|
|
1033
|
+
execFileSync("npx", ["skills", "add", String(source), "-g", "-y"], {
|
|
1034
|
+
encoding: "utf-8",
|
|
1035
|
+
timeout: 60000,
|
|
1036
|
+
});
|
|
1037
|
+
const after = snapshotDirs();
|
|
1038
|
+
const newDirs = diffSnapshots(before, after);
|
|
1039
|
+
let skillName;
|
|
1040
|
+
if (newDirs.length > 0) {
|
|
1041
|
+
const installed = newDirs[0];
|
|
1042
|
+
skillName = installed.name;
|
|
1043
|
+
copySkillToInstance(installed.name, path.join(installed.dir, installed.name));
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
skillName = extractSkillName(source);
|
|
1047
|
+
const existing = findExistingSkill(skillName);
|
|
1048
|
+
if (existing) {
|
|
1049
|
+
copySkillToInstance(existing.name, existing.dir);
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
return serverError(res, "Skill installed globally but could not locate the directory");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
upsertManifest(skillName, source);
|
|
1056
|
+
return json(res, { status: "installed", name: skillName });
|
|
1057
|
+
}
|
|
1058
|
+
catch (err) {
|
|
1059
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1060
|
+
return serverError(res, msg);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// GET /api/skills
|
|
1064
|
+
if (method === "GET" && pathname === "/api/skills") {
|
|
1065
|
+
if (!fs.existsSync(SKILLS_DIR))
|
|
1066
|
+
return json(res, []);
|
|
1067
|
+
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
|
|
1068
|
+
const skills = entries.filter((e) => e.isDirectory()).map((e) => {
|
|
1069
|
+
const skillMdPath = path.join(SKILLS_DIR, e.name, "SKILL.md");
|
|
1070
|
+
let description = "";
|
|
1071
|
+
if (fs.existsSync(skillMdPath)) {
|
|
1072
|
+
const content = fs.readFileSync(skillMdPath, "utf-8");
|
|
1073
|
+
// Extract description from YAML frontmatter, ## Trigger section, or first paragraph
|
|
1074
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
1075
|
+
if (frontmatterMatch) {
|
|
1076
|
+
const descMatch = frontmatterMatch[1].match(/^description:\s*(.+)$/m);
|
|
1077
|
+
if (descMatch) {
|
|
1078
|
+
description = descMatch[1].trim();
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
if (!description) {
|
|
1082
|
+
const triggerMatch = content.match(/##\s*Trigger\s*\n+([^\n#]+)/);
|
|
1083
|
+
if (triggerMatch) {
|
|
1084
|
+
description = triggerMatch[1].trim();
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
// Use first non-heading, non-empty, non-frontmatter line
|
|
1088
|
+
const bodyContent = frontmatterMatch ? content.slice(frontmatterMatch[0].length) : content;
|
|
1089
|
+
const lines = bodyContent.split("\n");
|
|
1090
|
+
for (const line of lines) {
|
|
1091
|
+
const trimmed = line.trim();
|
|
1092
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
1093
|
+
description = trimmed;
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return { name: e.name, description };
|
|
1101
|
+
});
|
|
1102
|
+
return json(res, skills);
|
|
1103
|
+
}
|
|
1104
|
+
// GET /api/skills/:name
|
|
1105
|
+
params = matchRoute("/api/skills/:name", pathname);
|
|
1106
|
+
if (method === "GET" && params) {
|
|
1107
|
+
const skillMd = path.join(SKILLS_DIR, params.name, "SKILL.md");
|
|
1108
|
+
if (!fs.existsSync(skillMd))
|
|
1109
|
+
return notFound(res);
|
|
1110
|
+
const content = fs.readFileSync(skillMd, "utf-8");
|
|
1111
|
+
return json(res, { name: params.name, content });
|
|
1112
|
+
}
|
|
1113
|
+
// DELETE /api/skills/:name — remove a skill
|
|
1114
|
+
params = matchRoute("/api/skills/:name", pathname);
|
|
1115
|
+
if (method === "DELETE" && params) {
|
|
1116
|
+
const skillDir = path.join(SKILLS_DIR, params.name);
|
|
1117
|
+
if (!fs.existsSync(skillDir))
|
|
1118
|
+
return notFound(res);
|
|
1119
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
1120
|
+
const { removeFromManifest } = await import("../cli/skills.js");
|
|
1121
|
+
removeFromManifest(params.name);
|
|
1122
|
+
logger.info(`Skill removed via API: ${params.name}`);
|
|
1123
|
+
return json(res, { status: "removed", name: params.name });
|
|
1124
|
+
}
|
|
1125
|
+
// GET /api/config
|
|
1126
|
+
if (method === "GET" && pathname === "/api/config") {
|
|
1127
|
+
const config = context.getConfig();
|
|
1128
|
+
// Sanitize: remove any secrets/tokens from connectors
|
|
1129
|
+
const rawConnectors = config.connectors || {};
|
|
1130
|
+
const sanitizedConnectors = {};
|
|
1131
|
+
for (const [k, v] of Object.entries(rawConnectors)) {
|
|
1132
|
+
if (k === "instances" && Array.isArray(v)) {
|
|
1133
|
+
sanitizedConnectors.instances = v.map((inst) => ({
|
|
1134
|
+
...inst,
|
|
1135
|
+
token: inst?.token ? "***" : undefined,
|
|
1136
|
+
signingSecret: inst?.signingSecret ? "***" : undefined,
|
|
1137
|
+
botToken: inst?.botToken ? "***" : undefined,
|
|
1138
|
+
appToken: inst?.appToken ? "***" : undefined,
|
|
1139
|
+
}));
|
|
1140
|
+
}
|
|
1141
|
+
else if (v && typeof v === "object") {
|
|
1142
|
+
sanitizedConnectors[k] = {
|
|
1143
|
+
...v,
|
|
1144
|
+
token: v?.token ? "***" : undefined,
|
|
1145
|
+
signingSecret: v?.signingSecret ? "***" : undefined,
|
|
1146
|
+
botToken: v?.botToken ? "***" : undefined,
|
|
1147
|
+
appToken: v?.appToken ? "***" : undefined,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
sanitizedConnectors[k] = v;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const sanitized = {
|
|
1155
|
+
...config,
|
|
1156
|
+
connectors: sanitizedConnectors,
|
|
1157
|
+
};
|
|
1158
|
+
return json(res, sanitized);
|
|
1159
|
+
}
|
|
1160
|
+
// PUT /api/config
|
|
1161
|
+
if (method === "PUT" && pathname === "/api/config") {
|
|
1162
|
+
const _parsed = await readJsonBody(req, res);
|
|
1163
|
+
if (!_parsed.ok)
|
|
1164
|
+
return;
|
|
1165
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1166
|
+
const body = _parsed.body;
|
|
1167
|
+
// Basic validation: must be a plain object
|
|
1168
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1169
|
+
return badRequest(res, "Config must be a JSON object");
|
|
1170
|
+
}
|
|
1171
|
+
// Validate known top-level keys
|
|
1172
|
+
// Keep this aligned with `JinnConfig` in src/shared/types.ts
|
|
1173
|
+
const KNOWN_KEYS = [
|
|
1174
|
+
"jinn",
|
|
1175
|
+
"gateway",
|
|
1176
|
+
"engines",
|
|
1177
|
+
"connectors",
|
|
1178
|
+
"logging",
|
|
1179
|
+
"mcp",
|
|
1180
|
+
"sessions",
|
|
1181
|
+
"cron",
|
|
1182
|
+
"notifications",
|
|
1183
|
+
"portal",
|
|
1184
|
+
"context",
|
|
1185
|
+
"stt",
|
|
1186
|
+
"skills",
|
|
1187
|
+
"remotes",
|
|
1188
|
+
];
|
|
1189
|
+
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.includes(k));
|
|
1190
|
+
if (unknownKeys.length > 0) {
|
|
1191
|
+
return badRequest(res, `Unknown config keys: ${unknownKeys.join(", ")}`);
|
|
1192
|
+
}
|
|
1193
|
+
// Validate critical field types
|
|
1194
|
+
if (body.gateway !== undefined) {
|
|
1195
|
+
if (typeof body.gateway !== "object" || Array.isArray(body.gateway)) {
|
|
1196
|
+
return badRequest(res, "gateway must be an object");
|
|
1197
|
+
}
|
|
1198
|
+
if (body.gateway.port !== undefined && typeof body.gateway.port !== "number") {
|
|
1199
|
+
return badRequest(res, "gateway.port must be a number");
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (body.engines !== undefined && (typeof body.engines !== "object" || Array.isArray(body.engines))) {
|
|
1203
|
+
return badRequest(res, "engines must be an object");
|
|
1204
|
+
}
|
|
1205
|
+
// Deep-merge incoming config with existing config to preserve
|
|
1206
|
+
// fields not included in the update (e.g. connector tokens).
|
|
1207
|
+
let existing = {};
|
|
1208
|
+
try {
|
|
1209
|
+
existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
|
|
1210
|
+
}
|
|
1211
|
+
catch { /* start fresh if unreadable */ }
|
|
1212
|
+
const merged = deepMerge(existing, body);
|
|
1213
|
+
const yamlStr = yaml.dump(merged);
|
|
1214
|
+
fs.writeFileSync(CONFIG_PATH, yamlStr);
|
|
1215
|
+
logger.info("Config updated via API");
|
|
1216
|
+
return json(res, { status: "ok" });
|
|
1217
|
+
}
|
|
1218
|
+
// GET /api/logs
|
|
1219
|
+
if (method === "GET" && pathname === "/api/logs") {
|
|
1220
|
+
const logFile = path.join(LOGS_DIR, "gateway.log");
|
|
1221
|
+
if (!fs.existsSync(logFile))
|
|
1222
|
+
return json(res, { lines: [] });
|
|
1223
|
+
const n = parseInt(url.searchParams.get("n") || "100", 10);
|
|
1224
|
+
// Read only the last 64KB to avoid loading the entire file into memory
|
|
1225
|
+
const MAX_BYTES = 64 * 1024;
|
|
1226
|
+
const stat = fs.statSync(logFile);
|
|
1227
|
+
const readSize = Math.min(stat.size, MAX_BYTES);
|
|
1228
|
+
const fd = fs.openSync(logFile, "r");
|
|
1229
|
+
const buf = Buffer.alloc(readSize);
|
|
1230
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
1231
|
+
fs.closeSync(fd);
|
|
1232
|
+
const allLines = buf.toString("utf-8").split("\n").filter(Boolean);
|
|
1233
|
+
const lines = allLines.slice(-n);
|
|
1234
|
+
return json(res, { lines });
|
|
1235
|
+
}
|
|
1236
|
+
// POST /api/connectors/reload — stop all instance connectors and restart from config
|
|
1237
|
+
if (method === "POST" && pathname === "/api/connectors/reload") {
|
|
1238
|
+
if (!context.reloadConnectorInstances) {
|
|
1239
|
+
return json(res, { error: "Connector reload not available" }, 501);
|
|
1240
|
+
}
|
|
1241
|
+
try {
|
|
1242
|
+
const result = await context.reloadConnectorInstances();
|
|
1243
|
+
context.emit("connectors:reloaded", result);
|
|
1244
|
+
return json(res, result);
|
|
1245
|
+
}
|
|
1246
|
+
catch (err) {
|
|
1247
|
+
return json(res, { error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
// POST /api/connectors/:id/incoming — receive proxied Discord messages from primary instance
|
|
1251
|
+
// Supports both the legacy /api/connectors/discord/incoming and named instance ids
|
|
1252
|
+
params = matchRoute("/api/connectors/:id/incoming", pathname);
|
|
1253
|
+
if (method === "POST" && params && params.id) {
|
|
1254
|
+
// Try the exact instance id first, then fall back to "discord" for the legacy path
|
|
1255
|
+
const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
|
|
1256
|
+
if (!connector)
|
|
1257
|
+
return notFound(res);
|
|
1258
|
+
if (!("deliverMessage" in connector)) {
|
|
1259
|
+
return json(res, { error: "Discord connector is not in remote mode" }, 400);
|
|
1260
|
+
}
|
|
1261
|
+
const _parsed = await readJsonBody(req, res);
|
|
1262
|
+
if (!_parsed.ok)
|
|
1263
|
+
return;
|
|
1264
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1265
|
+
const body = _parsed.body;
|
|
1266
|
+
// Download attachments from Discord CDN URLs to local temp
|
|
1267
|
+
const { downloadAttachment } = await import("../connectors/discord/format.js");
|
|
1268
|
+
const attachments = await Promise.all((body.attachments || []).map(async (att) => {
|
|
1269
|
+
if (att.url) {
|
|
1270
|
+
try {
|
|
1271
|
+
const localPath = await downloadAttachment(att.url, TMP_DIR, att.name);
|
|
1272
|
+
return { name: att.name, url: att.url, mimeType: att.mimeType, localPath };
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
return { name: att.name, url: att.url, mimeType: att.mimeType };
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return att;
|
|
1279
|
+
}));
|
|
1280
|
+
const incomingMsg = {
|
|
1281
|
+
connector: params.id,
|
|
1282
|
+
source: "discord",
|
|
1283
|
+
sessionKey: body.sessionKey,
|
|
1284
|
+
channel: body.channel,
|
|
1285
|
+
thread: body.thread,
|
|
1286
|
+
user: body.user,
|
|
1287
|
+
userId: body.userId,
|
|
1288
|
+
text: body.text,
|
|
1289
|
+
messageId: body.messageId,
|
|
1290
|
+
attachments,
|
|
1291
|
+
replyContext: body.replyContext || {},
|
|
1292
|
+
transportMeta: body.transportMeta,
|
|
1293
|
+
raw: body,
|
|
1294
|
+
};
|
|
1295
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1296
|
+
connector.deliverMessage(incomingMsg);
|
|
1297
|
+
return json(res, { status: "delivered" });
|
|
1298
|
+
}
|
|
1299
|
+
// POST /api/connectors/:id/proxy — proxy connector operations from remote instances
|
|
1300
|
+
// Supports both the legacy /api/connectors/discord/proxy and named instance ids
|
|
1301
|
+
params = matchRoute("/api/connectors/:id/proxy", pathname);
|
|
1302
|
+
if (method === "POST" && params && params.id) {
|
|
1303
|
+
const connector = context.connectors.get(params.id) ?? (params.id === "discord" ? context.connectors.get("discord") : undefined);
|
|
1304
|
+
if (!connector)
|
|
1305
|
+
return notFound(res);
|
|
1306
|
+
const _parsed = await readJsonBody(req, res);
|
|
1307
|
+
if (!_parsed.ok)
|
|
1308
|
+
return;
|
|
1309
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1310
|
+
const body = _parsed.body;
|
|
1311
|
+
const action = body.action;
|
|
1312
|
+
const target = body.target;
|
|
1313
|
+
let messageId;
|
|
1314
|
+
switch (action) {
|
|
1315
|
+
case "sendMessage":
|
|
1316
|
+
if (!target || !body.text)
|
|
1317
|
+
return badRequest(res, "target and text are required");
|
|
1318
|
+
messageId = (await connector.sendMessage(target, body.text));
|
|
1319
|
+
break;
|
|
1320
|
+
case "replyMessage":
|
|
1321
|
+
if (!target || !body.text)
|
|
1322
|
+
return badRequest(res, "target and text are required");
|
|
1323
|
+
messageId = (await connector.replyMessage(target, body.text));
|
|
1324
|
+
break;
|
|
1325
|
+
case "editMessage":
|
|
1326
|
+
if (!target || !body.text)
|
|
1327
|
+
return badRequest(res, "target and text are required");
|
|
1328
|
+
await connector.editMessage(target, body.text);
|
|
1329
|
+
break;
|
|
1330
|
+
case "addReaction":
|
|
1331
|
+
if (!target || !body.emoji)
|
|
1332
|
+
return badRequest(res, "target and emoji are required");
|
|
1333
|
+
await connector.addReaction(target, body.emoji);
|
|
1334
|
+
break;
|
|
1335
|
+
case "removeReaction":
|
|
1336
|
+
if (!target || !body.emoji)
|
|
1337
|
+
return badRequest(res, "target and emoji are required");
|
|
1338
|
+
await connector.removeReaction(target, body.emoji);
|
|
1339
|
+
break;
|
|
1340
|
+
case "setTypingStatus":
|
|
1341
|
+
if (connector.setTypingStatus) {
|
|
1342
|
+
await connector.setTypingStatus(body.channelId ?? "", body.threadTs, body.status ?? "");
|
|
1343
|
+
}
|
|
1344
|
+
break;
|
|
1345
|
+
default:
|
|
1346
|
+
return badRequest(res, `Unknown proxy action: ${action}`);
|
|
1347
|
+
}
|
|
1348
|
+
return json(res, { status: "ok", messageId });
|
|
1349
|
+
}
|
|
1350
|
+
// POST /api/connectors/:name/send — send a message via a connector
|
|
1351
|
+
params = matchRoute("/api/connectors/:name/send", pathname);
|
|
1352
|
+
if (method === "POST" && params) {
|
|
1353
|
+
const connector = context.connectors.get(params.name);
|
|
1354
|
+
if (!connector)
|
|
1355
|
+
return notFound(res);
|
|
1356
|
+
const _parsed = await readJsonBody(req, res);
|
|
1357
|
+
if (!_parsed.ok)
|
|
1358
|
+
return;
|
|
1359
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1360
|
+
const body = _parsed.body;
|
|
1361
|
+
if (!body.channel || !body.text)
|
|
1362
|
+
return badRequest(res, "channel and text are required");
|
|
1363
|
+
await connector.sendMessage({ channel: body.channel, thread: body.thread }, body.text);
|
|
1364
|
+
return json(res, { status: "sent" });
|
|
1365
|
+
}
|
|
1366
|
+
// GET /api/connectors/whatsapp/qr — return current QR code as PNG data URL
|
|
1367
|
+
if (method === "GET" && pathname === "/api/connectors/whatsapp/qr") {
|
|
1368
|
+
const waConnector = context.connectors.get("whatsapp");
|
|
1369
|
+
if (!waConnector)
|
|
1370
|
+
return notFound(res);
|
|
1371
|
+
const qrString = waConnector.getQrCode();
|
|
1372
|
+
if (!qrString)
|
|
1373
|
+
return json(res, { qr: null });
|
|
1374
|
+
const dataUrl = await QRCode.toDataURL(qrString, { width: 256, margin: 2 });
|
|
1375
|
+
return json(res, { qr: dataUrl });
|
|
1376
|
+
}
|
|
1377
|
+
// GET /api/connectors — list available connectors
|
|
1378
|
+
if (method === "GET" && pathname === "/api/connectors") {
|
|
1379
|
+
const connectors = Array.from(context.connectors.entries()).map(([instanceId, connector]) => ({
|
|
1380
|
+
name: connector.name,
|
|
1381
|
+
instanceId,
|
|
1382
|
+
employee: connector.getEmployee?.() ?? undefined,
|
|
1383
|
+
...connector.getHealth(),
|
|
1384
|
+
}));
|
|
1385
|
+
return json(res, connectors);
|
|
1386
|
+
}
|
|
1387
|
+
// GET /api/activity — recent activity derived from sessions
|
|
1388
|
+
if (method === "GET" && pathname === "/api/activity") {
|
|
1389
|
+
const sessions = listSessions();
|
|
1390
|
+
const events = [];
|
|
1391
|
+
for (const s of sessions) {
|
|
1392
|
+
const ts = new Date(s.lastActivity || s.createdAt).getTime();
|
|
1393
|
+
const transportState = context.sessionManager.getQueue().getTransportState(s.sessionKey || s.sourceRef, s.status);
|
|
1394
|
+
if (transportState === "running") {
|
|
1395
|
+
events.push({ event: "session:started", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
|
|
1396
|
+
}
|
|
1397
|
+
else if (transportState === "queued") {
|
|
1398
|
+
events.push({ event: "session:queued", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
|
|
1399
|
+
}
|
|
1400
|
+
else if (transportState === "idle") {
|
|
1401
|
+
events.push({ event: "session:completed", payload: { sessionId: s.id, employee: s.employee, engine: s.engine, connector: s.connector }, ts });
|
|
1402
|
+
}
|
|
1403
|
+
else if (transportState === "error") {
|
|
1404
|
+
events.push({ event: "session:error", payload: { sessionId: s.id, employee: s.employee, error: s.lastError, connector: s.connector }, ts });
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
events.sort((a, b) => b.ts - a.ts);
|
|
1408
|
+
return json(res, events.slice(0, 30));
|
|
1409
|
+
}
|
|
1410
|
+
// GET /api/onboarding — check if onboarding is needed
|
|
1411
|
+
if (method === "GET" && pathname === "/api/onboarding") {
|
|
1412
|
+
const sessions = listSessions();
|
|
1413
|
+
const hasEmployees = fs.existsSync(ORG_DIR) &&
|
|
1414
|
+
fs.readdirSync(ORG_DIR, { recursive: true }).some((f) => String(f).endsWith(".yaml") && !String(f).endsWith("department.yaml"));
|
|
1415
|
+
const config = context.getConfig();
|
|
1416
|
+
const onboarded = config.portal?.onboarded === true;
|
|
1417
|
+
return json(res, {
|
|
1418
|
+
needed: !onboarded && sessions.length === 0 && !hasEmployees,
|
|
1419
|
+
onboarded,
|
|
1420
|
+
sessionsCount: sessions.length,
|
|
1421
|
+
hasEmployees,
|
|
1422
|
+
portalName: config.portal?.portalName ?? null,
|
|
1423
|
+
operatorName: config.portal?.operatorName ?? null,
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
// POST /api/onboarding — persist portal personalization
|
|
1427
|
+
if (method === "POST" && pathname === "/api/onboarding") {
|
|
1428
|
+
const _parsed = await readJsonBody(req, res);
|
|
1429
|
+
if (!_parsed.ok)
|
|
1430
|
+
return;
|
|
1431
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1432
|
+
const body = _parsed.body;
|
|
1433
|
+
const { portalName, operatorName, language } = body;
|
|
1434
|
+
// Read current config and merge portal settings
|
|
1435
|
+
const config = context.getConfig();
|
|
1436
|
+
const updated = {
|
|
1437
|
+
...config,
|
|
1438
|
+
portal: {
|
|
1439
|
+
...config.portal,
|
|
1440
|
+
onboarded: true,
|
|
1441
|
+
...(portalName !== undefined && { portalName: portalName || undefined }),
|
|
1442
|
+
...(operatorName !== undefined && { operatorName: operatorName || undefined }),
|
|
1443
|
+
...(language !== undefined && { language: language || undefined }),
|
|
1444
|
+
},
|
|
1445
|
+
};
|
|
1446
|
+
// Write updated config
|
|
1447
|
+
const yamlStr = yaml.dump(updated, { lineWidth: -1 });
|
|
1448
|
+
fs.writeFileSync(CONFIG_PATH, yamlStr);
|
|
1449
|
+
logger.info(`Onboarding: portal name="${portalName}", operator="${operatorName}", language="${language}"`);
|
|
1450
|
+
const effectiveName = portalName || "Ryoko";
|
|
1451
|
+
const languageSection = language && language !== "English"
|
|
1452
|
+
? `\n\n## Language\nAlways respond in ${language}. All communication with the user must be in ${language}.`
|
|
1453
|
+
: "";
|
|
1454
|
+
// Update CLAUDE.md with personalized COO name and language
|
|
1455
|
+
const claudeMdPath = path.join(JINN_HOME, "CLAUDE.md");
|
|
1456
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
1457
|
+
let claudeMd = fs.readFileSync(claudeMdPath, "utf-8");
|
|
1458
|
+
// Replace the identity line in CLAUDE.md
|
|
1459
|
+
claudeMd = claudeMd.replace(/^You are \w+, the COO of the user's AI organization\.$/m, `You are ${effectiveName}, the COO of the user's AI organization.`);
|
|
1460
|
+
// Remove existing language section if present, then add new one if needed
|
|
1461
|
+
claudeMd = claudeMd.replace(/\n\n## Language\nAlways respond in .+\. All communication with the user must be in .+\./m, "");
|
|
1462
|
+
if (languageSection) {
|
|
1463
|
+
claudeMd = claudeMd.trimEnd() + languageSection + "\n";
|
|
1464
|
+
}
|
|
1465
|
+
fs.writeFileSync(claudeMdPath, claudeMd);
|
|
1466
|
+
}
|
|
1467
|
+
// Update AGENTS.md with personalized name and language
|
|
1468
|
+
const agentsMdPath = path.join(JINN_HOME, "AGENTS.md");
|
|
1469
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
1470
|
+
let agentsMd = fs.readFileSync(agentsMdPath, "utf-8");
|
|
1471
|
+
// Replace the bold identity line (e.g. "You are **Jinn**")
|
|
1472
|
+
agentsMd = agentsMd.replace(/You are \*\*\w+\*\*/, `You are **${effectiveName}**`);
|
|
1473
|
+
// Remove existing language section if present, then add new one if needed
|
|
1474
|
+
agentsMd = agentsMd.replace(/\n\n## Language\nAlways respond in .+\. All communication with the user must be in .+\./m, "");
|
|
1475
|
+
if (languageSection) {
|
|
1476
|
+
agentsMd = agentsMd.trimEnd() + languageSection + "\n";
|
|
1477
|
+
}
|
|
1478
|
+
fs.writeFileSync(agentsMdPath, agentsMd);
|
|
1479
|
+
}
|
|
1480
|
+
context.emit("config:updated", { portal: updated.portal });
|
|
1481
|
+
return json(res, { status: "ok", portal: updated.portal });
|
|
1482
|
+
}
|
|
1483
|
+
// ── STT (Speech-to-Text) ──────────────────────────────────
|
|
1484
|
+
if (method === "GET" && pathname === "/api/stt/status") {
|
|
1485
|
+
const config = context.getConfig();
|
|
1486
|
+
const languages = resolveLanguages(config.stt);
|
|
1487
|
+
const status = getSttStatus(config.stt?.model, languages);
|
|
1488
|
+
return json(res, status);
|
|
1489
|
+
}
|
|
1490
|
+
if (method === "POST" && pathname === "/api/stt/download") {
|
|
1491
|
+
const config = context.getConfig();
|
|
1492
|
+
const model = config.stt?.model || "small";
|
|
1493
|
+
downloadModel(model, (progress) => {
|
|
1494
|
+
context.emit("stt:download:progress", { progress });
|
|
1495
|
+
}).then(() => {
|
|
1496
|
+
// Update config to mark STT as enabled
|
|
1497
|
+
try {
|
|
1498
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
1499
|
+
const cfg = yaml.load(raw);
|
|
1500
|
+
if (!cfg.stt || typeof cfg.stt !== "object")
|
|
1501
|
+
cfg.stt = {};
|
|
1502
|
+
const sttCfg = cfg.stt;
|
|
1503
|
+
sttCfg.enabled = true;
|
|
1504
|
+
sttCfg.model = model;
|
|
1505
|
+
if (!sttCfg.languages)
|
|
1506
|
+
sttCfg.languages = ["en"];
|
|
1507
|
+
fs.writeFileSync(CONFIG_PATH, yaml.dump(cfg, { lineWidth: -1 }));
|
|
1508
|
+
}
|
|
1509
|
+
catch (err) {
|
|
1510
|
+
logger.error(`Failed to update config after STT download: ${err}`);
|
|
1511
|
+
}
|
|
1512
|
+
context.emit("stt:download:complete", { model });
|
|
1513
|
+
}).catch((err) => {
|
|
1514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1515
|
+
logger.error(`STT download failed: ${msg}`);
|
|
1516
|
+
context.emit("stt:download:error", { error: msg });
|
|
1517
|
+
});
|
|
1518
|
+
return json(res, { status: "downloading", model });
|
|
1519
|
+
}
|
|
1520
|
+
if (method === "POST" && pathname === "/api/stt/transcribe") {
|
|
1521
|
+
const config = context.getConfig();
|
|
1522
|
+
const model = config.stt?.model || "small";
|
|
1523
|
+
const languages = resolveLanguages(config.stt);
|
|
1524
|
+
// Accept language from query param, fall back to first configured language
|
|
1525
|
+
const requestedLang = url.searchParams.get("language");
|
|
1526
|
+
const language = requestedLang && languages.includes(requestedLang) ? requestedLang : languages[0];
|
|
1527
|
+
const audioBuffer = await readBodyRaw(req);
|
|
1528
|
+
if (audioBuffer.length === 0)
|
|
1529
|
+
return badRequest(res, "No audio data");
|
|
1530
|
+
if (audioBuffer.length > 100 * 1024 * 1024)
|
|
1531
|
+
return badRequest(res, "Audio too large (100MB max)");
|
|
1532
|
+
const contentType = req.headers["content-type"] || "audio/webm";
|
|
1533
|
+
const ext = contentType.includes("wav") ? ".wav"
|
|
1534
|
+
: contentType.includes("mp4") || contentType.includes("m4a") ? ".m4a"
|
|
1535
|
+
: contentType.includes("ogg") ? ".ogg"
|
|
1536
|
+
: ".webm";
|
|
1537
|
+
const tmpFile = path.join(TMP_DIR, `stt-${crypto.randomUUID()}${ext}`);
|
|
1538
|
+
fs.mkdirSync(TMP_DIR, { recursive: true });
|
|
1539
|
+
fs.writeFileSync(tmpFile, audioBuffer);
|
|
1540
|
+
try {
|
|
1541
|
+
const text = await sttTranscribe(tmpFile, model, language);
|
|
1542
|
+
return json(res, { text });
|
|
1543
|
+
}
|
|
1544
|
+
catch (err) {
|
|
1545
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1546
|
+
logger.error(`STT transcription failed: ${msg}`);
|
|
1547
|
+
return serverError(res, `Transcription failed: ${msg}`);
|
|
1548
|
+
}
|
|
1549
|
+
finally {
|
|
1550
|
+
try {
|
|
1551
|
+
fs.unlinkSync(tmpFile);
|
|
1552
|
+
}
|
|
1553
|
+
catch { }
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
if (method === "PUT" && pathname === "/api/stt/config") {
|
|
1557
|
+
const _parsed = await readJsonBody(req, res);
|
|
1558
|
+
if (!_parsed.ok)
|
|
1559
|
+
return;
|
|
1560
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1561
|
+
const body = _parsed.body;
|
|
1562
|
+
const langs = body.languages;
|
|
1563
|
+
if (!Array.isArray(langs) || langs.length === 0) {
|
|
1564
|
+
return badRequest(res, "languages must be a non-empty array");
|
|
1565
|
+
}
|
|
1566
|
+
const invalid = langs.filter((l) => typeof l !== "string" || !WHISPER_LANGUAGES[l]);
|
|
1567
|
+
if (invalid.length > 0) {
|
|
1568
|
+
return badRequest(res, `Invalid language codes: ${invalid.join(", ")}`);
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
1572
|
+
const cfg = yaml.load(raw);
|
|
1573
|
+
if (!cfg.stt || typeof cfg.stt !== "object")
|
|
1574
|
+
cfg.stt = {};
|
|
1575
|
+
const sttCfg = cfg.stt;
|
|
1576
|
+
sttCfg.languages = langs;
|
|
1577
|
+
// Remove deprecated language field if present
|
|
1578
|
+
delete sttCfg.language;
|
|
1579
|
+
fs.writeFileSync(CONFIG_PATH, yaml.dump(cfg, { lineWidth: -1 }));
|
|
1580
|
+
return json(res, { status: "ok", languages: langs });
|
|
1581
|
+
}
|
|
1582
|
+
catch (err) {
|
|
1583
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1584
|
+
return serverError(res, `Failed to update STT config: ${msg}`);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
// /api/files — file upload/download/management
|
|
1588
|
+
if (pathname.startsWith("/api/files")) {
|
|
1589
|
+
const handled = await handleFilesRequest(req, res, pathname, method, context);
|
|
1590
|
+
if (handled)
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
// ── Goals ────────────────────────────────────────────────────────
|
|
1594
|
+
// GET /api/goals
|
|
1595
|
+
if (method === "GET" && pathname === "/api/goals") {
|
|
1596
|
+
const { listGoals } = await import("./goals.js");
|
|
1597
|
+
const db = initDb();
|
|
1598
|
+
return json(res, listGoals(db));
|
|
1599
|
+
}
|
|
1600
|
+
// GET /api/goals/tree
|
|
1601
|
+
if (method === "GET" && pathname === "/api/goals/tree") {
|
|
1602
|
+
const { getGoalTree } = await import("./goals.js");
|
|
1603
|
+
const db = initDb();
|
|
1604
|
+
return json(res, getGoalTree(db));
|
|
1605
|
+
}
|
|
1606
|
+
// POST /api/goals
|
|
1607
|
+
if (method === "POST" && pathname === "/api/goals") {
|
|
1608
|
+
const _parsed = await readJsonBody(req, res);
|
|
1609
|
+
if (!_parsed.ok)
|
|
1610
|
+
return;
|
|
1611
|
+
const { createGoal } = await import("./goals.js");
|
|
1612
|
+
const db = initDb();
|
|
1613
|
+
const goal = createGoal(db, _parsed.body);
|
|
1614
|
+
return json(res, goal, 201);
|
|
1615
|
+
}
|
|
1616
|
+
// GET /api/goals/:id
|
|
1617
|
+
params = matchRoute("/api/goals/:id", pathname);
|
|
1618
|
+
if (method === "GET" && params) {
|
|
1619
|
+
const { getGoal } = await import("./goals.js");
|
|
1620
|
+
const db = initDb();
|
|
1621
|
+
const goal = getGoal(db, params.id);
|
|
1622
|
+
if (!goal)
|
|
1623
|
+
return notFound(res);
|
|
1624
|
+
return json(res, goal);
|
|
1625
|
+
}
|
|
1626
|
+
// PUT /api/goals/:id
|
|
1627
|
+
params = matchRoute("/api/goals/:id", pathname);
|
|
1628
|
+
if (method === "PUT" && params) {
|
|
1629
|
+
const _parsed = await readJsonBody(req, res);
|
|
1630
|
+
if (!_parsed.ok)
|
|
1631
|
+
return;
|
|
1632
|
+
const { updateGoal } = await import("./goals.js");
|
|
1633
|
+
const db = initDb();
|
|
1634
|
+
const goal = updateGoal(db, params.id, _parsed.body);
|
|
1635
|
+
if (!goal)
|
|
1636
|
+
return notFound(res);
|
|
1637
|
+
return json(res, goal);
|
|
1638
|
+
}
|
|
1639
|
+
// DELETE /api/goals/:id
|
|
1640
|
+
params = matchRoute("/api/goals/:id", pathname);
|
|
1641
|
+
if (method === "DELETE" && params) {
|
|
1642
|
+
const { deleteGoal } = await import("./goals.js");
|
|
1643
|
+
const db = initDb();
|
|
1644
|
+
deleteGoal(db, params.id);
|
|
1645
|
+
return json(res, { status: "ok" });
|
|
1646
|
+
}
|
|
1647
|
+
// ── Costs ────────────────────────────────────────────────────────
|
|
1648
|
+
// GET /api/costs/summary
|
|
1649
|
+
if (method === "GET" && pathname === "/api/costs/summary") {
|
|
1650
|
+
const { getCostSummary } = await import("./costs.js");
|
|
1651
|
+
const rawPeriod = url.searchParams.get("period") ?? "month";
|
|
1652
|
+
const period = (rawPeriod === "day" || rawPeriod === "week" || rawPeriod === "month") ? rawPeriod : "month";
|
|
1653
|
+
return json(res, getCostSummary(period));
|
|
1654
|
+
}
|
|
1655
|
+
// GET /api/costs/by-employee
|
|
1656
|
+
if (method === "GET" && pathname === "/api/costs/by-employee") {
|
|
1657
|
+
const { getCostsByEmployee } = await import("./costs.js");
|
|
1658
|
+
const rawPeriod = url.searchParams.get("period") ?? "month";
|
|
1659
|
+
const period = (rawPeriod === "week") ? "week" : "month";
|
|
1660
|
+
return json(res, getCostsByEmployee(period));
|
|
1661
|
+
}
|
|
1662
|
+
// ── Budgets ──────────────────────────────────────────────────────
|
|
1663
|
+
// GET /api/budgets
|
|
1664
|
+
if (method === "GET" && pathname === "/api/budgets") {
|
|
1665
|
+
const { getBudgetStatus } = await import("./budgets.js");
|
|
1666
|
+
const config = context.getConfig();
|
|
1667
|
+
const budgetConfig = config.budgets?.employees ?? {};
|
|
1668
|
+
const employees = Object.keys(budgetConfig);
|
|
1669
|
+
const statuses = employees.map((emp) => ({
|
|
1670
|
+
employee: emp,
|
|
1671
|
+
...getBudgetStatus(emp, budgetConfig),
|
|
1672
|
+
}));
|
|
1673
|
+
return json(res, { employees: budgetConfig, statuses });
|
|
1674
|
+
}
|
|
1675
|
+
// PUT /api/budgets
|
|
1676
|
+
if (method === "PUT" && pathname === "/api/budgets") {
|
|
1677
|
+
const _parsed = await readJsonBody(req, res);
|
|
1678
|
+
if (!_parsed.ok)
|
|
1679
|
+
return;
|
|
1680
|
+
const body = _parsed.body;
|
|
1681
|
+
let existing = {};
|
|
1682
|
+
try {
|
|
1683
|
+
existing = yaml.load(fs.readFileSync(CONFIG_PATH, "utf-8")) || {};
|
|
1684
|
+
}
|
|
1685
|
+
catch { /* start fresh if unreadable */ }
|
|
1686
|
+
const merged = deepMerge(existing, { budgets: { employees: body } });
|
|
1687
|
+
fs.writeFileSync(CONFIG_PATH, yaml.dump(merged));
|
|
1688
|
+
logger.info("Budget limits updated via API");
|
|
1689
|
+
return json(res, { status: "ok" });
|
|
1690
|
+
}
|
|
1691
|
+
// POST /api/budgets/:employee/override
|
|
1692
|
+
params = matchRoute("/api/budgets/:employee/override", pathname);
|
|
1693
|
+
if (method === "POST" && params) {
|
|
1694
|
+
const { overrideBudget } = await import("./budgets.js");
|
|
1695
|
+
const config = context.getConfig();
|
|
1696
|
+
const budgetConfig = config.budgets?.employees ?? {};
|
|
1697
|
+
return json(res, overrideBudget(params.employee, budgetConfig));
|
|
1698
|
+
}
|
|
1699
|
+
// GET /api/budgets/events
|
|
1700
|
+
if (method === "GET" && pathname === "/api/budgets/events") {
|
|
1701
|
+
const { getBudgetEvents } = await import("./budgets.js");
|
|
1702
|
+
return json(res, getBudgetEvents());
|
|
1703
|
+
}
|
|
1704
|
+
return notFound(res);
|
|
1705
|
+
}
|
|
1706
|
+
catch (err) {
|
|
1707
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1708
|
+
logger.error(`API error: ${msg}`);
|
|
1709
|
+
return serverError(res, msg);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Parse the output of `npx skills find <query>` into structured results.
|
|
1714
|
+
*
|
|
1715
|
+
* Format:
|
|
1716
|
+
* ```
|
|
1717
|
+
* owner/repo@skill-name <N> installs
|
|
1718
|
+
* └ https://skills.sh/owner/repo/skill-name
|
|
1719
|
+
* ```
|
|
1720
|
+
*/
|
|
1721
|
+
function stripAnsi(str) {
|
|
1722
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1723
|
+
}
|
|
1724
|
+
function parseSkillsSearchOutput(output) {
|
|
1725
|
+
const results = [];
|
|
1726
|
+
const lines = output.trim().split("\n");
|
|
1727
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1728
|
+
const headerLine = stripAnsi(lines[i]).trim();
|
|
1729
|
+
// Match "owner/repo@skill-name <N> installs"
|
|
1730
|
+
const headerMatch = headerLine.match(/^(\S+)\s+(\d+)\s+installs?$/);
|
|
1731
|
+
if (!headerMatch)
|
|
1732
|
+
continue;
|
|
1733
|
+
const source = headerMatch[1];
|
|
1734
|
+
const installs = parseInt(headerMatch[2], 10);
|
|
1735
|
+
const atIdx = source.lastIndexOf("@");
|
|
1736
|
+
const name = atIdx > 0 ? source.slice(atIdx + 1) : source;
|
|
1737
|
+
// Next line should be the URL
|
|
1738
|
+
let url = "";
|
|
1739
|
+
if (i + 1 < lines.length) {
|
|
1740
|
+
const urlLine = stripAnsi(lines[i + 1]).trim();
|
|
1741
|
+
const urlMatch = urlLine.match(/[└]\s*(https?:\/\/\S+)/);
|
|
1742
|
+
if (urlMatch) {
|
|
1743
|
+
url = urlMatch[1];
|
|
1744
|
+
i++; // consume the URL line
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
results.push({ name, source, url, installs });
|
|
1748
|
+
}
|
|
1749
|
+
return results;
|
|
1750
|
+
}
|
|
1751
|
+
function loadRawTranscript(engineSessionId) {
|
|
1752
|
+
const claudeProjectsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "projects");
|
|
1753
|
+
if (!fs.existsSync(claudeProjectsDir))
|
|
1754
|
+
return [];
|
|
1755
|
+
const projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
|
|
1756
|
+
for (const dir of projectDirs) {
|
|
1757
|
+
if (!dir.isDirectory())
|
|
1758
|
+
continue;
|
|
1759
|
+
const jsonlPath = path.join(claudeProjectsDir, dir.name, `${engineSessionId}.jsonl`);
|
|
1760
|
+
if (!fs.existsSync(jsonlPath))
|
|
1761
|
+
continue;
|
|
1762
|
+
const entries = [];
|
|
1763
|
+
const lines = fs.readFileSync(jsonlPath, "utf-8").trim().split("\n").filter(Boolean);
|
|
1764
|
+
for (const line of lines) {
|
|
1765
|
+
try {
|
|
1766
|
+
const obj = JSON.parse(line);
|
|
1767
|
+
const type = obj.type;
|
|
1768
|
+
if (type !== "user" && type !== "assistant")
|
|
1769
|
+
continue;
|
|
1770
|
+
const msg = obj.message;
|
|
1771
|
+
if (!msg)
|
|
1772
|
+
continue;
|
|
1773
|
+
const rawContent = msg.content;
|
|
1774
|
+
const blocks = [];
|
|
1775
|
+
if (typeof rawContent === "string") {
|
|
1776
|
+
if (rawContent.trim())
|
|
1777
|
+
blocks.push({ type: "text", text: rawContent });
|
|
1778
|
+
}
|
|
1779
|
+
else if (Array.isArray(rawContent)) {
|
|
1780
|
+
for (const block of rawContent) {
|
|
1781
|
+
if (!block || typeof block !== "object")
|
|
1782
|
+
continue;
|
|
1783
|
+
const b = block;
|
|
1784
|
+
const blockType = String(b.type || "");
|
|
1785
|
+
if (blockType === "text") {
|
|
1786
|
+
blocks.push({ type: "text", text: String(b.text || "") });
|
|
1787
|
+
}
|
|
1788
|
+
else if (blockType === "tool_use") {
|
|
1789
|
+
blocks.push({
|
|
1790
|
+
type: "tool_use",
|
|
1791
|
+
name: String(b.name || ""),
|
|
1792
|
+
input: b.input || {},
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
else if (blockType === "tool_result") {
|
|
1796
|
+
const resultContent = b.content;
|
|
1797
|
+
let resultText;
|
|
1798
|
+
if (typeof resultContent === "string") {
|
|
1799
|
+
resultText = resultContent;
|
|
1800
|
+
}
|
|
1801
|
+
else if (Array.isArray(resultContent)) {
|
|
1802
|
+
resultText = resultContent
|
|
1803
|
+
.filter((rc) => rc.type === "text")
|
|
1804
|
+
.map((rc) => String(rc.text || ""))
|
|
1805
|
+
.join("");
|
|
1806
|
+
}
|
|
1807
|
+
else {
|
|
1808
|
+
resultText = "";
|
|
1809
|
+
}
|
|
1810
|
+
blocks.push({ type: "tool_result", text: resultText });
|
|
1811
|
+
}
|
|
1812
|
+
else if (blockType === "thinking") {
|
|
1813
|
+
blocks.push({ type: "thinking", text: String(b.thinking || b.text || "") });
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
if (blocks.length > 0) {
|
|
1818
|
+
entries.push({ role: type, content: blocks });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return entries;
|
|
1826
|
+
}
|
|
1827
|
+
return [];
|
|
1828
|
+
}
|
|
1829
|
+
function loadTranscriptMessages(engineSessionId) {
|
|
1830
|
+
// Claude Code stores transcripts in ~/.claude/projects/<project-key>/<sessionId>.jsonl
|
|
1831
|
+
const claudeProjectsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude", "projects");
|
|
1832
|
+
if (!fs.existsSync(claudeProjectsDir))
|
|
1833
|
+
return [];
|
|
1834
|
+
// Search all project dirs for the transcript
|
|
1835
|
+
const projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
|
|
1836
|
+
for (const dir of projectDirs) {
|
|
1837
|
+
if (!dir.isDirectory())
|
|
1838
|
+
continue;
|
|
1839
|
+
const jsonlPath = path.join(claudeProjectsDir, dir.name, `${engineSessionId}.jsonl`);
|
|
1840
|
+
if (!fs.existsSync(jsonlPath))
|
|
1841
|
+
continue;
|
|
1842
|
+
const messages = [];
|
|
1843
|
+
const lines = fs.readFileSync(jsonlPath, "utf-8").trim().split("\n").filter(Boolean);
|
|
1844
|
+
for (const line of lines) {
|
|
1845
|
+
try {
|
|
1846
|
+
const obj = JSON.parse(line);
|
|
1847
|
+
const type = obj.type;
|
|
1848
|
+
if (type !== "user" && type !== "assistant")
|
|
1849
|
+
continue;
|
|
1850
|
+
const msg = obj.message;
|
|
1851
|
+
if (!msg)
|
|
1852
|
+
continue;
|
|
1853
|
+
let content = msg.content;
|
|
1854
|
+
if (Array.isArray(content)) {
|
|
1855
|
+
content = content
|
|
1856
|
+
.filter((b) => b.type === "text")
|
|
1857
|
+
.map((b) => b.text)
|
|
1858
|
+
.join("");
|
|
1859
|
+
}
|
|
1860
|
+
if (typeof content === "string" && content.trim()) {
|
|
1861
|
+
messages.push({ role: type, content: content.trim() });
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
catch {
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return messages;
|
|
1869
|
+
}
|
|
1870
|
+
return [];
|
|
1871
|
+
}
|
|
1872
|
+
async function runWebSession(session, prompt, engine, config, context, attachments) {
|
|
1873
|
+
const currentSession = getSession(session.id);
|
|
1874
|
+
if (!currentSession) {
|
|
1875
|
+
logger.info(`Skipping deleted web session ${session.id} before run start`);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
logger.info(`Web session ${currentSession.id} running engine "${currentSession.engine}" (model: ${currentSession.model || "default"})`);
|
|
1879
|
+
// Ensure status is "running" (may already be set by the POST handler)
|
|
1880
|
+
const currentStatus = getSession(currentSession.id);
|
|
1881
|
+
if (currentStatus && currentStatus.status !== "running") {
|
|
1882
|
+
updateSession(currentSession.id, {
|
|
1883
|
+
status: "running",
|
|
1884
|
+
lastActivity: new Date().toISOString(),
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
// If this session has an assigned employee, load their persona
|
|
1888
|
+
let employee;
|
|
1889
|
+
if (currentSession.employee) {
|
|
1890
|
+
const { findEmployee } = await import("./org.js");
|
|
1891
|
+
const { scanOrg } = await import("./org.js");
|
|
1892
|
+
const registry = scanOrg();
|
|
1893
|
+
employee = findEmployee(currentSession.employee, registry);
|
|
1894
|
+
}
|
|
1895
|
+
const { scanOrg: scanOrgForHierarchy } = await import("./org.js");
|
|
1896
|
+
const { resolveOrgHierarchy } = await import("./org-hierarchy.js");
|
|
1897
|
+
const orgHierarchy = resolveOrgHierarchy(scanOrgForHierarchy());
|
|
1898
|
+
try {
|
|
1899
|
+
const systemPrompt = buildContext({
|
|
1900
|
+
source: "web",
|
|
1901
|
+
channel: currentSession.sourceRef,
|
|
1902
|
+
user: "web-user",
|
|
1903
|
+
employee,
|
|
1904
|
+
connectors: Array.from(context.connectors.keys()),
|
|
1905
|
+
config,
|
|
1906
|
+
sessionId: currentSession.id,
|
|
1907
|
+
hierarchy: orgHierarchy,
|
|
1908
|
+
});
|
|
1909
|
+
const engineConfig = currentSession.engine === "codex"
|
|
1910
|
+
? config.engines.codex
|
|
1911
|
+
: currentSession.engine === "gemini"
|
|
1912
|
+
? config.engines.gemini ?? config.engines.claude
|
|
1913
|
+
: config.engines.claude;
|
|
1914
|
+
const effortLevel = resolveEffort(engineConfig, currentSession, employee);
|
|
1915
|
+
let lastHeartbeatAt = 0;
|
|
1916
|
+
const runHeartbeat = setInterval(() => {
|
|
1917
|
+
updateSession(currentSession.id, {
|
|
1918
|
+
status: "running",
|
|
1919
|
+
lastActivity: new Date().toISOString(),
|
|
1920
|
+
});
|
|
1921
|
+
}, 5000);
|
|
1922
|
+
const syncSinceIso = currentSession.transportMeta?.claudeSyncSince;
|
|
1923
|
+
const syncSinceMs = typeof syncSinceIso === "string" ? new Date(syncSinceIso).getTime() : NaN;
|
|
1924
|
+
const syncRequested = currentSession.engine === "claude" && typeof syncSinceIso === "string" && Number.isFinite(syncSinceMs);
|
|
1925
|
+
const promptToRun = syncRequested
|
|
1926
|
+
? (() => {
|
|
1927
|
+
const sinceMessages = getMessages(currentSession.id)
|
|
1928
|
+
.filter((m) => (m.role === "user" || m.role === "assistant") && m.timestamp >= syncSinceMs)
|
|
1929
|
+
.map((m) => `${m.role.toUpperCase()}: ${m.content}`);
|
|
1930
|
+
const transcript = sinceMessages.slice(-20).join("\n\n");
|
|
1931
|
+
return `We temporarily switched to GPT due to a Claude usage limit. Sync your context with this transcript (most recent last), then respond to the last USER message.\n\n${transcript}`;
|
|
1932
|
+
})()
|
|
1933
|
+
: prompt;
|
|
1934
|
+
const result = await engine.run({
|
|
1935
|
+
prompt: promptToRun,
|
|
1936
|
+
resumeSessionId: currentSession.engineSessionId ?? undefined,
|
|
1937
|
+
systemPrompt,
|
|
1938
|
+
cwd: JINN_HOME,
|
|
1939
|
+
bin: engineConfig.bin,
|
|
1940
|
+
model: currentSession.model ?? engineConfig.model,
|
|
1941
|
+
effortLevel,
|
|
1942
|
+
cliFlags: employee?.cliFlags,
|
|
1943
|
+
attachments: attachments?.length ? attachments : undefined,
|
|
1944
|
+
sessionId: currentSession.id,
|
|
1945
|
+
onStream: (delta) => {
|
|
1946
|
+
const now = Date.now();
|
|
1947
|
+
if (now - lastHeartbeatAt >= 2000) {
|
|
1948
|
+
lastHeartbeatAt = now;
|
|
1949
|
+
updateSession(currentSession.id, {
|
|
1950
|
+
status: "running",
|
|
1951
|
+
lastActivity: new Date(now).toISOString(),
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
try {
|
|
1955
|
+
context.emit("session:delta", {
|
|
1956
|
+
sessionId: currentSession.id,
|
|
1957
|
+
type: delta.type,
|
|
1958
|
+
content: delta.content,
|
|
1959
|
+
toolName: delta.toolName,
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
catch (err) {
|
|
1963
|
+
logger.warn(`Failed to emit stream delta for session ${currentSession.id}: ${err instanceof Error ? err.message : err}`);
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
}).finally(() => {
|
|
1967
|
+
clearInterval(runHeartbeat);
|
|
1968
|
+
});
|
|
1969
|
+
if (!getSession(currentSession.id)) {
|
|
1970
|
+
logger.info(`Skipping completion for deleted web session ${currentSession.id}`);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const wasInterrupted = result.error?.startsWith("Interrupted");
|
|
1974
|
+
const rateLimit = !wasInterrupted ? detectRateLimit(result) : { limited: false };
|
|
1975
|
+
if (rateLimit.limited) {
|
|
1976
|
+
recordClaudeRateLimit(rateLimit.resetsAt);
|
|
1977
|
+
const strategy = config.sessions?.rateLimitStrategy ?? "fallback";
|
|
1978
|
+
// Optional fallback: switch to GPT (Codex) while Claude resets
|
|
1979
|
+
if (currentSession.engine === "claude" && strategy === "fallback") {
|
|
1980
|
+
const fallbackName = config.sessions?.fallbackEngine ?? "codex";
|
|
1981
|
+
const fallbackEngine = context.sessionManager.getEngine(fallbackName);
|
|
1982
|
+
if (fallbackEngine) {
|
|
1983
|
+
const { resumeAt } = computeNextRetryDelayMs(rateLimit.resetsAt);
|
|
1984
|
+
const until = resumeAt ?? new Date(Date.now() + 6 * 60 * 60_000);
|
|
1985
|
+
const syncSince = new Date().toISOString();
|
|
1986
|
+
const resumeText = resumeAt
|
|
1987
|
+
? resumeAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
|
|
1988
|
+
: null;
|
|
1989
|
+
const notificationText = `⚠️ Claude usage limit reached${resumeText ? `. Resets ${resumeText}` : ""}. Switching to GPT for now.`;
|
|
1990
|
+
insertMessage(currentSession.id, "notification", notificationText);
|
|
1991
|
+
context.emit("session:notification", { sessionId: currentSession.id, message: notificationText });
|
|
1992
|
+
const nextMeta = { ...(currentSession.transportMeta || {}) };
|
|
1993
|
+
const engineSessionsRaw = nextMeta.engineSessions;
|
|
1994
|
+
const engineSessions = (engineSessionsRaw && typeof engineSessionsRaw === "object" && !Array.isArray(engineSessionsRaw))
|
|
1995
|
+
? { ...engineSessionsRaw }
|
|
1996
|
+
: {};
|
|
1997
|
+
if (currentSession.engineSessionId) {
|
|
1998
|
+
engineSessions.claude = currentSession.engineSessionId;
|
|
1999
|
+
}
|
|
2000
|
+
nextMeta.engineSessions = engineSessions;
|
|
2001
|
+
nextMeta.engineOverride = { originalEngine: "claude", originalEngineSessionId: currentSession.engineSessionId, until: until.toISOString(), syncSince };
|
|
2002
|
+
updateSession(currentSession.id, {
|
|
2003
|
+
engine: fallbackName,
|
|
2004
|
+
transportMeta: nextMeta,
|
|
2005
|
+
status: "running",
|
|
2006
|
+
lastActivity: new Date().toISOString(),
|
|
2007
|
+
lastError: resumeAt
|
|
2008
|
+
? `Claude usage limit — using GPT until ${resumeAt.toISOString()}`
|
|
2009
|
+
: "Claude usage limit — using GPT temporarily",
|
|
2010
|
+
});
|
|
2011
|
+
notifyDiscordChannel(`⚠️ Claude usage limit reached. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} switching to GPT.`);
|
|
2012
|
+
const fallbackConfig = config.engines.codex;
|
|
2013
|
+
const fallbackEffort = resolveEffort(fallbackConfig, currentSession, employee);
|
|
2014
|
+
const codexResume = typeof engineSessions.codex === "string" ? engineSessions.codex : undefined;
|
|
2015
|
+
const history = getMessages(currentSession.id)
|
|
2016
|
+
.filter((m) => m.role === "user" || m.role === "assistant")
|
|
2017
|
+
.map((m) => `${m.role.toUpperCase()}: ${m.content}`);
|
|
2018
|
+
const historyText = history.slice(-12).join("\n\n");
|
|
2019
|
+
const fallbackPrompt = codexResume
|
|
2020
|
+
? prompt
|
|
2021
|
+
: `Continue this conversation and respond to the last USER message.\n\nConversation so far:\n\n${historyText}`;
|
|
2022
|
+
const fallbackResult = await fallbackEngine.run({
|
|
2023
|
+
prompt: fallbackPrompt,
|
|
2024
|
+
resumeSessionId: codexResume,
|
|
2025
|
+
systemPrompt,
|
|
2026
|
+
cwd: JINN_HOME,
|
|
2027
|
+
bin: fallbackConfig.bin,
|
|
2028
|
+
model: currentSession.model ?? fallbackConfig.model,
|
|
2029
|
+
effortLevel: fallbackEffort,
|
|
2030
|
+
cliFlags: employee?.cliFlags,
|
|
2031
|
+
sessionId: currentSession.id,
|
|
2032
|
+
onStream: (delta) => {
|
|
2033
|
+
context.emit("session:delta", {
|
|
2034
|
+
sessionId: currentSession.id,
|
|
2035
|
+
type: delta.type,
|
|
2036
|
+
content: delta.content,
|
|
2037
|
+
toolName: delta.toolName,
|
|
2038
|
+
});
|
|
2039
|
+
},
|
|
2040
|
+
});
|
|
2041
|
+
if (fallbackResult.result) {
|
|
2042
|
+
insertMessage(currentSession.id, "assistant", fallbackResult.result);
|
|
2043
|
+
}
|
|
2044
|
+
// Persist Codex thread id so future fallbacks can resume it
|
|
2045
|
+
const nextEngineSessions = { ...engineSessions };
|
|
2046
|
+
if (fallbackResult.sessionId) {
|
|
2047
|
+
nextEngineSessions.codex = fallbackResult.sessionId;
|
|
2048
|
+
}
|
|
2049
|
+
const metaAfter = { ...(getSession(currentSession.id)?.transportMeta || nextMeta) };
|
|
2050
|
+
metaAfter.engineSessions = nextEngineSessions;
|
|
2051
|
+
updateSession(currentSession.id, { transportMeta: metaAfter });
|
|
2052
|
+
const completedFallback = updateSession(currentSession.id, {
|
|
2053
|
+
engineSessionId: fallbackResult.sessionId,
|
|
2054
|
+
status: fallbackResult.error ? "error" : "idle",
|
|
2055
|
+
lastActivity: new Date().toISOString(),
|
|
2056
|
+
lastError: fallbackResult.error ?? null,
|
|
2057
|
+
});
|
|
2058
|
+
if (completedFallback) {
|
|
2059
|
+
notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2060
|
+
}
|
|
2061
|
+
context.emit("session:completed", {
|
|
2062
|
+
sessionId: currentSession.id,
|
|
2063
|
+
employee: currentSession.employee || config.portal?.portalName || "Ryoko",
|
|
2064
|
+
title: currentSession.title,
|
|
2065
|
+
result: fallbackResult.result,
|
|
2066
|
+
error: fallbackResult.error || null,
|
|
2067
|
+
cost: fallbackResult.cost,
|
|
2068
|
+
durationMs: fallbackResult.durationMs,
|
|
2069
|
+
});
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
// Otherwise: wait until reset and retry automatically
|
|
2074
|
+
const { delayMs, resumeAt } = computeNextRetryDelayMs(rateLimit.resetsAt);
|
|
2075
|
+
const deadlineMs = computeRateLimitDeadlineMs(rateLimit.resetsAt, rateLimit.resetsAt ? 30 * 60_000 : 6 * 60 * 60_000);
|
|
2076
|
+
const resumeText = resumeAt
|
|
2077
|
+
? resumeAt.toLocaleString("en-GB", { weekday: "short", day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" })
|
|
2078
|
+
: null;
|
|
2079
|
+
logger.info(`Web session ${currentSession.id} hit Claude usage limit — will auto-retry ${resumeAt ? `at ${resumeAt.toISOString()}` : `in ${Math.round(delayMs / 1000)}s`}`);
|
|
2080
|
+
// Send hardcoded Discord notification — does not depend on the LLM
|
|
2081
|
+
notifyDiscordChannel(`⚠️ Claude usage limit reached. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} paused${resumeText ? ` until ${resumeText}` : ""}.`);
|
|
2082
|
+
const notificationText = `⏳ Claude usage limit reached${resumeText ? `. Resets ${resumeText}` : ""} — I'll continue automatically.`;
|
|
2083
|
+
insertMessage(currentSession.id, "notification", notificationText);
|
|
2084
|
+
context.emit("session:notification", { sessionId: currentSession.id, message: notificationText });
|
|
2085
|
+
const waitingSession = updateSession(currentSession.id, {
|
|
2086
|
+
...(result.sessionId?.trim() ? { engineSessionId: result.sessionId } : {}),
|
|
2087
|
+
status: "waiting",
|
|
2088
|
+
lastActivity: new Date().toISOString(),
|
|
2089
|
+
lastError: resumeAt
|
|
2090
|
+
? `Claude usage limit — resumes ${resumeAt.toISOString()}`
|
|
2091
|
+
: "Claude usage limit — waiting for reset",
|
|
2092
|
+
});
|
|
2093
|
+
// Notify parent session about rate limit (fire-and-forget)
|
|
2094
|
+
notifyRateLimited((waitingSession ?? { ...currentSession, status: "waiting" }), resumeAt
|
|
2095
|
+
? resumeAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })
|
|
2096
|
+
: undefined);
|
|
2097
|
+
context.emit("session:rate-limited", {
|
|
2098
|
+
sessionId: currentSession.id,
|
|
2099
|
+
employee: currentSession.employee,
|
|
2100
|
+
error: result.error,
|
|
2101
|
+
resetsAt: rateLimit.resetsAt ?? null,
|
|
2102
|
+
});
|
|
2103
|
+
// Keep lastActivity fresh while waiting (UI / status endpoints)
|
|
2104
|
+
const heartbeat = setInterval(() => {
|
|
2105
|
+
updateSession(currentSession.id, { status: "waiting", lastActivity: new Date().toISOString() });
|
|
2106
|
+
}, 60_000);
|
|
2107
|
+
try {
|
|
2108
|
+
let attempt = 0;
|
|
2109
|
+
let nextDelayMs = delayMs;
|
|
2110
|
+
while (Date.now() < deadlineMs) {
|
|
2111
|
+
await new Promise((r) => setTimeout(r, nextDelayMs));
|
|
2112
|
+
attempt++;
|
|
2113
|
+
// Check session still exists and hasn't been cancelled
|
|
2114
|
+
const current = getSession(currentSession.id);
|
|
2115
|
+
if (!current || current.status === "error") {
|
|
2116
|
+
logger.info(`Web session ${currentSession.id} stopped while waiting for usage reset`);
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
logger.info(`Web session ${currentSession.id} retrying after usage limit (attempt ${attempt})`);
|
|
2120
|
+
const retryResult = await engine.run({
|
|
2121
|
+
prompt,
|
|
2122
|
+
resumeSessionId: current.engineSessionId ?? undefined,
|
|
2123
|
+
systemPrompt,
|
|
2124
|
+
cwd: JINN_HOME,
|
|
2125
|
+
bin: engineConfig.bin,
|
|
2126
|
+
model: current.model ?? engineConfig.model,
|
|
2127
|
+
effortLevel,
|
|
2128
|
+
cliFlags: employee?.cliFlags,
|
|
2129
|
+
sessionId: currentSession.id,
|
|
2130
|
+
onStream: (delta) => {
|
|
2131
|
+
context.emit("session:delta", {
|
|
2132
|
+
sessionId: currentSession.id,
|
|
2133
|
+
type: delta.type,
|
|
2134
|
+
content: delta.content,
|
|
2135
|
+
toolName: delta.toolName,
|
|
2136
|
+
});
|
|
2137
|
+
},
|
|
2138
|
+
});
|
|
2139
|
+
const retryInterrupted = retryResult.error?.startsWith("Interrupted");
|
|
2140
|
+
const retryRateLimit = !retryInterrupted ? detectRateLimit(retryResult) : { limited: false };
|
|
2141
|
+
if (retryRateLimit.limited) {
|
|
2142
|
+
recordClaudeRateLimit(retryRateLimit.resetsAt);
|
|
2143
|
+
logger.info(`Web session ${currentSession.id} still rate limited (attempt ${attempt})`);
|
|
2144
|
+
const next = computeNextRetryDelayMs(retryRateLimit.resetsAt);
|
|
2145
|
+
nextDelayMs = next.delayMs;
|
|
2146
|
+
updateSession(currentSession.id, {
|
|
2147
|
+
...(retryResult.sessionId?.trim() ? { engineSessionId: retryResult.sessionId } : {}),
|
|
2148
|
+
status: "waiting",
|
|
2149
|
+
lastActivity: new Date().toISOString(),
|
|
2150
|
+
lastError: next.resumeAt
|
|
2151
|
+
? `Claude usage limit — resumes ${next.resumeAt.toISOString()}`
|
|
2152
|
+
: "Claude usage limit — waiting for reset",
|
|
2153
|
+
});
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
// Usage limit cleared — handle result
|
|
2157
|
+
if (retryResult.result) {
|
|
2158
|
+
insertMessage(currentSession.id, "assistant", retryResult.result);
|
|
2159
|
+
}
|
|
2160
|
+
const completedAfterRetry = updateSession(currentSession.id, {
|
|
2161
|
+
...(retryResult.sessionId?.trim() ? { engineSessionId: retryResult.sessionId } : {}),
|
|
2162
|
+
status: retryResult.error ? "error" : "idle",
|
|
2163
|
+
lastActivity: new Date().toISOString(),
|
|
2164
|
+
lastError: retryResult.error ?? null,
|
|
2165
|
+
});
|
|
2166
|
+
if (completedAfterRetry) {
|
|
2167
|
+
notifyRateLimitResumed(completedAfterRetry);
|
|
2168
|
+
notifyDiscordChannel(`✅ Claude usage limit cleared. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} resumed.`);
|
|
2169
|
+
notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2170
|
+
}
|
|
2171
|
+
context.emit("session:completed", {
|
|
2172
|
+
sessionId: currentSession.id,
|
|
2173
|
+
employee: currentSession.employee || config.portal?.portalName || "Ryoko",
|
|
2174
|
+
title: currentSession.title,
|
|
2175
|
+
result: retryResult.result,
|
|
2176
|
+
error: retryResult.error || null,
|
|
2177
|
+
cost: retryResult.cost,
|
|
2178
|
+
durationMs: retryResult.durationMs,
|
|
2179
|
+
});
|
|
2180
|
+
logger.info(`Web session ${currentSession.id} resumed after usage reset`);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
// Exhausted waiting window
|
|
2184
|
+
notifyDiscordChannel(`❌ Claude usage limit did not clear in time. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} has been stopped.`);
|
|
2185
|
+
const erroredSession = updateSession(currentSession.id, {
|
|
2186
|
+
status: "error",
|
|
2187
|
+
lastActivity: new Date().toISOString(),
|
|
2188
|
+
lastError: "Claude usage limit did not clear in time",
|
|
2189
|
+
});
|
|
2190
|
+
if (erroredSession) {
|
|
2191
|
+
notifyParentSession(erroredSession, { error: "Claude usage limit did not clear in time" }, { alwaysNotify: employee?.alwaysNotify });
|
|
2192
|
+
}
|
|
2193
|
+
context.emit("session:completed", {
|
|
2194
|
+
sessionId: currentSession.id,
|
|
2195
|
+
result: null,
|
|
2196
|
+
error: "Claude usage limit did not clear in time",
|
|
2197
|
+
});
|
|
2198
|
+
logger.warn(`Web session ${currentSession.id} exhausted usage limit retries`);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
finally {
|
|
2202
|
+
clearInterval(heartbeat);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
// Persist the assistant response
|
|
2206
|
+
if (result.result) {
|
|
2207
|
+
insertMessage(currentSession.id, "assistant", result.result);
|
|
2208
|
+
}
|
|
2209
|
+
const completedSession = updateSession(currentSession.id, {
|
|
2210
|
+
...(result.sessionId?.trim() ? { engineSessionId: result.sessionId } : {}),
|
|
2211
|
+
status: result.error ? "error" : "idle",
|
|
2212
|
+
lastActivity: new Date().toISOString(),
|
|
2213
|
+
lastError: result.error ?? null,
|
|
2214
|
+
});
|
|
2215
|
+
if (syncRequested && !rateLimit.limited && !wasInterrupted) {
|
|
2216
|
+
const meta = (getSession(currentSession.id)?.transportMeta || currentSession.transportMeta || {});
|
|
2217
|
+
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
|
|
2218
|
+
const nextMeta = { ...meta };
|
|
2219
|
+
delete nextMeta["claudeSyncSince"];
|
|
2220
|
+
updateSession(currentSession.id, { transportMeta: nextMeta });
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
if (completedSession) {
|
|
2224
|
+
notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2225
|
+
}
|
|
2226
|
+
context.emit("session:completed", {
|
|
2227
|
+
sessionId: currentSession.id,
|
|
2228
|
+
employee: currentSession.employee || config.portal?.portalName || "Ryoko",
|
|
2229
|
+
title: currentSession.title,
|
|
2230
|
+
result: result.result,
|
|
2231
|
+
error: result.error || null,
|
|
2232
|
+
cost: result.cost,
|
|
2233
|
+
durationMs: result.durationMs,
|
|
2234
|
+
});
|
|
2235
|
+
logger.info(`Web session ${currentSession.id} completed` +
|
|
2236
|
+
(result.durationMs ? ` in ${result.durationMs}ms` : "") +
|
|
2237
|
+
(result.cost ? ` ($${result.cost.toFixed(4)})` : ""));
|
|
2238
|
+
}
|
|
2239
|
+
catch (err) {
|
|
2240
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2241
|
+
if (!getSession(currentSession.id)) {
|
|
2242
|
+
logger.info(`Skipping error handling for deleted web session ${currentSession.id}: ${errMsg}`);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
const erroredSession = updateSession(currentSession.id, {
|
|
2246
|
+
status: "error",
|
|
2247
|
+
lastActivity: new Date().toISOString(),
|
|
2248
|
+
lastError: errMsg,
|
|
2249
|
+
});
|
|
2250
|
+
if (erroredSession) {
|
|
2251
|
+
notifyParentSession(erroredSession, { error: errMsg }, { alwaysNotify: employee?.alwaysNotify });
|
|
2252
|
+
}
|
|
2253
|
+
context.emit("session:completed", {
|
|
2254
|
+
sessionId: currentSession.id,
|
|
2255
|
+
result: null,
|
|
2256
|
+
error: errMsg,
|
|
2257
|
+
});
|
|
2258
|
+
logger.error(`Web session ${currentSession.id} error: ${errMsg}`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
//# sourceMappingURL=api.js.map
|