thepopebot 1.2.75-beta.2 → 1.2.75-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/api/CLAUDE.md +1 -1
- package/api/index.js +5 -12
- package/bin/CLAUDE.md +1 -1
- package/bin/cli.js +329 -14
- package/bin/docker-build.js +5 -0
- package/bin/managed-paths.js +0 -7
- package/bin/sync.js +84 -0
- package/config/CLAUDE.md +1 -29
- package/config/instrumentation.js +1 -1
- package/lib/CLAUDE.md +3 -3
- package/lib/ai/CLAUDE.md +24 -3
- package/lib/ai/agent.js +8 -5
- package/lib/ai/async-channel.js +51 -0
- package/lib/ai/headless-stream.js +3 -0
- package/lib/ai/index.js +149 -173
- package/lib/ai/line-mappers.js +72 -9
- package/lib/ai/tools.js +40 -28
- package/lib/chat/actions.js +34 -6
- package/lib/chat/api.js +17 -1
- package/lib/chat/components/chat-header.js +4 -0
- package/lib/chat/components/chat-header.jsx +4 -0
- package/lib/chat/components/chat-input.js +1 -0
- package/lib/chat/components/chat-input.jsx +1 -0
- package/lib/chat/components/chat.js +9 -1
- package/lib/chat/components/chat.jsx +15 -2
- package/lib/chat/components/chats-page.js +3 -3
- package/lib/chat/components/chats-page.jsx +4 -6
- package/lib/chat/components/crons-page.js +1 -1
- package/lib/chat/components/crons-page.jsx +1 -1
- package/lib/chat/components/message.js +12 -4
- package/lib/chat/components/message.jsx +17 -4
- package/lib/chat/components/settings-chat-page.js +2 -1
- package/lib/chat/components/settings-chat-page.jsx +4 -1
- package/lib/chat/components/settings-coding-agents-page.js +139 -1
- package/lib/chat/components/settings-coding-agents-page.jsx +160 -0
- package/lib/chat/components/settings-jobs-page.js +13 -2
- package/lib/chat/components/settings-jobs-page.jsx +15 -1
- package/lib/chat/components/settings-secrets-layout.js +1 -1
- package/lib/chat/components/settings-secrets-layout.jsx +1 -1
- package/lib/chat/components/sidebar-history-item.js +3 -3
- package/lib/chat/components/sidebar-history-item.jsx +4 -6
- package/lib/chat/components/triggers-page.js +1 -1
- package/lib/chat/components/triggers-page.jsx +1 -1
- package/lib/cluster/actions.js +4 -4
- package/lib/cluster/execute.js +3 -1
- package/lib/code/actions.js +34 -11
- package/lib/code/code-page.js +40 -40
- package/lib/code/code-page.jsx +36 -36
- package/lib/code/port-forwards.js +17 -3
- package/lib/code/terminal-view.js +16 -0
- package/lib/code/terminal-view.jsx +18 -0
- package/lib/config.js +4 -0
- package/lib/cron.js +3 -3
- package/lib/db/api-keys.js +22 -61
- package/lib/db/config.js +23 -0
- package/lib/db/index.js +3 -1
- package/lib/maintenance.js +34 -11
- package/lib/paths.js +1 -38
- package/lib/tools/create-agent-job.js +0 -4
- package/lib/tools/docker.js +23 -16
- package/lib/triggers.js +4 -3
- package/lib/utils/render-md.js +3 -1
- package/package.json +2 -1
- package/setup/setup-ssl.mjs +414 -0
- package/templates/.github/workflows/rebuild-event-handler.yml +3 -0
- package/templates/.github/workflows/upgrade-event-handler.yml +1 -1
- package/templates/.gitignore.template +7 -3
- package/templates/.tmp/CLAUDE.md.template +5 -0
- package/templates/CLAUDE.md +3 -2
- package/templates/CLAUDE.md.template +24 -357
- package/templates/agent-job/CLAUDE.md.template +57 -0
- package/templates/agent-job/CRONS.json +16 -0
- package/templates/{config/agent-job → agent-job}/SOUL.md +3 -3
- package/templates/agent-job/SYSTEM.md +60 -0
- package/templates/agents/CLAUDE.md.template +54 -0
- package/templates/data/CLAUDE.md.template +5 -0
- package/templates/docker-compose.custom.yml +41 -62
- package/templates/docker-compose.yml +14 -21
- package/templates/event-handler/CLAUDE.md.template +0 -0
- package/templates/logs/CLAUDE.md.template +5 -0
- package/templates/skills/CLAUDE.md.template +57 -32
- package/templates/skills/active/.gitkeep +0 -0
- package/templates/skills/library/agent-job-secrets/SKILL.md +23 -0
- package/templates/skills/library/agent-job-secrets/agent-job-secrets.js +62 -0
- package/templates/.pi/extensions/env-sanitizer/index.ts +0 -48
- package/templates/.pi/extensions/env-sanitizer/package.json +0 -5
- package/templates/README.md +0 -75
- package/templates/config/CLAUDE.md.template +0 -40
- package/templates/config/CRONS.json +0 -56
- package/templates/config/agent-job/AGENT_JOB.md +0 -30
- package/templates/cron/CLAUDE.md.template +0 -24
- package/templates/docker-compose.litellm.yml +0 -82
- package/templates/docs/CLAUDE.md.template +0 -12
- package/templates/docs/CLI.md +0 -59
- package/templates/docs/CLUSTERS.md +0 -151
- package/templates/docs/CONFIGURATION.md +0 -181
- package/templates/docs/CRONS_AND_TRIGGERS.md +0 -132
- package/templates/docs/GETTING_STARTED.md +0 -64
- package/templates/docs/SECURITY.md +0 -61
- package/templates/docs/SKILLS.md +0 -113
- package/templates/docs/UPGRADING.md +0 -92
- package/templates/skills/LICENSE +0 -21
- package/templates/skills/README.md +0 -117
- package/templates/skills/agent-job-secrets/SKILL.md +0 -25
- package/templates/skills/agent-job-secrets/agent-job-secrets.js +0 -66
- package/templates/traefik-dynamic.yml.example +0 -7
- package/templates/triggers/CLAUDE.md.template +0 -41
- /package/templates/{config → agent-job}/HEARTBEAT.md +0 -0
- /package/templates/{cron → data}/.gitkeep +0 -0
- /package/templates/{logs → data/clusters}/.gitkeep +0 -0
- /package/templates/{triggers → data/db}/.gitkeep +0 -0
- /package/templates/{config/agent-job → event-handler}/SUMMARY.md +0 -0
- /package/templates/{config → event-handler}/TRIGGERS.json +0 -0
- /package/templates/{config → event-handler}/agent-chat/SYSTEM.md +0 -0
- /package/templates/{config/cluster → event-handler/clusters}/ROLE.md +0 -0
- /package/templates/{config/cluster → event-handler/clusters}/SYSTEM.md +0 -0
- /package/templates/{config → event-handler}/code-chat/SYSTEM.md +0 -0
- /package/templates/{config → event-handler}/litellm/main.yaml +0 -0
- /package/templates/skills/{playwright-cli → library/playwright-cli}/SKILL.md +0 -0
package/config/CLAUDE.md
CHANGED
|
@@ -1,32 +1,4 @@
|
|
|
1
|
-
# config/ —
|
|
2
|
-
|
|
3
|
-
## Directory Structure
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
config/
|
|
7
|
-
├── agent-chat/
|
|
8
|
-
│ └── SYSTEM.md # Agent chat system prompt (supports {{skills}}, {{datetime}})
|
|
9
|
-
├── code-chat/
|
|
10
|
-
│ └── SYSTEM.md # Code workspace system prompt
|
|
11
|
-
├── agent-job/
|
|
12
|
-
│ ├── SOUL.md # Agent personality/identity (used by Docker agent)
|
|
13
|
-
│ ├── AGENT_JOB.md # Agent runtime environment docs (used by Docker agent)
|
|
14
|
-
│ └── SUMMARY.md # Prompt for summarizing completed jobs
|
|
15
|
-
├── cluster/
|
|
16
|
-
│ ├── SYSTEM.md # Cluster worker system prompt
|
|
17
|
-
│ └── ROLE.md # Per-role prompt template for cluster workers
|
|
18
|
-
├── HEARTBEAT.md # Self-monitoring behavior (cron task prompt)
|
|
19
|
-
├── CRONS.json # Scheduled job definitions
|
|
20
|
-
└── TRIGGERS.json # Webhook trigger definitions
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
## Markdown File Includes
|
|
24
|
-
|
|
25
|
-
Markdown files in `config/` support includes and built-in variables, powered by `lib/utils/render-md.js`.
|
|
26
|
-
|
|
27
|
-
- **File includes**: `{{ filepath.md }}` — resolves relative to project root, recursive with circular detection. Missing files are left as-is.
|
|
28
|
-
- **`{{datetime}}`** — Current ISO timestamp.
|
|
29
|
-
- **`{{skills}}`** — Dynamic bullet list of active skill descriptions from `skills/active/*/SKILL.md` frontmatter. Never hardcode skill names — this is resolved at runtime.
|
|
1
|
+
# config/ — Next.js Config Wrapper
|
|
30
2
|
|
|
31
3
|
## Next.js Config Wrapper (index.js)
|
|
32
4
|
|
|
@@ -70,7 +70,7 @@ export async function register() {
|
|
|
70
70
|
const { startClusterRuntime } = await import('../lib/cluster/runtime.js');
|
|
71
71
|
startClusterRuntime();
|
|
72
72
|
|
|
73
|
-
// Start internal maintenance cron (cleanup
|
|
73
|
+
// Start internal maintenance cron (cleanup orphaned agent job keys, etc.)
|
|
74
74
|
const { startMaintenanceCron } = await import('../lib/maintenance.js');
|
|
75
75
|
startMaintenanceCron();
|
|
76
76
|
|
package/lib/CLAUDE.md
CHANGED
|
@@ -12,16 +12,16 @@ If the task needs to *think*, use `agent`. If it just needs to *do*, use `comman
|
|
|
12
12
|
|
|
13
13
|
**Agent**: Creates a Docker Agent job via `createAgentJob()`. Pushes an `agent-job/*` branch and launches a local Docker container. The `job` string is the LLM task prompt. Agent backend selected via `agent_backend` in `agent-job.config.json`.
|
|
14
14
|
|
|
15
|
-
**Command**: Runs a shell command on the event handler. Working directory:
|
|
15
|
+
**Command**: Runs a shell command on the event handler. Working directory: project root.
|
|
16
16
|
|
|
17
17
|
**Webhook**: Makes an HTTP request. `GET` skips the body; `POST` (default) sends `{ ...vars }` or `{ ...vars, data: <payload> }`.
|
|
18
18
|
|
|
19
19
|
## Cron Jobs
|
|
20
20
|
|
|
21
|
-
Defined in `
|
|
21
|
+
Defined in `agent-job/CRONS.json`, loaded by `lib/cron.js` at startup via `node-cron`. Each entry has `name`, `schedule` (cron expression), `type` (`agent`/`command`/`webhook`), and the corresponding action fields (`job`, `command`, or `url`/`method`/`headers`/`vars`). Set `enabled: false` to disable. Agent-type entries support optional `llm_provider` and `llm_model` fields to override the default LLM (passed to Docker agent via `agent-job.config.json`).
|
|
22
22
|
|
|
23
23
|
## Webhook Triggers
|
|
24
24
|
|
|
25
|
-
Defined in `
|
|
25
|
+
Defined in `event-handler/TRIGGERS.json`, loaded by `lib/triggers.js`. Each trigger watches an endpoint path (`watch_path`) and fires an array of actions (fire-and-forget, after auth, before route handler). Actions use the same `type`/`job`/`command`/`url` fields as cron jobs, including optional `llm_provider`/`llm_model` overrides.
|
|
26
26
|
|
|
27
27
|
Template tokens in `job` and `command` strings: `{{body}}`, `{{body.field}}`, `{{query}}`, `{{query.field}}`, `{{headers}}`, `{{headers.field}}`.
|
package/lib/ai/CLAUDE.md
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
Two agent singletons, both using `createReactAgent` from `@langchain/langgraph/prebuilt` with `SqliteSaver` for conversation memory:
|
|
6
6
|
|
|
7
7
|
**Agent Chat** — singleton via `getAgentChat()`:
|
|
8
|
-
- System prompt: `
|
|
8
|
+
- System prompt: `event-handler/agent-chat/SYSTEM.md` (rendered fresh each invocation via `render_md()`)
|
|
9
9
|
- Tools: `agent_job`, `coding_agent`
|
|
10
10
|
- Call `resetAgentChats()` to clear both singletons (required if hot-reloading)
|
|
11
11
|
|
|
12
12
|
**Code Chat** — singleton via `getCodeChat()`:
|
|
13
|
-
- System prompt: `
|
|
13
|
+
- System prompt: `event-handler/code-chat/SYSTEM.md` (rendered fresh each invocation)
|
|
14
14
|
- Tools: `coding_agent` (reads repo/branch/workspace from `runtime.configurable`)
|
|
15
15
|
|
|
16
16
|
## Adding a New Tool
|
|
@@ -73,7 +73,28 @@ Three-layer parser for Claude Code agents running in headless Docker containers:
|
|
|
73
73
|
3. **Event mapper** (`mapLine()`) — Converts each line to chat events:
|
|
74
74
|
- `assistant` messages: `text` blocks → `{ type: 'text' }`, `tool_use` blocks → `{ type: 'tool-call' }`
|
|
75
75
|
- `user` messages: `tool_result` blocks → `{ type: 'tool-result' }` (priority: stdout > string content > array)
|
|
76
|
-
- `result` messages: → `{ type: 'text'
|
|
76
|
+
- `result` messages: → `{ type: 'text' }` (final summary from the agent)
|
|
77
77
|
- Non-JSON lines (e.g. `NO_CHANGES`, `AGENT_FAILED`): wrapped as plain text events
|
|
78
78
|
|
|
79
79
|
`parseHeadlessStream(dockerLogStream)` is an async generator consuming `http.IncomingMessage`. `mapLine()` is also reused by `lib/cluster/stream.js` for worker log parsing.
|
|
80
|
+
|
|
81
|
+
### Tool Return Format
|
|
82
|
+
|
|
83
|
+
The `coding_agent` tool (in `tools.js`) returns the **full container session** as a flat JSON array. This becomes the ToolMessage in LangGraph's checkpoint, giving the LLM complete context on the current turn. The array contains:
|
|
84
|
+
|
|
85
|
+
- `{ type: 'meta', codingAgent, backendApi }` — first event, agent identity
|
|
86
|
+
- `{ type: 'text', text }` — agent text output
|
|
87
|
+
- `{ type: 'tool-call', toolCallId, toolName, args }` — agent tool invocations
|
|
88
|
+
- `{ type: 'tool-result', toolCallId, result }` — tool execution results
|
|
89
|
+
- `{ type: 'exit', exitCode }` — last event, container exit status
|
|
90
|
+
|
|
91
|
+
On error before streaming starts: `[{ type: 'error', message }]`.
|
|
92
|
+
|
|
93
|
+
### Adding a New Agent Mapper (line-mappers.js)
|
|
94
|
+
|
|
95
|
+
Each coding agent CLI has its own mapper function (`mapClaudeCodeLine`, `mapPiLine`, `mapGeminiLine`, `mapCodexLine`, `mapOpenCodeLine`, `mapKimiLine`). When adding a new agent:
|
|
96
|
+
|
|
97
|
+
1. Create `mapXxxLine(parsed)` in `line-mappers.js` that returns an array of `{ type, ... }` events
|
|
98
|
+
2. Register it in `headless-stream.js`: add to imports, re-exports, and the `mapperMap` object
|
|
99
|
+
3. Map the agent's JSON output to three event types: `{ type: 'text', text }`, `{ type: 'tool-call', toolCallId, toolName, args }`, `{ type: 'tool-result', toolCallId, result }`
|
|
100
|
+
4. Return `[{ type: 'skip' }]` for noise events (session init, rate limits, etc.) to suppress them without triggering the unknown fallback
|
package/lib/ai/agent.js
CHANGED
|
@@ -3,7 +3,8 @@ import { SystemMessage } from '@langchain/core/messages';
|
|
|
3
3
|
import { createModel } from './model.js';
|
|
4
4
|
import { agentJobTool, agentChatCodingTool, codeChatCodingTool } from './tools.js';
|
|
5
5
|
import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
|
|
6
|
-
import
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { PROJECT_ROOT } from '../paths.js';
|
|
7
8
|
import { render_md } from '../utils/render-md.js';
|
|
8
9
|
|
|
9
10
|
// Singletons on globalThis to survive Next.js webpack chunk duplication.
|
|
@@ -20,13 +21,14 @@ export async function getAgentChat() {
|
|
|
20
21
|
const model = await createModel();
|
|
21
22
|
const tools = [agentJobTool, agentChatCodingTool];
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
+
const dbPath = process.env.DATABASE_PATH || path.join(PROJECT_ROOT, 'data/db/thepopebot.sqlite');
|
|
25
|
+
const checkpointer = SqliteSaver.fromConnString(dbPath);
|
|
24
26
|
|
|
25
27
|
globalThis.__popebotAgentChat = createReactAgent({
|
|
26
28
|
llm: model,
|
|
27
29
|
tools,
|
|
28
30
|
checkpointSaver: checkpointer,
|
|
29
|
-
prompt: (state) => [new SystemMessage(render_md(
|
|
31
|
+
prompt: (state) => [new SystemMessage(render_md(path.join(PROJECT_ROOT, 'event-handler/agent-chat/SYSTEM.md'))), ...state.messages],
|
|
30
32
|
});
|
|
31
33
|
}
|
|
32
34
|
return globalThis.__popebotAgentChat;
|
|
@@ -41,13 +43,14 @@ export async function getCodeChat() {
|
|
|
41
43
|
const model = await createModel();
|
|
42
44
|
const tools = [codeChatCodingTool];
|
|
43
45
|
|
|
44
|
-
const
|
|
46
|
+
const dbPath = process.env.DATABASE_PATH || path.join(PROJECT_ROOT, 'data/db/thepopebot.sqlite');
|
|
47
|
+
const checkpointer = SqliteSaver.fromConnString(dbPath);
|
|
45
48
|
|
|
46
49
|
globalThis.__popebotCodeChat = createReactAgent({
|
|
47
50
|
llm: model,
|
|
48
51
|
tools,
|
|
49
52
|
checkpointSaver: checkpointer,
|
|
50
|
-
prompt: (state) => [new SystemMessage(render_md(
|
|
53
|
+
prompt: (state) => [new SystemMessage(render_md(path.join(PROJECT_ROOT, 'event-handler/code-chat/SYSTEM.md'))), ...state.messages],
|
|
51
54
|
});
|
|
52
55
|
}
|
|
53
56
|
return globalThis.__popebotCodeChat;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async push/pull queue. Producer calls push()/done(), consumer uses for-await.
|
|
3
|
+
*/
|
|
4
|
+
export function createChannel() {
|
|
5
|
+
const queue = [];
|
|
6
|
+
const waiters = [];
|
|
7
|
+
let isDone = false;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
push(value) {
|
|
11
|
+
if (waiters.length > 0) waiters.shift()(value);
|
|
12
|
+
else queue.push(value);
|
|
13
|
+
},
|
|
14
|
+
done() {
|
|
15
|
+
isDone = true;
|
|
16
|
+
while (waiters.length > 0) waiters.shift()(Symbol.for('done'));
|
|
17
|
+
},
|
|
18
|
+
async *[Symbol.asyncIterator]() {
|
|
19
|
+
while (true) {
|
|
20
|
+
if (queue.length > 0) {
|
|
21
|
+
yield queue.shift();
|
|
22
|
+
} else if (isDone) {
|
|
23
|
+
return;
|
|
24
|
+
} else {
|
|
25
|
+
const value = await new Promise(resolve => waiters.push(resolve));
|
|
26
|
+
if (value === Symbol.for('done')) return;
|
|
27
|
+
yield value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Merge two async iterables — yields from whichever has data first.
|
|
36
|
+
* Completes when BOTH are exhausted.
|
|
37
|
+
*/
|
|
38
|
+
export async function* mergeAsyncIterables(iter1, iter2) {
|
|
39
|
+
const channel = createChannel();
|
|
40
|
+
let active = 2;
|
|
41
|
+
|
|
42
|
+
const consume = async (iter) => {
|
|
43
|
+
for await (const item of iter) channel.push(item);
|
|
44
|
+
if (--active === 0) channel.done();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
consume(iter1);
|
|
48
|
+
consume(iter2);
|
|
49
|
+
|
|
50
|
+
yield* channel;
|
|
51
|
+
}
|
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
mapGeminiLine,
|
|
11
11
|
mapCodexLine,
|
|
12
12
|
mapOpenCodeLine,
|
|
13
|
+
mapKimiLine,
|
|
13
14
|
} from './line-mappers.js';
|
|
14
15
|
|
|
15
16
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
mapGeminiLine,
|
|
20
21
|
mapCodexLine,
|
|
21
22
|
mapOpenCodeLine,
|
|
23
|
+
mapKimiLine,
|
|
22
24
|
} from './line-mappers.js';
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -41,6 +43,7 @@ export async function* parseHeadlessStream(dockerLogStream, codingAgent = 'claud
|
|
|
41
43
|
'gemini-cli': mapGeminiLine,
|
|
42
44
|
'codex-cli': mapCodexLine,
|
|
43
45
|
'opencode': mapOpenCodeLine,
|
|
46
|
+
'kimi-cli': mapKimiLine,
|
|
44
47
|
};
|
|
45
48
|
const mapper = mapperMap[codingAgent] || mapClaudeCodeLine;
|
|
46
49
|
|
package/lib/ai/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { HumanMessage
|
|
1
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import { createChannel, mergeAsyncIterables } from './async-channel.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { getAgentChat, getCodeChat } from './agent.js';
|
|
4
5
|
import { createModel } from './model.js';
|
|
5
|
-
import
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { PROJECT_ROOT } from '../paths.js';
|
|
6
8
|
import { render_md } from '../utils/render-md.js';
|
|
7
9
|
import { getChatById, createChat, saveMessage, updateChatTitle, linkChatToWorkspace } from '../db/chats.js';
|
|
8
10
|
|
|
@@ -178,16 +180,21 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
|
|
|
178
180
|
}
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
// Side channel: bridges the tool's live container output to this generator
|
|
184
|
+
const sideChannel = createChannel();
|
|
185
|
+
const streamCallback = (chunk) => {
|
|
186
|
+
if (chunk === null) sideChannel.done();
|
|
187
|
+
else sideChannel.push(chunk);
|
|
188
|
+
};
|
|
189
|
+
|
|
181
190
|
try {
|
|
182
191
|
const stream = await agent.stream(
|
|
183
192
|
{ messages: [new HumanMessage({ content: messageContent })] },
|
|
184
|
-
{ configurable: { thread_id: threadId, workspaceId, repo, branch, codeModeType }, streamMode: 'messages' }
|
|
193
|
+
{ configurable: { thread_id: threadId, workspaceId, repo, branch, codeModeType, streamCallback }, streamMode: 'messages' }
|
|
185
194
|
);
|
|
186
195
|
|
|
187
|
-
let fullText = '';
|
|
188
196
|
const toolCallNames = {};
|
|
189
197
|
const pendingToolCalls = new Map();
|
|
190
|
-
let headlessContainer = null;
|
|
191
198
|
|
|
192
199
|
// Accumulate raw tool call arg fragments across streaming chunks.
|
|
193
200
|
// Each AIMessageChunk only carries its own delta — the first chunk
|
|
@@ -195,202 +202,170 @@ async function* chatStream(threadId, message, attachments = [], options = {}) {
|
|
|
195
202
|
// chunks (input_json_delta) have only index with the partial JSON delta.
|
|
196
203
|
const toolCallRawArgs = {}; // tool_call_id → accumulated args string
|
|
197
204
|
const indexToToolCallId = {}; // chunk index → tool_call_id
|
|
205
|
+
const toolCallArgsEmitted = new Set(); // tool_call_ids whose complete args have been yielded
|
|
206
|
+
|
|
207
|
+
// Headless container streaming state
|
|
208
|
+
const headlessPendingToolCalls = new Map();
|
|
209
|
+
let pendingText = ''; // channel text, flushed to DB at tool boundaries
|
|
210
|
+
let llmTextAccum = ''; // langgraph text (direct response or LLM follow-up after container)
|
|
211
|
+
|
|
212
|
+
// Tag helper so mergeAsyncIterables can tell the two sources apart.
|
|
213
|
+
// The LangGraph wrapper also closes sideChannel when the agent stream
|
|
214
|
+
// finishes — this prevents a deadlock when no tool calls streamCallback.
|
|
215
|
+
async function* tagged(iter, source) {
|
|
216
|
+
for await (const item of iter) yield { _src: source, item };
|
|
217
|
+
if (source === 'lg') sideChannel.done();
|
|
218
|
+
}
|
|
198
219
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (c.id) {
|
|
223
|
-
indexToToolCallId[c.index] = c.id;
|
|
224
|
-
toolCallRawArgs[c.id] = (toolCallRawArgs[c.id] || '') + (c.args || '');
|
|
225
|
-
} else if (c.index != null && indexToToolCallId[c.index]) {
|
|
226
|
-
const id = indexToToolCallId[c.index];
|
|
227
|
-
toolCallRawArgs[id] = (toolCallRawArgs[id] || '') + (c.args || '');
|
|
220
|
+
try {
|
|
221
|
+
for await (const { _src, item } of mergeAsyncIterables(
|
|
222
|
+
tagged(stream, 'lg'),
|
|
223
|
+
tagged(sideChannel, 'ch')
|
|
224
|
+
)) {
|
|
225
|
+
if (_src === 'lg') {
|
|
226
|
+
// ── LangGraph agent stream ────────────────────────────────────────
|
|
227
|
+
const msg = Array.isArray(item) ? item[0] : item;
|
|
228
|
+
const msgType = msg._getType?.();
|
|
229
|
+
|
|
230
|
+
if (msgType === 'ai') {
|
|
231
|
+
// Tool calls — AIMessage.tool_calls is an array of { id, name, args }
|
|
232
|
+
if (msg.tool_calls?.length > 0) {
|
|
233
|
+
for (const tc of msg.tool_calls) {
|
|
234
|
+
toolCallNames[tc.id] = tc.name;
|
|
235
|
+
pendingToolCalls.set(tc.id, { toolName: tc.name, args: tc.args });
|
|
236
|
+
yield {
|
|
237
|
+
type: 'tool-call',
|
|
238
|
+
toolCallId: tc.id,
|
|
239
|
+
toolName: tc.name,
|
|
240
|
+
args: tc.args,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
228
243
|
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Text content (wrapped in structured object)
|
|
233
|
-
let text = '';
|
|
234
|
-
if (typeof msg.content === 'string') {
|
|
235
|
-
text = msg.content;
|
|
236
|
-
} else if (Array.isArray(msg.content)) {
|
|
237
|
-
text = msg.content
|
|
238
|
-
.filter((b) => b.type === 'text' && b.text)
|
|
239
|
-
.map((b) => b.text)
|
|
240
|
-
.join('');
|
|
241
|
-
}
|
|
242
244
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
input: completeArgs,
|
|
271
|
-
output: msg.content,
|
|
272
|
-
}), options);
|
|
273
|
-
pendingToolCalls.delete(msg.tool_call_id);
|
|
274
|
-
}
|
|
245
|
+
// Accumulate raw tool call arg strings from streaming chunks
|
|
246
|
+
if (msg.tool_call_chunks?.length > 0) {
|
|
247
|
+
for (const c of msg.tool_call_chunks) {
|
|
248
|
+
if (c.id) {
|
|
249
|
+
indexToToolCallId[c.index] = c.id;
|
|
250
|
+
toolCallRawArgs[c.id] = (toolCallRawArgs[c.id] || '') + (c.args || '');
|
|
251
|
+
} else if (c.index != null && indexToToolCallId[c.index]) {
|
|
252
|
+
const id = indexToToolCallId[c.index];
|
|
253
|
+
toolCallRawArgs[id] = (toolCallRawArgs[id] || '') + (c.args || '');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Re-yield tool-call with complete args once the JSON is fully streamed
|
|
257
|
+
for (const c of msg.tool_call_chunks) {
|
|
258
|
+
const id = c.id || indexToToolCallId[c.index];
|
|
259
|
+
if (id && toolCallRawArgs[id] && !toolCallArgsEmitted.has(id)) {
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(toolCallRawArgs[id]);
|
|
262
|
+
toolCallArgsEmitted.add(id);
|
|
263
|
+
const tc = pendingToolCalls.get(id);
|
|
264
|
+
if (tc) {
|
|
265
|
+
tc.args = parsed;
|
|
266
|
+
yield { type: 'tool-call', toolCallId: id, toolName: tc.toolName, args: parsed };
|
|
267
|
+
}
|
|
268
|
+
} catch {} // args not complete yet, keep accumulating
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
275
272
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
273
|
+
// Text content (wrapped in structured object)
|
|
274
|
+
let text = '';
|
|
275
|
+
if (typeof msg.content === 'string') {
|
|
276
|
+
text = msg.content;
|
|
277
|
+
} else if (Array.isArray(msg.content)) {
|
|
278
|
+
text = msg.content
|
|
279
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
280
|
+
.map((b) => b.text)
|
|
281
|
+
.join('');
|
|
283
282
|
}
|
|
284
|
-
} catch {}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
// Skip other message types (human, system)
|
|
288
|
-
}
|
|
289
283
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
284
|
+
if (text) {
|
|
285
|
+
llmTextAccum += text;
|
|
286
|
+
yield { type: 'text', text };
|
|
287
|
+
}
|
|
288
|
+
} else if (msgType === 'tool') {
|
|
289
|
+
// Parse complete args from accumulated raw fragments
|
|
290
|
+
const tc = pendingToolCalls.get(msg.tool_call_id);
|
|
291
|
+
const rawArgs = toolCallRawArgs[msg.tool_call_id];
|
|
292
|
+
let completeArgs;
|
|
293
|
+
try { completeArgs = rawArgs ? JSON.parse(rawArgs) : {}; } catch { completeArgs = {}; }
|
|
294
|
+
|
|
295
|
+
// Tool result — ToolMessage has tool_call_id and content
|
|
296
|
+
yield {
|
|
297
|
+
type: 'tool-result',
|
|
298
|
+
toolCallId: msg.tool_call_id,
|
|
299
|
+
toolName: tc?.toolName,
|
|
300
|
+
args: completeArgs,
|
|
301
|
+
result: msg.content,
|
|
302
|
+
};
|
|
294
303
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const memoryParts = [];
|
|
307
|
-
const headlessPendingToolCalls = new Map();
|
|
308
|
-
let pendingText = '';
|
|
309
|
-
|
|
310
|
-
let lastEmittedText = '';
|
|
311
|
-
for await (const chunk of parseHeadlessStream(logStream, headlessContainer.codingAgent)) {
|
|
312
|
-
// Result summary: skip if duplicate, otherwise ensure it starts on a new line
|
|
313
|
-
if (chunk._resultSummary && chunk.type === 'text') {
|
|
314
|
-
resultSummary = chunk._resultSummary;
|
|
315
|
-
if (pendingText.trim() && chunk.text.trim() === pendingText.trim()) {
|
|
316
|
-
continue;
|
|
304
|
+
// Save complete tool invocation as JSON
|
|
305
|
+
if (tc) {
|
|
306
|
+
persistMessage(threadId, 'assistant', JSON.stringify({
|
|
307
|
+
type: 'tool-invocation',
|
|
308
|
+
toolCallId: msg.tool_call_id,
|
|
309
|
+
toolName: tc.toolName,
|
|
310
|
+
state: 'output-available',
|
|
311
|
+
input: completeArgs,
|
|
312
|
+
output: msg.content,
|
|
313
|
+
}), options);
|
|
314
|
+
pendingToolCalls.delete(msg.tool_call_id);
|
|
317
315
|
}
|
|
318
|
-
chunk = { ...chunk, text: '\n\n' + chunk.text };
|
|
319
316
|
}
|
|
320
|
-
|
|
317
|
+
// Skip other message types (human, system)
|
|
318
|
+
|
|
319
|
+
} else {
|
|
320
|
+
// ── Side channel: headless container chunks ───────────────────────
|
|
321
|
+
const chunk = item;
|
|
322
|
+
|
|
321
323
|
if (chunk.type === 'text') {
|
|
322
|
-
fullText += chunk.text;
|
|
323
|
-
memoryParts.push(chunk.text);
|
|
324
324
|
pendingText += chunk.text;
|
|
325
|
+
yield chunk;
|
|
325
326
|
} else if (chunk.type === 'tool-call') {
|
|
326
327
|
// Flush accumulated text before tool call
|
|
327
328
|
if (pendingText) {
|
|
328
329
|
persistMessage(threadId, 'assistant', pendingText, options);
|
|
329
330
|
pendingText = '';
|
|
330
331
|
}
|
|
331
|
-
memoryParts.push('[tool-call] ' + chunk.toolName + ': ' + JSON.stringify(chunk.args));
|
|
332
332
|
headlessPendingToolCalls.set(chunk.toolCallId, { toolName: chunk.toolName, args: chunk.args });
|
|
333
|
+
yield chunk;
|
|
333
334
|
} else if (chunk.type === 'tool-result') {
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
335
|
+
// Enrich with args from matching tool-call (required by api.js tool-input-available update)
|
|
336
|
+
const htc = headlessPendingToolCalls.get(chunk.toolCallId);
|
|
337
|
+
const enriched = htc ? { ...chunk, args: htc.args, toolName: htc.toolName } : chunk;
|
|
338
|
+
yield enriched;
|
|
339
|
+
if (htc) {
|
|
337
340
|
persistMessage(threadId, 'assistant', JSON.stringify({
|
|
338
341
|
type: 'tool-invocation',
|
|
339
342
|
toolCallId: chunk.toolCallId,
|
|
340
|
-
toolName:
|
|
343
|
+
toolName: htc.toolName,
|
|
341
344
|
state: 'output-available',
|
|
342
|
-
input:
|
|
345
|
+
input: htc.args,
|
|
343
346
|
output: chunk.result,
|
|
344
347
|
}), options);
|
|
345
348
|
headlessPendingToolCalls.delete(chunk.toolCallId);
|
|
346
349
|
}
|
|
350
|
+
} else {
|
|
351
|
+
// unknown/meta events pass through unchanged
|
|
352
|
+
yield chunk;
|
|
347
353
|
}
|
|
348
|
-
if (chunk._resultSummary) resultSummary = chunk._resultSummary;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Flush remaining accumulated text
|
|
352
|
-
if (pendingText) {
|
|
353
|
-
persistMessage(threadId, 'assistant', pendingText, options);
|
|
354
|
-
pendingText = '';
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Container has exited by now (tailContainerLogs follows until EOF)
|
|
358
|
-
const exitCode = await waitForContainer(headlessContainer.containerName);
|
|
359
|
-
await removeContainer(headlessContainer.containerName);
|
|
360
|
-
|
|
361
|
-
if (exitCode === 0) {
|
|
362
|
-
const completionMsg = codeModeType === 'plan'
|
|
363
|
-
? '\n\nPlanning complete.'
|
|
364
|
-
: '\n\nCoding complete.';
|
|
365
|
-
yield { type: 'text', text: completionMsg };
|
|
366
|
-
fullText += completionMsg;
|
|
367
|
-
persistMessage(threadId, 'assistant', completionMsg, options);
|
|
368
|
-
} else {
|
|
369
|
-
const failureMsg = '\n\nTask exited with errors.';
|
|
370
|
-
yield { type: 'text', text: failureMsg };
|
|
371
|
-
fullText += failureMsg;
|
|
372
|
-
persistMessage(threadId, 'assistant', failureMsg, options);
|
|
373
354
|
}
|
|
355
|
+
}
|
|
356
|
+
} finally {
|
|
357
|
+
// Ensure no dangling promise when tool was never called
|
|
358
|
+
sideChannel.done();
|
|
359
|
+
}
|
|
374
360
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
{ messages: [new AIMessage(memoryParts.join('\n'))] }
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
// Also inject the summary separately for concise follow-up context
|
|
383
|
-
if (resultSummary) {
|
|
384
|
-
await agent.updateState(
|
|
385
|
-
{ configurable: { thread_id: threadId } },
|
|
386
|
-
{ messages: [new AIMessage(resultSummary)] }
|
|
387
|
-
);
|
|
388
|
-
}
|
|
361
|
+
// Flush remaining channel text
|
|
362
|
+
if (pendingText) {
|
|
363
|
+
persistMessage(threadId, 'assistant', pendingText, options);
|
|
364
|
+
}
|
|
389
365
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
366
|
+
// Persist LLM text (direct response with no tool, or LLM follow-up after container)
|
|
367
|
+
if (llmTextAccum) {
|
|
368
|
+
persistMessage(threadId, 'assistant', llmTextAccum, options);
|
|
394
369
|
}
|
|
395
370
|
|
|
396
371
|
} catch (err) {
|
|
@@ -410,7 +385,7 @@ async function autoTitle(threadId, firstMessage) {
|
|
|
410
385
|
|
|
411
386
|
const model = await createModel({ maxTokens: 250 });
|
|
412
387
|
const response = await model.withStructuredOutput(z.object({ title: z.string() })).invoke([
|
|
413
|
-
['system', '
|
|
388
|
+
['system', 'Title this chat in 2-5 words. Name the subject matter only. Never start with "User". Never describe what the user is doing — just the topic. Always produce a title, even for vague messages — infer the likely topic.'],
|
|
414
389
|
['human', firstMessage],
|
|
415
390
|
]);
|
|
416
391
|
if (response.title.trim()) {
|
|
@@ -434,10 +409,11 @@ async function autoTitle(threadId, firstMessage) {
|
|
|
434
409
|
async function summarizeAgentJob(results) {
|
|
435
410
|
try {
|
|
436
411
|
const model = await createModel({ maxTokens: 1024 });
|
|
437
|
-
const
|
|
412
|
+
const summaryMdPath = path.join(PROJECT_ROOT, 'event-handler/SUMMARY.md');
|
|
413
|
+
const systemPrompt = render_md(summaryMdPath);
|
|
438
414
|
|
|
439
415
|
if (!systemPrompt) {
|
|
440
|
-
console.error(`[summarizeAgentJob] Empty system prompt —
|
|
416
|
+
console.error(`[summarizeAgentJob] Empty system prompt — event-handler/SUMMARY.md not found or empty at: ${summaryMdPath}`);
|
|
441
417
|
}
|
|
442
418
|
|
|
443
419
|
const userMessage = [
|