kimaki 0.7.0 → 0.7.1

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.
@@ -65,6 +65,9 @@ const CLAUDE_CODE_VERSION = "2.1.75";
65
65
  const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
66
66
  const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
67
67
  const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
68
+ // Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
69
+ // line + an <env> block instead. We strip from here to </env> inclusive.
70
+ const SUBAGENT_MODEL_IDENTITY = "You are powered by the model named";
68
71
  const CLAUDE_CODE_BETA = "claude-code-20250219";
69
72
  const OAUTH_BETA = "oauth-2025-04-20";
70
73
  const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14";
@@ -477,28 +480,59 @@ function toClaudeCodeToolName(name) {
477
480
  */
478
481
  function sanitizeAnthropicSystemText(text, onError) {
479
482
  const startIdx = text.indexOf(OPENCODE_IDENTITY);
480
- if (startIdx === -1)
481
- return text;
482
- // Keep the marker aligned with the current OpenCode Anthropic prompt.
483
- const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
484
- if (endIdx === -1) {
485
- onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
486
- return text;
483
+ if (startIdx !== -1) {
484
+ // Main session path: strip from OpenCode identity to the Anthropic prompt marker.
485
+ // Keep the marker aligned with the current OpenCode Anthropic prompt.
486
+ const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
487
+ if (endIdx === -1) {
488
+ onError?.("sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity");
489
+ return text;
490
+ }
491
+ return replaceBlockWithCompactEnv(text, startIdx, endIdx);
492
+ }
493
+ // Subagent path: opencode appends "You are powered by the model named ..."
494
+ // followed by an <env> block. Strip from that line through </env>.
495
+ const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
496
+ if (subagentIdx !== -1) {
497
+ const envCloseTag = "</env>";
498
+ const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
499
+ if (envCloseIdx === -1) {
500
+ onError?.("sanitizeAnthropicSystemText: could not find </env> after subagent model identity");
501
+ return text;
502
+ }
503
+ const endIdx = envCloseIdx + envCloseTag.length;
504
+ // Skip trailing newline so the join is clean
505
+ const afterEnd = text[endIdx] === "\n" ? endIdx + 1 : endIdx;
506
+ return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
487
507
  }
488
- // Extract the cwd from the block we're about to strip. OpenCode's system
489
- // prompt embeds <environment><cwd>/path</cwd></environment> in the identity
490
- // block. We preserve the per-session cwd instead of falling back to
491
- // process.cwd() which is the opencode server's cwd and wrong for
492
- // multi-session/worktree setups where each session has a different directory.
508
+ return text;
509
+ }
510
+ // Extract cwd from the block being stripped and replace it with a compact
511
+ // <environment> tag. Shared by both main-session and subagent paths.
512
+ // Source: anomalyco/opencode packages/opencode/src/session/system.ts
513
+ // OpenCode's system prompt format (as of 2025):
514
+ // <env>
515
+ // Working directory: ${Instance.directory}
516
+ // Workspace root folder: ${Instance.worktree}
517
+ // Is directory a git repo: yes/no
518
+ // Platform: ${process.platform}
519
+ // Today's date: ${new Date().toDateString()}
520
+ // </env>
521
+ // Older format used <environment><cwd>/path</cwd></environment>.
522
+ // We try both patterns to stay compatible across opencode versions.
523
+ // We preserve the per-session directory instead of falling back to
524
+ // process.cwd() which is the opencode server's cwd and wrong for
525
+ // multi-session/worktree setups where each session has a different directory.
526
+ function replaceBlockWithCompactEnv(text, startIdx, endIdx) {
493
527
  const strippedBlock = text.slice(startIdx, endIdx);
494
- const cwdMatch = strippedBlock.match(/<cwd>([^<]+)<\/cwd>/);
495
- const cwd = cwdMatch?.[1] || process.cwd();
528
+ const cwdMatch = strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
529
+ strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
530
+ const cwd = cwdMatch || process.cwd();
496
531
  const envContext = `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
497
- `Read, write, and edit files under <cwd>.\n\n`;
498
- const result = text.slice(0, startIdx) +
532
+ `Read, write, and edit files under ${cwd}.\n\n`;
533
+ return (text.slice(0, startIdx) +
499
534
  envContext +
500
- text.slice(endIdx);
501
- return result;
535
+ text.slice(endIdx));
502
536
  }
503
537
  function mapSystemTextPart(part, onError) {
504
538
  if (typeof part === "string") {
@@ -439,6 +439,25 @@ Read this first.
439
439
  ]
440
440
  `);
441
441
  });
