kimaki 0.18.0 → 0.19.0
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/cli-commands/send.js +34 -10
- package/dist/cli-commands/session.js +4 -1
- package/dist/markdown.js +59 -4
- package/dist/markdown.test.js +73 -2
- package/package.json +3 -3
- package/skills/sigillo/SKILL.md +34 -0
- package/src/cli-commands/send.ts +37 -13
- package/src/cli-commands/session.ts +5 -2
- package/src/markdown.test.ts +89 -2
- package/src/markdown.ts +63 -4
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Terminal send command for creating Discord threads and scheduling prompts.
|
|
2
|
+
// Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
|
|
3
|
+
// The local SQLite database (channel_directories) is NOT required for the basic
|
|
4
|
+
// flow: post message → create thread → remote bot picks it up. The local project
|
|
5
|
+
// directory mapping is only needed for --send-at, --wait, and --cwd.
|
|
2
6
|
import { goke } from 'goke';
|
|
3
7
|
import { z } from 'zod';
|
|
4
8
|
import { note } from '@clack/prompts';
|
|
@@ -271,9 +275,18 @@ cli
|
|
|
271
275
|
if (!threadData.parent_id) {
|
|
272
276
|
throw new Error(`Thread has no parent channel: ${targetThreadId}`);
|
|
273
277
|
}
|
|
278
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
279
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
280
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
281
|
+
// We only require it for features that genuinely need a local directory
|
|
282
|
+
// (scheduled tasks and --wait).
|
|
274
283
|
const channelConfig = await getChannelDirectory(threadData.parent_id);
|
|
275
|
-
if
|
|
276
|
-
|
|
284
|
+
// Guard early: fail before sending the message if a feature that
|
|
285
|
+
// needs local project directory mapping is requested.
|
|
286
|
+
if (!channelConfig && (parsedSchedule || options.wait)) {
|
|
287
|
+
const flag = parsedSchedule ? '--send-at' : '--wait';
|
|
288
|
+
throw new Error('Thread parent channel is not configured with a project directory. ' +
|
|
289
|
+
`${flag} requires a local project mapping. Run the bot first to sync channel data.`);
|
|
277
290
|
}
|
|
278
291
|
if (parsedSchedule) {
|
|
279
292
|
const payload = {
|
|
@@ -298,6 +311,7 @@ cli
|
|
|
298
311
|
channelId: threadData.parent_id,
|
|
299
312
|
threadId: targetThreadId,
|
|
300
313
|
sessionId: sessionId || undefined,
|
|
314
|
+
// channelConfig is guaranteed: early guard threw if missing with --send-at
|
|
301
315
|
projectDirectory: channelConfig.directory,
|
|
302
316
|
});
|
|
303
317
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`;
|
|
@@ -337,6 +351,8 @@ cli
|
|
|
337
351
|
process.stdout.write(`Session: ${existingSessionId}\n`);
|
|
338
352
|
process.stdout.write(`${threadUrl}\n`);
|
|
339
353
|
if (options.wait) {
|
|
354
|
+
// channelConfig is guaranteed here: early guard above already
|
|
355
|
+
// threw if channelConfig is missing when --wait is used.
|
|
340
356
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
341
357
|
await waitAndOutputSession({
|
|
342
358
|
threadId: targetThreadId,
|
|
@@ -352,17 +368,25 @@ cli
|
|
|
352
368
|
}
|
|
353
369
|
// Get channel info to extract directory from topic
|
|
354
370
|
const channelData = (await rest.get(Routes.channel(channelId)));
|
|
371
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
372
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
373
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
374
|
+
// We only require it for features that genuinely need a local directory
|
|
375
|
+
// (--send-at, --wait, --cwd).
|
|
355
376
|
const channelConfig = await getChannelDirectory(channelData.id);
|
|
356
|
-
if (!channelConfig && !notifyOnly) {
|
|
357
|
-
cliLogger.log('Channel not configured');
|
|
358
|
-
throw new Error(`Channel #${channelData.name} is not configured with a project directory. Run the bot first to sync channel data.`);
|
|
359
|
-
}
|
|
360
377
|
const projectDirectory = channelConfig?.directory;
|
|
378
|
+
// Features that require a local project directory mapping
|
|
379
|
+
const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd);
|
|
380
|
+
if (!channelConfig && needsProjectDirectory) {
|
|
381
|
+
throw new Error(`Channel #${channelData.name} is not configured with a project directory. ` +
|
|
382
|
+
`${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
|
|
383
|
+
'Run the bot first to sync channel data.');
|
|
384
|
+
}
|
|
361
385
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
362
386
|
let resolvedCwd;
|
|
363
387
|
if (options.cwd) {
|
|
364
|
-
// projectDirectory is guaranteed here:
|
|
365
|
-
//
|
|
388
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
389
|
+
// already threw if channelConfig is missing when --cwd is used.
|
|
366
390
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
367
391
|
projectDirectory: projectDirectory,
|
|
368
392
|
candidatePath: options.cwd,
|
|
@@ -510,8 +534,8 @@ cli
|
|
|
510
534
|
process.stdout.write(`Session: ${newSessionId}\n`);
|
|
511
535
|
process.stdout.write(`${threadUrl}\n`);
|
|
512
536
|
if (options.wait) {
|
|
513
|
-
// projectDirectory is guaranteed here:
|
|
514
|
-
//
|
|
537
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
538
|
+
// already threw if channelConfig is missing when --wait is used.
|
|
515
539
|
const { waitAndOutputSession } = await import('../wait-session.js');
|
|
516
540
|
await waitAndOutputSession({
|
|
517
541
|
threadId: threadData.id,
|
|
@@ -126,6 +126,7 @@ cli
|
|
|
126
126
|
cli
|
|
127
127
|
.command('session read <sessionId>', 'Read a session conversation as markdown (pipe to file to grep)')
|
|
128
128
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
129
|
+
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
129
130
|
.action(async (sessionId, options) => {
|
|
130
131
|
try {
|
|
131
132
|
const projectDirectory = path.resolve(options.project || '.');
|
|
@@ -137,8 +138,9 @@ cli
|
|
|
137
138
|
process.exit(EXIT_NO_RESTART);
|
|
138
139
|
}
|
|
139
140
|
// Try current project first (fast path)
|
|
141
|
+
const compactTools = !options.verbose;
|
|
140
142
|
const markdown = new ShareMarkdown(getClient());
|
|
141
|
-
const result = await markdown.generate({ sessionID: sessionId });
|
|
143
|
+
const result = await markdown.generate({ sessionID: sessionId, compactTools });
|
|
142
144
|
if (!(result instanceof Error)) {
|
|
143
145
|
process.stdout.write(result);
|
|
144
146
|
process.exit(0);
|
|
@@ -171,6 +173,7 @@ cli
|
|
|
171
173
|
const otherMarkdown = new ShareMarkdown(otherClient());
|
|
172
174
|
const otherResult = await otherMarkdown.generate({
|
|
173
175
|
sessionID: sessionId,
|
|
176
|
+
compactTools,
|
|
174
177
|
});
|
|
175
178
|
if (!(otherResult instanceof Error)) {
|
|
176
179
|
process.stdout.write(otherResult);
|
package/dist/markdown.js
CHANGED
|
@@ -26,7 +26,7 @@ export class ShareMarkdown {
|
|
|
26
26
|
* @returns Error or markdown string
|
|
27
27
|
*/
|
|
28
28
|
async generate(options) {
|
|
29
|
-
const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
|
|
29
|
+
const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options;
|
|
30
30
|
// Get session info
|
|
31
31
|
const sessionResponse = await this.client.session.get({
|
|
32
32
|
sessionID,
|
|
@@ -75,13 +75,13 @@ export class ShareMarkdown {
|
|
|
75
75
|
lines.push('');
|
|
76
76
|
}
|
|
77
77
|
for (const message of messagesToRender) {
|
|
78
|
-
const messageLines = this.renderMessage(message.info, message.parts);
|
|
78
|
+
const messageLines = this.renderMessage(message.info, message.parts, { compactTools });
|
|
79
79
|
lines.push(...messageLines);
|
|
80
80
|
lines.push('');
|
|
81
81
|
}
|
|
82
82
|
return lines.join('\n');
|
|
83
83
|
}
|
|
84
|
-
renderMessage(message, parts) {
|
|
84
|
+
renderMessage(message, parts, opts) {
|
|
85
85
|
const lines = [];
|
|
86
86
|
if (message.role === 'user') {
|
|
87
87
|
lines.push('### 👤 User');
|
|
@@ -128,7 +128,9 @@ export class ShareMarkdown {
|
|
|
128
128
|
return true;
|
|
129
129
|
});
|
|
130
130
|
for (const part of filteredParts) {
|
|
131
|
-
const partLines =
|
|
131
|
+
const partLines = opts.compactTools
|
|
132
|
+
? this.renderPartCompact(part, message)
|
|
133
|
+
: this.renderPart(part, message);
|
|
132
134
|
lines.push(...partLines);
|
|
133
135
|
}
|
|
134
136
|
// Add completion time if available
|
|
@@ -214,6 +216,59 @@ export class ShareMarkdown {
|
|
|
214
216
|
}
|
|
215
217
|
return lines;
|
|
216
218
|
}
|
|
219
|
+
/** Compact rendering: tool calls become a single line with line count instead of full output. */
|
|
220
|
+
renderPartCompact(part, message) {
|
|
221
|
+
// Non-tool parts render the same as verbose
|
|
222
|
+
if (part.type !== 'tool') {
|
|
223
|
+
return this.renderPart(part, message);
|
|
224
|
+
}
|
|
225
|
+
const lines = [];
|
|
226
|
+
if (part.state.status === 'completed') {
|
|
227
|
+
const output = part.state.output || '';
|
|
228
|
+
const lineCount = output ? output.split('\n').length : 0;
|
|
229
|
+
const inputSummary = this.compactInputSummary(part.state.input);
|
|
230
|
+
const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : '';
|
|
231
|
+
const parts = [inputSummary, outputLabel].filter(Boolean).join(' ');
|
|
232
|
+
lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`);
|
|
233
|
+
lines.push('');
|
|
234
|
+
}
|
|
235
|
+
else if (part.state.status === 'error') {
|
|
236
|
+
const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120);
|
|
237
|
+
lines.push(`> ❌ **${part.tool}** — ${errorText}`);
|
|
238
|
+
lines.push('');
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
/** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
|
|
243
|
+
compactInputSummary(input) {
|
|
244
|
+
if (!input)
|
|
245
|
+
return '';
|
|
246
|
+
const entries = Object.entries(input);
|
|
247
|
+
if (entries.length === 0)
|
|
248
|
+
return '';
|
|
249
|
+
const parts = [];
|
|
250
|
+
const maxParams = 2;
|
|
251
|
+
for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
|
|
252
|
+
const [key, value] = entries[i];
|
|
253
|
+
let val;
|
|
254
|
+
try {
|
|
255
|
+
val = typeof value === 'string'
|
|
256
|
+
? value
|
|
257
|
+
: (JSON.stringify(value) ?? String(value));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
val = String(value);
|
|
261
|
+
}
|
|
262
|
+
// Collapse whitespace for readability
|
|
263
|
+
const collapsed = val.replace(/\s+/g, ' ').trim();
|
|
264
|
+
const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed;
|
|
265
|
+
parts.push(`${key}=${normalized}`);
|
|
266
|
+
}
|
|
267
|
+
if (entries.length > maxParams) {
|
|
268
|
+
parts.push('...');
|
|
269
|
+
}
|
|
270
|
+
return parts.join(', ');
|
|
271
|
+
}
|
|
217
272
|
formatDuration(ms) {
|
|
218
273
|
if (ms < 1000)
|
|
219
274
|
return `${ms}ms`;
|
package/dist/markdown.test.js
CHANGED
|
@@ -36,6 +36,18 @@ function createMatchers() {
|
|
|
36
36
|
],
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
|
+
const toolCallMatcher = {
|
|
40
|
+
id: 'tool-call-reply',
|
|
41
|
+
priority: 90,
|
|
42
|
+
when: { latestUserTextIncludes: 'use a tool please' },
|
|
43
|
+
then: {
|
|
44
|
+
parts: [
|
|
45
|
+
{ type: 'stream-start', warnings: [] },
|
|
46
|
+
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
|
|
47
|
+
{ type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
39
51
|
const defaultMatcher = {
|
|
40
52
|
id: 'default-reply',
|
|
41
53
|
priority: 1,
|
|
@@ -49,12 +61,13 @@ function createMatchers() {
|
|
|
49
61
|
],
|
|
50
62
|
},
|
|
51
63
|
};
|
|
52
|
-
return [helloMatcher, defaultMatcher];
|
|
64
|
+
return [helloMatcher, toolCallMatcher, defaultMatcher];
|
|
53
65
|
}
|
|
54
66
|
let client;
|
|
55
67
|
let directories;
|
|
56
68
|
let testStartTime;
|
|
57
69
|
let sessionID;
|
|
70
|
+
let toolSessionID;
|
|
58
71
|
beforeAll(async () => {
|
|
59
72
|
testStartTime = Date.now();
|
|
60
73
|
directories = createRunDirectories();
|
|
@@ -117,7 +130,34 @@ beforeAll(async () => {
|
|
|
117
130
|
setTimeout(resolve, 200);
|
|
118
131
|
});
|
|
119
132
|
}
|
|
120
|
-
|
|
133
|
+
// Create a second session that triggers a tool call (bash echo)
|
|
134
|
+
const toolCreateResult = await client.session.create({
|
|
135
|
+
directory: directories.projectDirectory,
|
|
136
|
+
title: 'Tool Call Session',
|
|
137
|
+
});
|
|
138
|
+
toolSessionID = toolCreateResult.data.id;
|
|
139
|
+
await client.session.promptAsync({
|
|
140
|
+
sessionID: toolSessionID,
|
|
141
|
+
directory: directories.projectDirectory,
|
|
142
|
+
parts: [{ type: 'text', text: 'use a tool please' }],
|
|
143
|
+
});
|
|
144
|
+
// Wait for tool execution to complete
|
|
145
|
+
const toolMaxWait = 15_000;
|
|
146
|
+
const toolPollStart = Date.now();
|
|
147
|
+
while (Date.now() - toolPollStart < toolMaxWait) {
|
|
148
|
+
const msgs = await client.session.messages({
|
|
149
|
+
sessionID: toolSessionID,
|
|
150
|
+
directory: directories.projectDirectory,
|
|
151
|
+
});
|
|
152
|
+
const messages = msgs.data || [];
|
|
153
|
+
const hasToolPart = messages.some((m) => m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'));
|
|
154
|
+
if (hasToolPart) {
|
|
155
|
+
await new Promise((resolve) => { setTimeout(resolve, 500); });
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((resolve) => { setTimeout(resolve, 200); });
|
|
159
|
+
}
|
|
160
|
+
}, 30_000);
|
|
121
161
|
afterAll(async () => {
|
|
122
162
|
if (directories) {
|
|
123
163
|
await cleanupTestSessions({
|
|
@@ -262,3 +302,34 @@ test('generate markdown with lastAssistantOnly', async () => {
|
|
|
262
302
|
// Should contain the assistant response
|
|
263
303
|
expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
|
|
264
304
|
});
|
|
305
|
+
test('compact tools: tool calls show one-liner with line count', async () => {
|
|
306
|
+
const exporter = new ShareMarkdown(client);
|
|
307
|
+
const result = await exporter.generate({
|
|
308
|
+
sessionID: toolSessionID,
|
|
309
|
+
compactTools: true,
|
|
310
|
+
});
|
|
311
|
+
expect(errore.isOk(result)).toBe(true);
|
|
312
|
+
const md = errore.unwrap(result);
|
|
313
|
+
// Compact mode: exact one-liner format with params and line count
|
|
314
|
+
expect(md).toContain('> 🛠️ **bash** command=echo hello world, description=Print greeting');
|
|
315
|
+
expect(md).toMatch(/\(\d+ lines?\)/);
|
|
316
|
+
// Should NOT contain full output code blocks or input YAML
|
|
317
|
+
expect(md).not.toContain('**Output:**');
|
|
318
|
+
expect(md).not.toContain('**Input:**');
|
|
319
|
+
expect(md).not.toContain('```yaml');
|
|
320
|
+
});
|
|
321
|
+
test('verbose tools: tool calls show full input and output', async () => {
|
|
322
|
+
const exporter = new ShareMarkdown(client);
|
|
323
|
+
const result = await exporter.generate({
|
|
324
|
+
sessionID: toolSessionID,
|
|
325
|
+
compactTools: false,
|
|
326
|
+
});
|
|
327
|
+
expect(errore.isOk(result)).toBe(true);
|
|
328
|
+
const md = errore.unwrap(result);
|
|
329
|
+
// Verbose mode: full tool rendering with input YAML and output code block
|
|
330
|
+
expect(md).toContain('#### 🛠️ Tool: bash');
|
|
331
|
+
expect(md).toContain('**Input:**');
|
|
332
|
+
expect(md).toContain('```yaml');
|
|
333
|
+
expect(md).toContain('**Output:**');
|
|
334
|
+
expect(md).toContain('hello world');
|
|
335
|
+
});
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "kimaki",
|
|
3
3
|
"module": "index.ts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.19.0",
|
|
6
6
|
"repository": "https://github.com/remorses/kimaki",
|
|
7
7
|
"bin": "bin.js",
|
|
8
8
|
"files": [
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"tsx": "^4.20.5",
|
|
26
26
|
"undici": "^8.0.2",
|
|
27
27
|
"discord-digital-twin": "^0.1.0",
|
|
28
|
-
"db": "^0.0.0",
|
|
29
28
|
"opencode-cached-provider": "^0.0.1",
|
|
30
|
-
"opencode-deterministic-provider": "^0.0.1"
|
|
29
|
+
"opencode-deterministic-provider": "^0.0.1",
|
|
30
|
+
"db": "^0.0.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@ai-sdk/google": "^3.0.53",
|
package/skills/sigillo/SKILL.md
CHANGED
|
@@ -199,6 +199,40 @@ After setup, `sigillo run` in any subdirectory uses that project + environment a
|
|
|
199
199
|
|
|
200
200
|
Avoid `sigillo secrets download` unless a specific tool requires a file. Prefer injecting directly via `sigillo run --` so values never touch the filesystem.
|
|
201
201
|
|
|
202
|
+
## Placeholder secrets (user fills in later)
|
|
203
|
+
|
|
204
|
+
When the user asks to add a secret but will provide the actual value later via the dashboard, set it with an empty value:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
sigillo secrets set DATABASE_URL ""
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This creates the secret as a placeholder. The CLI shows `empty: true` when listing secrets so empty placeholders are visible. After setting the placeholder, always print the dashboard URL so the user can fill it in:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
https://sigillo.dev/dash/projects/<PROJECT_ID>/envs/<ENV_SLUG>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Replace `<PROJECT_ID>` and `<ENV_SLUG>` with the actual values from the current setup. To find them:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
sigillo secrets
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The `environment_id` in the output is the env ID. The project ID is from `sigillo setup` or `sigillo projects`.
|
|
223
|
+
|
|
224
|
+
To set placeholders across all environments at once:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
sigillo secrets set DATABASE_URL "" -c dev -c preview -c prod
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
For secrets that need real random values immediately (auth secrets, encryption keys), generate them instead of leaving empty:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
sigillo secrets set AUTH_SECRET "$(openssl rand -base64 32)" -c dev -c preview -c prod
|
|
234
|
+
```
|
|
235
|
+
|
|
202
236
|
## Bootstrapping a project for a new codebase
|
|
203
237
|
|
|
204
238
|
When a codebase needs Sigillo for the first time, the agent creates the org, project, and placeholder secrets. The user fills in real values later via the web UI.
|
package/src/cli-commands/send.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Terminal send command for creating Discord threads and scheduling prompts.
|
|
2
|
+
// Designed to work in CI/headless environments with just KIMAKI_BOT_TOKEN.
|
|
3
|
+
// The local SQLite database (channel_directories) is NOT required for the basic
|
|
4
|
+
// flow: post message → create thread → remote bot picks it up. The local project
|
|
5
|
+
// directory mapping is only needed for --send-at, --wait, and --cwd.
|
|
2
6
|
import { goke } from 'goke'
|
|
3
7
|
import { z } from 'zod'
|
|
4
8
|
import { note } from '@clack/prompts'
|
|
@@ -395,10 +399,20 @@ cli
|
|
|
395
399
|
throw new Error(`Thread has no parent channel: ${targetThreadId}`)
|
|
396
400
|
}
|
|
397
401
|
|
|
402
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
403
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
404
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
405
|
+
// We only require it for features that genuinely need a local directory
|
|
406
|
+
// (scheduled tasks and --wait).
|
|
398
407
|
const channelConfig = await getChannelDirectory(threadData.parent_id)
|
|
399
|
-
|
|
408
|
+
|
|
409
|
+
// Guard early: fail before sending the message if a feature that
|
|
410
|
+
// needs local project directory mapping is requested.
|
|
411
|
+
if (!channelConfig && (parsedSchedule || options.wait)) {
|
|
412
|
+
const flag = parsedSchedule ? '--send-at' : '--wait'
|
|
400
413
|
throw new Error(
|
|
401
|
-
'Thread parent channel is not configured with a project directory'
|
|
414
|
+
'Thread parent channel is not configured with a project directory. ' +
|
|
415
|
+
`${flag} requires a local project mapping. Run the bot first to sync channel data.`,
|
|
402
416
|
)
|
|
403
417
|
}
|
|
404
418
|
|
|
@@ -425,7 +439,8 @@ cli
|
|
|
425
439
|
channelId: threadData.parent_id,
|
|
426
440
|
threadId: targetThreadId,
|
|
427
441
|
sessionId: sessionId || undefined,
|
|
428
|
-
|
|
442
|
+
// channelConfig is guaranteed: early guard threw if missing with --send-at
|
|
443
|
+
projectDirectory: channelConfig!.directory,
|
|
429
444
|
})
|
|
430
445
|
|
|
431
446
|
const threadUrl = `https://discord.com/channels/${threadData.guild_id}/${threadData.id}`
|
|
@@ -475,10 +490,12 @@ cli
|
|
|
475
490
|
process.stdout.write(`${threadUrl}\n`)
|
|
476
491
|
|
|
477
492
|
if (options.wait) {
|
|
493
|
+
// channelConfig is guaranteed here: early guard above already
|
|
494
|
+
// threw if channelConfig is missing when --wait is used.
|
|
478
495
|
const { waitAndOutputSession } = await import('../wait-session.js')
|
|
479
496
|
await waitAndOutputSession({
|
|
480
497
|
threadId: targetThreadId,
|
|
481
|
-
projectDirectory: channelConfig
|
|
498
|
+
projectDirectory: channelConfig!.directory,
|
|
482
499
|
waitStartedAtMs,
|
|
483
500
|
})
|
|
484
501
|
}
|
|
@@ -500,22 +517,29 @@ cli
|
|
|
500
517
|
guild_id: string
|
|
501
518
|
}
|
|
502
519
|
|
|
520
|
+
// channelConfig is optional: in CI/headless environments the local DB
|
|
521
|
+
// has no channel_directories rows because the bot hasn't synced yet.
|
|
522
|
+
// The running bot on the other end resolves the directory from its own DB.
|
|
523
|
+
// We only require it for features that genuinely need a local directory
|
|
524
|
+
// (--send-at, --wait, --cwd).
|
|
503
525
|
const channelConfig = await getChannelDirectory(channelData.id)
|
|
526
|
+
const projectDirectory = channelConfig?.directory
|
|
504
527
|
|
|
505
|
-
|
|
506
|
-
|
|
528
|
+
// Features that require a local project directory mapping
|
|
529
|
+
const needsProjectDirectory = Boolean(parsedSchedule || options.wait || options.cwd)
|
|
530
|
+
if (!channelConfig && needsProjectDirectory) {
|
|
507
531
|
throw new Error(
|
|
508
|
-
`Channel #${channelData.name} is not configured with a project directory.
|
|
532
|
+
`Channel #${channelData.name} is not configured with a project directory. ` +
|
|
533
|
+
`${parsedSchedule ? '--send-at' : options.wait ? '--wait' : '--cwd'} requires a local project mapping. ` +
|
|
534
|
+
'Run the bot first to sync channel data.',
|
|
509
535
|
)
|
|
510
536
|
}
|
|
511
537
|
|
|
512
|
-
const projectDirectory = channelConfig?.directory
|
|
513
|
-
|
|
514
538
|
// Validate --cwd is inside the project or an existing git worktree.
|
|
515
539
|
let resolvedCwd: string | undefined
|
|
516
540
|
if (options.cwd) {
|
|
517
|
-
// projectDirectory is guaranteed here:
|
|
518
|
-
//
|
|
541
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
542
|
+
// already threw if channelConfig is missing when --cwd is used.
|
|
519
543
|
const cwdResult = await resolveSessionWorkingDirectory({
|
|
520
544
|
projectDirectory: projectDirectory!,
|
|
521
545
|
candidatePath: options.cwd,
|
|
@@ -690,8 +714,8 @@ cli
|
|
|
690
714
|
process.stdout.write(`${threadUrl}\n`)
|
|
691
715
|
|
|
692
716
|
if (options.wait) {
|
|
693
|
-
// projectDirectory is guaranteed here:
|
|
694
|
-
//
|
|
717
|
+
// projectDirectory is guaranteed here: needsProjectDirectory check above
|
|
718
|
+
// already threw if channelConfig is missing when --wait is used.
|
|
695
719
|
const { waitAndOutputSession } = await import('../wait-session.js')
|
|
696
720
|
await waitAndOutputSession({
|
|
697
721
|
threadId: threadData.id,
|
|
@@ -188,7 +188,8 @@ cli
|
|
|
188
188
|
'Read a session conversation as markdown (pipe to file to grep)',
|
|
189
189
|
)
|
|
190
190
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
191
|
-
.
|
|
191
|
+
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
192
|
+
.action(async (sessionId: string, options: { project?: string; verbose?: boolean }) => {
|
|
192
193
|
try {
|
|
193
194
|
const projectDirectory = path.resolve(options.project || '.')
|
|
194
195
|
|
|
@@ -202,8 +203,9 @@ cli
|
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
// Try current project first (fast path)
|
|
206
|
+
const compactTools = !options.verbose
|
|
205
207
|
const markdown = new ShareMarkdown(getClient())
|
|
206
|
-
const result = await markdown.generate({ sessionID: sessionId })
|
|
208
|
+
const result = await markdown.generate({ sessionID: sessionId, compactTools })
|
|
207
209
|
if (!(result instanceof Error)) {
|
|
208
210
|
process.stdout.write(result)
|
|
209
211
|
process.exit(0)
|
|
@@ -236,6 +238,7 @@ cli
|
|
|
236
238
|
const otherMarkdown = new ShareMarkdown(otherClient())
|
|
237
239
|
const otherResult = await otherMarkdown.generate({
|
|
238
240
|
sessionID: sessionId,
|
|
241
|
+
compactTools,
|
|
239
242
|
})
|
|
240
243
|
if (!(otherResult instanceof Error)) {
|
|
241
244
|
process.stdout.write(otherResult)
|
package/src/markdown.test.ts
CHANGED
|
@@ -45,6 +45,19 @@ function createMatchers(): DeterministicMatcher[] {
|
|
|
45
45
|
},
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const toolCallMatcher: DeterministicMatcher = {
|
|
49
|
+
id: 'tool-call-reply',
|
|
50
|
+
priority: 90,
|
|
51
|
+
when: { latestUserTextIncludes: 'use a tool please' },
|
|
52
|
+
then: {
|
|
53
|
+
parts: [
|
|
54
|
+
{ type: 'stream-start', warnings: [] },
|
|
55
|
+
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
|
|
56
|
+
{ type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
const defaultMatcher: DeterministicMatcher = {
|
|
49
62
|
id: 'default-reply',
|
|
50
63
|
priority: 1,
|
|
@@ -59,13 +72,14 @@ function createMatchers(): DeterministicMatcher[] {
|
|
|
59
72
|
},
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
return [helloMatcher, defaultMatcher]
|
|
75
|
+
return [helloMatcher, toolCallMatcher, defaultMatcher]
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
let client: OpencodeClient
|
|
66
79
|
let directories: ReturnType<typeof createRunDirectories>
|
|
67
80
|
let testStartTime: number
|
|
68
81
|
let sessionID: string
|
|
82
|
+
let toolSessionID: string
|
|
69
83
|
|
|
70
84
|
beforeAll(async () => {
|
|
71
85
|
testStartTime = Date.now()
|
|
@@ -148,7 +162,39 @@ beforeAll(async () => {
|
|
|
148
162
|
setTimeout(resolve, 200)
|
|
149
163
|
})
|
|
150
164
|
}
|
|
151
|
-
|
|
165
|
+
|
|
166
|
+
// Create a second session that triggers a tool call (bash echo)
|
|
167
|
+
const toolCreateResult = await client.session.create({
|
|
168
|
+
directory: directories.projectDirectory,
|
|
169
|
+
title: 'Tool Call Session',
|
|
170
|
+
})
|
|
171
|
+
toolSessionID = toolCreateResult.data!.id
|
|
172
|
+
|
|
173
|
+
await client.session.promptAsync({
|
|
174
|
+
sessionID: toolSessionID,
|
|
175
|
+
directory: directories.projectDirectory,
|
|
176
|
+
parts: [{ type: 'text', text: 'use a tool please' }],
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Wait for tool execution to complete
|
|
180
|
+
const toolMaxWait = 15_000
|
|
181
|
+
const toolPollStart = Date.now()
|
|
182
|
+
while (Date.now() - toolPollStart < toolMaxWait) {
|
|
183
|
+
const msgs = await client.session.messages({
|
|
184
|
+
sessionID: toolSessionID,
|
|
185
|
+
directory: directories.projectDirectory,
|
|
186
|
+
})
|
|
187
|
+
const messages = msgs.data || []
|
|
188
|
+
const hasToolPart = messages.some((m) =>
|
|
189
|
+
m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'),
|
|
190
|
+
)
|
|
191
|
+
if (hasToolPart) {
|
|
192
|
+
await new Promise((resolve) => { setTimeout(resolve, 500) })
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
await new Promise((resolve) => { setTimeout(resolve, 200) })
|
|
196
|
+
}
|
|
197
|
+
}, 30_000)
|
|
152
198
|
|
|
153
199
|
afterAll(async () => {
|
|
154
200
|
if (directories) {
|
|
@@ -313,3 +359,44 @@ test('generate markdown with lastAssistantOnly', async () => {
|
|
|
313
359
|
// Should contain the assistant response
|
|
314
360
|
expect(markdown).toContain('Hello! This is a deterministic markdown test response.')
|
|
315
361
|
})
|
|
362
|
+
|
|
363
|
+
test('compact tools: tool calls show one-liner with line count', async () => {
|
|
364
|
+
const exporter = new ShareMarkdown(client)
|
|
365
|
+
|
|
366
|
+
const result = await exporter.generate({
|
|
367
|
+
sessionID: toolSessionID,
|
|
368
|
+
compactTools: true,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(errore.isOk(result)).toBe(true)
|
|
372
|
+
const md = errore.unwrap(result)
|
|
373
|
+
|
|
374
|
+
// Compact mode: exact one-liner format with params and line count
|
|
375
|
+
expect(md).toContain(
|
|
376
|
+
'> 🛠️ **bash** command=echo hello world, description=Print greeting',
|
|
377
|
+
)
|
|
378
|
+
expect(md).toMatch(/\(\d+ lines?\)/)
|
|
379
|
+
// Should NOT contain full output code blocks or input YAML
|
|
380
|
+
expect(md).not.toContain('**Output:**')
|
|
381
|
+
expect(md).not.toContain('**Input:**')
|
|
382
|
+
expect(md).not.toContain('```yaml')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('verbose tools: tool calls show full input and output', async () => {
|
|
386
|
+
const exporter = new ShareMarkdown(client)
|
|
387
|
+
|
|
388
|
+
const result = await exporter.generate({
|
|
389
|
+
sessionID: toolSessionID,
|
|
390
|
+
compactTools: false,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
expect(errore.isOk(result)).toBe(true)
|
|
394
|
+
const md = errore.unwrap(result)
|
|
395
|
+
|
|
396
|
+
// Verbose mode: full tool rendering with input YAML and output code block
|
|
397
|
+
expect(md).toContain('#### 🛠️ Tool: bash')
|
|
398
|
+
expect(md).toContain('**Input:**')
|
|
399
|
+
expect(md).toContain('```yaml')
|
|
400
|
+
expect(md).toContain('**Output:**')
|
|
401
|
+
expect(md).toContain('hello world')
|
|
402
|
+
})
|
package/src/markdown.ts
CHANGED
|
@@ -32,8 +32,10 @@ export class ShareMarkdown {
|
|
|
32
32
|
sessionID: string
|
|
33
33
|
includeSystemInfo?: boolean
|
|
34
34
|
lastAssistantOnly?: boolean
|
|
35
|
+
/** When true (default), tool calls show a compact one-liner with line count instead of full output. */
|
|
36
|
+
compactTools?: boolean
|
|
35
37
|
}): Promise<SessionNotFoundError | MessagesNotFoundError | string> {
|
|
36
|
-
const { sessionID, includeSystemInfo, lastAssistantOnly } = options
|
|
38
|
+
const { sessionID, includeSystemInfo, lastAssistantOnly, compactTools = true } = options
|
|
37
39
|
|
|
38
40
|
// Get session info
|
|
39
41
|
const sessionResponse = await this.client.session.get({
|
|
@@ -96,7 +98,7 @@ export class ShareMarkdown {
|
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
for (const message of messagesToRender) {
|
|
99
|
-
const messageLines = this.renderMessage(message!.info, message!.parts)
|
|
101
|
+
const messageLines = this.renderMessage(message!.info, message!.parts, { compactTools })
|
|
100
102
|
lines.push(...messageLines)
|
|
101
103
|
lines.push('')
|
|
102
104
|
}
|
|
@@ -104,7 +106,7 @@ export class ShareMarkdown {
|
|
|
104
106
|
return lines.join('\n')
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
private renderMessage(message: any, parts: any[]): string[] {
|
|
109
|
+
private renderMessage(message: any, parts: any[], opts: { compactTools: boolean }): string[] {
|
|
108
110
|
const lines: string[] = []
|
|
109
111
|
|
|
110
112
|
if (message.role === 'user') {
|
|
@@ -148,7 +150,9 @@ export class ShareMarkdown {
|
|
|
148
150
|
})
|
|
149
151
|
|
|
150
152
|
for (const part of filteredParts) {
|
|
151
|
-
const partLines =
|
|
153
|
+
const partLines = opts.compactTools
|
|
154
|
+
? this.renderPartCompact(part, message)
|
|
155
|
+
: this.renderPart(part, message)
|
|
152
156
|
lines.push(...partLines)
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -251,6 +255,61 @@ export class ShareMarkdown {
|
|
|
251
255
|
return lines
|
|
252
256
|
}
|
|
253
257
|
|
|
258
|
+
/** Compact rendering: tool calls become a single line with line count instead of full output. */
|
|
259
|
+
private renderPartCompact(part: any, message: any): string[] {
|
|
260
|
+
// Non-tool parts render the same as verbose
|
|
261
|
+
if (part.type !== 'tool') {
|
|
262
|
+
return this.renderPart(part, message)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const lines: string[] = []
|
|
266
|
+
|
|
267
|
+
if (part.state.status === 'completed') {
|
|
268
|
+
const output: string = part.state.output || ''
|
|
269
|
+
const lineCount = output ? output.split('\n').length : 0
|
|
270
|
+
const inputSummary = this.compactInputSummary(part.state.input)
|
|
271
|
+
const outputLabel = lineCount > 0 ? `(${lineCount} lines)` : ''
|
|
272
|
+
const parts = [inputSummary, outputLabel].filter(Boolean).join(' ')
|
|
273
|
+
lines.push(`> 🛠️ **${part.tool}**${parts ? ` ${parts}` : ''}`)
|
|
274
|
+
lines.push('')
|
|
275
|
+
} else if (part.state.status === 'error') {
|
|
276
|
+
const errorText = (part.state.error || 'Unknown error').split('\n')[0].slice(0, 120)
|
|
277
|
+
lines.push(`> ❌ **${part.tool}** — ${errorText}`)
|
|
278
|
+
lines.push('')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return lines
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Build a compact key=value summary of tool input, max 2 params, 80 chars each. */
|
|
285
|
+
private compactInputSummary(input: Record<string, unknown> | undefined): string {
|
|
286
|
+
if (!input) return ''
|
|
287
|
+
const entries = Object.entries(input)
|
|
288
|
+
if (entries.length === 0) return ''
|
|
289
|
+
|
|
290
|
+
const parts: string[] = []
|
|
291
|
+
const maxParams = 2
|
|
292
|
+
for (let i = 0; i < Math.min(entries.length, maxParams); i++) {
|
|
293
|
+
const [key, value] = entries[i]!
|
|
294
|
+
let val: string
|
|
295
|
+
try {
|
|
296
|
+
val = typeof value === 'string'
|
|
297
|
+
? value
|
|
298
|
+
: (JSON.stringify(value) ?? String(value))
|
|
299
|
+
} catch {
|
|
300
|
+
val = String(value)
|
|
301
|
+
}
|
|
302
|
+
// Collapse whitespace for readability
|
|
303
|
+
const collapsed = val.replace(/\s+/g, ' ').trim()
|
|
304
|
+
const normalized = collapsed.length > 80 ? `${collapsed.slice(0, 80)}…` : collapsed
|
|
305
|
+
parts.push(`${key}=${normalized}`)
|
|
306
|
+
}
|
|
307
|
+
if (entries.length > maxParams) {
|
|
308
|
+
parts.push('...')
|
|
309
|
+
}
|
|
310
|
+
return parts.join(', ')
|
|
311
|
+
}
|
|
312
|
+
|
|
254
313
|
private formatDuration(ms: number): string {
|
|
255
314
|
if (ms < 1000) return `${ms}ms`
|
|
256
315
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|