442
+ test('renders callout that was prefixed with ⬥ as plain text (regression)', () => {
443
+ // Before the fix, formatPart would add ⬥ prefix to callout lines,
444
+ // breaking the callout parser. Now formatPart skips the prefix for callouts.
445
+ const result = splitTablesFromMarkdown(`⬥ <callout accent="#ef4444">
446
+ ## Top priority
447
+ - **Stripe dispute** deadline
448
+ </callout>`);
449
+ expect(result).toMatchInlineSnapshot(`
450
+ [
451
+ {
452
+ "text": "⬥ <callout accent="#ef4444">
453
+ ## Top priority
454
+ - **Stripe dispute** deadline
455
+ </callout>",
456
+ "type": "text",
457
+ },
458
+ ]
459
+ `);
460
+ });
442
461
  test('falls back to plain text when a callout is not closed', () => {
443
462
  const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
444
463
  ## Important
@@ -307,7 +307,9 @@ export function formatPart(part, prefix) {
307
307
  }
308
308
  const firstChar = text[0] || '';
309
309
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
310
- const startsWithMarkdown = markdownStarters.includes(firstChar) || /^\d+\./.test(text);
310
+ const startsWithMarkdown = markdownStarters.includes(firstChar) ||
311
+ /^\d+\./.test(text) ||
312
+ /^<callout[\s>]/i.test(text);
311
313
  if (startsWithMarkdown) {
312
314
  return `\n${text}`;
313
315
  }
@@ -1,5 +1,47 @@
1
1
  import { describe, test, expect } from 'vitest';
2
- import { formatTodoList } from './message-formatting.js';
2
+ import { formatPart, formatTodoList } from './message-formatting.js';
3
+ describe('formatPart', () => {
4
+ test('callout text does not get ⬥ prefix', () => {
5
+ const part = {
6
+ id: 'test',
7
+ type: 'text',
8
+ sessionID: 'ses_test',
9
+ messageID: 'msg_test',
10
+ text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
11
+ };
12
+ expect(formatPart(part)).toMatchInlineSnapshot(`
13
+ "
14
+ <callout accent="#ef4444">
15
+ ## Top priority
16
+ - **Stripe dispute** deadline
17
+ </callout>"
18
+ `);
19
+ });
20
+ test('regular text gets ⬥ prefix', () => {
21
+ const part = {
22
+ id: 'test',
23
+ type: 'text',
24
+ sessionID: 'ses_test',
25
+ messageID: 'msg_test',
26
+ text: 'hello world',
27
+ };
28
+ expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`);
29
+ });
30
+ test('text starting with heading does not get ⬥ prefix', () => {
31
+ const part = {
32
+ id: 'test',
33
+ type: 'text',
34
+ sessionID: 'ses_test',
35
+ messageID: 'msg_test',
36
+ text: '## Summary\nDone.',
37
+ };
38
+ expect(formatPart(part)).toMatchInlineSnapshot(`
39
+ "
40
+ ## Summary
41
+ Done."
42
+ `);
43
+ });
44
+ });
3
45
  describe('formatTodoList', () => {
4
46
  test('formats active todo with monospace numbers', () => {
5
47
  const part = {
@@ -0,0 +1,85 @@
1
+ // WebSocket proxy preload for environments behind a firewall or VPN (e.g. GFW).
2
+ // Loaded via --require when HTTPS_PROXY / https_proxy / HTTP_PROXY / http_proxy is set.
3
+ //
4
+ // Monkey-patches Module.prototype.require to intercept `ws` module loads and
5
+ // inject an https-proxy-agent into every client-side WebSocket connection.
6
+ // This is needed because:
7
+ // 1. Node.js --use-env-proxy breaks ws WebSocket upgrades (nodejs/node#62054)
8
+ // 2. discord.js doesn't expose an agent option for WebSocket connections
9
+ // 3. @discordjs/ws imports ws via ESM, so require.cache patches don't work
10
+ // 4. The ws library supports agent in options but discord.js doesn't pass it
11
+ //
12
+ // Tracking issues:
13
+ // - nodejs/node#57872 (proxy env var support in Node.js)
14
+ // - nodejs/node#62054 (--use-env-proxy breaks ws)
15
+ // - discordjs/discord.js#10716 (proxy support for WebSocket, closed won't fix)
16
+ 'use strict';
17
+ const proxyUrl = process.env.https_proxy ||
18
+ process.env.HTTPS_PROXY ||
19
+ process.env.http_proxy ||
20
+ process.env.HTTP_PROXY;
21
+ if (!proxyUrl) {
22
+ return;
23
+ }
24
+ let HttpsProxyAgent;
25
+ try {
26
+ HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
27
+ }
28
+ catch {
29
+ // https-proxy-agent not installed; skip patching
30
+ return;
31
+ }
32
+ const agent = new HttpsProxyAgent(proxyUrl);
33
+ const Module = require('module');
34
+ const origRequire = Module.prototype.require;
35
+ Module.prototype.require = function patchedRequire(id) {
36
+ const mod = origRequire.apply(this, arguments);
37
+ // Only intercept the ws module, and only once
38
+ if (id === 'ws' && mod && !mod.__kimakiProxyPatched) {
39
+ const OrigWS = mod.WebSocket || mod;
40
+ if (typeof OrigWS === 'function' && !OrigWS.__kimakiProxyPatched) {
41
+ const PatchedWS = function ProxiedWebSocket(url, protocols, options) {
42
+ // ws allows protocols to be an options object when omitted
43
+ if (typeof protocols === 'object' &&
44
+ protocols !== null &&
45
+ !Array.isArray(protocols)) {
46
+ options = protocols;
47
+ protocols = undefined;
48
+ }
49
+ // Inject agent for client connections only (url !== null).
50
+ // Don't override if caller already provided an agent.
51
+ if (url !== null && (!options || !options.agent)) {
52
+ options = Object.assign({}, options, { agent });
53
+ }
54
+ if (new.target) {
55
+ return Reflect.construct(OrigWS, [url, protocols, options], new.target);
56
+ }
57
+ return new OrigWS(url, protocols, options);
58
+ };
59
+ // Inherit prototype so instanceof checks work
60
+ Object.setPrototypeOf(PatchedWS.prototype, OrigWS.prototype);
61
+ Object.setPrototypeOf(PatchedWS, OrigWS);
62
+ PatchedWS.prototype.constructor = PatchedWS;
63
+ // Copy static constants (CONNECTING, OPEN, CLOSING, CLOSED) and statics
64
+ for (const key of Object.getOwnPropertyNames(OrigWS)) {
65
+ if (key !== 'length' && key !== 'prototype' && key !== 'name') {
66
+ try {
67
+ Object.defineProperty(PatchedWS, key, Object.getOwnPropertyDescriptor(OrigWS, key));
68
+ }
69
+ catch {
70
+ // some properties may not be configurable
71
+ }
72
+ }
73
+ }
74
+ PatchedWS.__kimakiProxyPatched = true;
75
+ mod.WebSocket = PatchedWS;
76
+ // ws/index.js sets module.exports = WebSocket, so patch the default too
77
+ if (typeof mod === 'function') {
78
+ // Can't replace module.exports from here, but the WebSocket property
79
+ // is what @discordjs/ws uses via `import { WebSocket } from "ws"`
80
+ }
81
+ }
82
+ mod.__kimakiProxyPatched = true;
83
+ }
84
+ return mod;
85
+ };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.7.0",
5
+ "version": "0.7.1",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -26,8 +26,8 @@
26
26
  "tsx": "^4.20.5",
27
27
  "undici": "^8.0.2",
28
28
  "discord-digital-twin": "^0.1.0",
29
- "opencode-deterministic-provider": "^0.0.1",
30
29
  "opencode-cached-provider": "^0.0.1",
30
+ "opencode-deterministic-provider": "^0.0.1",
31
31
  "db": "^0.0.0"
32
32
  },
33
33
  "dependencies": {
@@ -64,10 +64,10 @@
64
64
  "yaml": "^2.8.3",
65
65
  "zod": "^4.3.6",
66
66
  "zustand": "^5.0.11",
67
- "errore": "^0.14.1",
68
67
  "libsqlproxy": "^0.1.0",
68
+ "opencode-injection-guard": "^0.2.1",
69
69
  "traforo": "^0.5.0",
70
- "opencode-injection-guard": "^0.2.1"
70
+ "errore": "^0.14.1"
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "@snazzah/davey": "^0.1.10",
@@ -93,6 +93,9 @@ const CLAUDE_CODE_IDENTITY =
93
93
  const OPENCODE_IDENTITY =
94
94
  "You are OpenCode, the best coding agent on the planet.";
95
95
  const ANTHROPIC_PROMPT_MARKER = "Skills provide specialized instructions";
96
+ // Subagent prompts don't contain OPENCODE_IDENTITY; opencode appends this
97
+ // line + an <env> block instead. We strip from here to </env> inclusive.
98
+ const SUBAGENT_MODEL_IDENTITY = "You are powered by the model named";
96
99
  const CLAUDE_CODE_BETA = "claude-code-20250219";
97
100
  const OAUTH_BETA = "oauth-2025-04-20";
98
101
  const FINE_GRAINED_TOOL_STREAMING_BETA =
@@ -613,36 +616,77 @@ function sanitizeAnthropicSystemText(
613
616
  onError?: (msg: string) => void,
614
617
  ) {
615
618
  const startIdx = text.indexOf(OPENCODE_IDENTITY);
616
- if (startIdx === -1) return text;
619
+ if (startIdx !== -1) {
620
+ // Main session path: strip from OpenCode identity to the Anthropic prompt marker.
621
+ // Keep the marker aligned with the current OpenCode Anthropic prompt.
622
+ const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
623
+ if (endIdx === -1) {
624
+ onError?.(
625
+ "sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity",
626
+ );
627
+ return text;
628
+ }
629
+ return replaceBlockWithCompactEnv(text, startIdx, endIdx);
630
+ }
617
631
 
618
- // Keep the marker aligned with the current OpenCode Anthropic prompt.
619
- const endIdx = text.indexOf(ANTHROPIC_PROMPT_MARKER, startIdx);
620
- if (endIdx === -1) {
621
- onError?.(
622
- "sanitizeAnthropicSystemText: could not find Anthropic prompt marker after OpenCode identity",
623
- );
624
- return text;
632
+ // Subagent path: opencode appends "You are powered by the model named ..."
633
+ // followed by an <env> block. Strip from that line through </env>.
634
+ const subagentIdx = text.indexOf(SUBAGENT_MODEL_IDENTITY);
635
+ if (subagentIdx !== -1) {
636
+ const envCloseTag = "</env>";
637
+ const envCloseIdx = text.indexOf(envCloseTag, subagentIdx);
638
+ if (envCloseIdx === -1) {
639
+ onError?.(
640
+ "sanitizeAnthropicSystemText: could not find </env> after subagent model identity",
641
+ );
642
+ return text;
643
+ }
644
+ const endIdx = envCloseIdx + envCloseTag.length;
645
+ // Skip trailing newline so the join is clean
646
+ const afterEnd =
647
+ text[endIdx] === "\n" ? endIdx + 1 : endIdx;
648
+ return replaceBlockWithCompactEnv(text, subagentIdx, afterEnd);
625
649
  }
626
650
 
627
- // Extract the cwd from the block we're about to strip. OpenCode's system
628
- // prompt embeds <environment><cwd>/path</cwd></environment> in the identity
629
- // block. We preserve the per-session cwd instead of falling back to
630
- // process.cwd() which is the opencode server's cwd and wrong for
631
- // multi-session/worktree setups where each session has a different directory.
651
+ return text;
652
+ }
653
+
654
+ // Extract cwd from the block being stripped and replace it with a compact
655
+ // <environment> tag. Shared by both main-session and subagent paths.
656
+ // Source: anomalyco/opencode packages/opencode/src/session/system.ts
657
+ // OpenCode's system prompt format (as of 2025):
658
+ // <env>
659
+ // Working directory: ${Instance.directory}
660
+ // Workspace root folder: ${Instance.worktree}
661
+ // Is directory a git repo: yes/no
662
+ // Platform: ${process.platform}
663
+ // Today's date: ${new Date().toDateString()}
664
+ // </env>
665
+ // Older format used <environment><cwd>/path</cwd></environment>.
666
+ // We try both patterns to stay compatible across opencode versions.
667
+ // We preserve the per-session directory instead of falling back to
668
+ // process.cwd() which is the opencode server's cwd and wrong for
669
+ // multi-session/worktree setups where each session has a different directory.
670
+ function replaceBlockWithCompactEnv(
671
+ text: string,
672
+ startIdx: number,
673
+ endIdx: number,
674
+ ) {
632
675
  const strippedBlock = text.slice(startIdx, endIdx);
633
- const cwdMatch = strippedBlock.match(/<cwd>([^<]+)<\/cwd>/);
634
- const cwd = cwdMatch?.[1] || process.cwd();
676
+ const cwdMatch =
677
+ strippedBlock.match(/Working directory:\s*(.+)/)?.[1]?.trim() ||
678
+ strippedBlock.match(/<cwd>([^<]+)<\/cwd>/)?.[1];
679
+ const cwd = cwdMatch || process.cwd();
635
680
 
636
681
  const envContext =
637
682
  `\n<environment>\n<cwd>${cwd}</cwd>\n</environment>\n` +
638
- `Read, write, and edit files under <cwd>.\n\n`;
683
+ `Read, write, and edit files under ${cwd}.\n\n`;
639
684
 
640
- const result =
685
+ return (
641
686
  text.slice(0, startIdx) +
642
687
  envContext +
643
- text.slice(endIdx);
644
-
645
- return result;
688
+ text.slice(endIdx)
689
+ );
646
690
  }
647
691
 
648
692
  function mapSystemTextPart(
@@ -475,6 +475,26 @@ Read this first.
475
475
  `)
476
476
  })
477
477
 
478
+ test('renders callout that was prefixed with ⬥ as plain text (regression)', () => {
479
+ // Before the fix, formatPart would add ⬥ prefix to callout lines,
480
+ // breaking the callout parser. Now formatPart skips the prefix for callouts.
481
+ const result = splitTablesFromMarkdown(`⬥ <callout accent="#ef4444">
482
+ ## Top priority
483
+ - **Stripe dispute** deadline
484
+ </callout>`)
485
+ expect(result).toMatchInlineSnapshot(`
486
+ [
487
+ {
488
+ "text": "⬥ <callout accent="#ef4444">
489
+ ## Top priority
490
+ - **Stripe dispute** deadline
491
+ </callout>",
492
+ "type": "text",
493
+ },
494
+ ]
495
+ `)
496
+ })
497
+
478
498
  test('falls back to plain text when a callout is not closed', () => {
479
499
  const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
480
500
  ## Important
@@ -1,7 +1,52 @@
1
1
  import { describe, test, expect } from 'vitest'
2
- import { formatTodoList } from './message-formatting.js'
2
+ import { formatPart, formatTodoList } from './message-formatting.js'
3
3
  import type { Part } from '@opencode-ai/sdk/v2'
4
4
 
5
+ describe('formatPart', () => {
6
+ test('callout text does not get ⬥ prefix', () => {
7
+ const part: Part = {
8
+ id: 'test',
9
+ type: 'text',
10
+ sessionID: 'ses_test',
11
+ messageID: 'msg_test',
12
+ text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
13
+ }
14
+ expect(formatPart(part)).toMatchInlineSnapshot(`
15
+ "
16
+ <callout accent="#ef4444">
17
+ ## Top priority
18
+ - **Stripe dispute** deadline
19
+ </callout>"
20
+ `)
21
+ })
22
+
23
+ test('regular text gets ⬥ prefix', () => {
24
+ const part: Part = {
25
+ id: 'test',
26
+ type: 'text',
27
+ sessionID: 'ses_test',
28
+ messageID: 'msg_test',
29
+ text: 'hello world',
30
+ }
31
+ expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`)
32
+ })
33
+
34
+ test('text starting with heading does not get ⬥ prefix', () => {
35
+ const part: Part = {
36
+ id: 'test',
37
+ type: 'text',
38
+ sessionID: 'ses_test',
39
+ messageID: 'msg_test',
40
+ text: '## Summary\nDone.',
41
+ }
42
+ expect(formatPart(part)).toMatchInlineSnapshot(`
43
+ "
44
+ ## Summary
45
+ Done."
46
+ `)
47
+ })
48
+ })
49
+
5
50
  describe('formatTodoList', () => {
6
51
  test('formats active todo with monospace numbers', () => {
7
52
  const part: Part = {
@@ -412,7 +412,9 @@ export function formatPart(part: Part, prefix?: string): string {
412
412
  const firstChar = text[0] || ''
413
413
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|']
414
414
  const startsWithMarkdown =
415
- markdownStarters.includes(firstChar) || /^\d+\./.test(text)
415
+ markdownStarters.includes(firstChar) ||
416
+ /^\d+\./.test(text) ||
417
+ /^<callout[\s>]/i.test(text)
416
418
  if (startsWithMarkdown) {
417
419
  return `\n${text}`
418
420
  }