@otto-assistant/otto 0.1.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.
Files changed (76) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +257 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config.d.ts +39 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +264 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/config.test.d.ts +2 -0
  10. package/dist/config.test.d.ts.map +1 -0
  11. package/dist/config.test.js +202 -0
  12. package/dist/config.test.js.map +1 -0
  13. package/dist/detect.d.ts +9 -0
  14. package/dist/detect.d.ts.map +1 -0
  15. package/dist/detect.js +40 -0
  16. package/dist/detect.js.map +1 -0
  17. package/dist/detect.test.d.ts +2 -0
  18. package/dist/detect.test.d.ts.map +1 -0
  19. package/dist/detect.test.js +25 -0
  20. package/dist/detect.test.js.map +1 -0
  21. package/dist/health.d.ts +27 -0
  22. package/dist/health.d.ts.map +1 -0
  23. package/dist/health.js +78 -0
  24. package/dist/health.js.map +1 -0
  25. package/dist/health.test.d.ts +2 -0
  26. package/dist/health.test.d.ts.map +1 -0
  27. package/dist/health.test.js +33 -0
  28. package/dist/health.test.js.map +1 -0
  29. package/dist/index.d.ts +12 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +11 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +8 -0
  36. package/dist/index.test.js.map +1 -0
  37. package/dist/installer.d.ts +10 -0
  38. package/dist/installer.d.ts.map +1 -0
  39. package/dist/installer.js +50 -0
  40. package/dist/installer.js.map +1 -0
  41. package/dist/installer.test.d.ts +2 -0
  42. package/dist/installer.test.d.ts.map +1 -0
  43. package/dist/installer.test.js +43 -0
  44. package/dist/installer.test.js.map +1 -0
  45. package/dist/lifecycle.d.ts +4 -0
  46. package/dist/lifecycle.d.ts.map +1 -0
  47. package/dist/lifecycle.js +30 -0
  48. package/dist/lifecycle.js.map +1 -0
  49. package/dist/lifecycle.test.d.ts +2 -0
  50. package/dist/lifecycle.test.d.ts.map +1 -0
  51. package/dist/lifecycle.test.js +19 -0
  52. package/dist/lifecycle.test.js.map +1 -0
  53. package/dist/manifest.d.ts +18 -0
  54. package/dist/manifest.d.ts.map +1 -0
  55. package/dist/manifest.js +30 -0
  56. package/dist/manifest.js.map +1 -0
  57. package/dist/sync.d.ts +10 -0
  58. package/dist/sync.d.ts.map +1 -0
  59. package/dist/sync.js +39 -0
  60. package/dist/sync.js.map +1 -0
  61. package/package.json +41 -0
  62. package/src/cli.ts +291 -0
  63. package/src/config.test.ts +237 -0
  64. package/src/config.ts +362 -0
  65. package/src/detect.test.ts +28 -0
  66. package/src/detect.ts +52 -0
  67. package/src/health.test.ts +39 -0
  68. package/src/health.ts +114 -0
  69. package/src/index.test.ts +8 -0
  70. package/src/index.ts +28 -0
  71. package/src/installer.test.ts +52 -0
  72. package/src/installer.ts +62 -0
  73. package/src/lifecycle.test.ts +22 -0
  74. package/src/lifecycle.ts +30 -0
  75. package/src/manifest.ts +42 -0
  76. package/src/sync.ts +53 -0
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ mergePlugins,
4
+ readOttoConfig,
5
+ writeOttoConfig,
6
+ OTTO_DEFAULTS,
7
+ buildSubagentThreadPolicy,
8
+ mergeAgentPrompts,
9
+ ensureSubagentThreadSkill,
10
+ readOpenCodeConfigState,
11
+ type OpenCodeConfig,
12
+ type OttoConfig,
13
+ } from "./config.js"
14
+ import fs from "node:fs"
15
+ import path from "node:path"
16
+ import os from "node:os"
17
+
18
+ describe("config", () => {
19
+ it("adds plugin to empty config", () => {
20
+ const config: OpenCodeConfig = {}
21
+ const result = mergePlugins(config, "opencode-agent-memory")
22
+ expect(result.plugin).toEqual(["opencode-agent-memory"])
23
+ })
24
+
25
+ it("appends plugin to existing array", () => {
26
+ const config: OpenCodeConfig = { plugin: ["existing-plugin"] }
27
+ const result = mergePlugins(config, "opencode-agent-memory")
28
+ expect(result.plugin).toEqual(["existing-plugin", "opencode-agent-memory"])
29
+ })
30
+
31
+ it("does not duplicate existing plugin", () => {
32
+ const config: OpenCodeConfig = { plugin: ["opencode-agent-memory"] }
33
+ const result = mergePlugins(config, "opencode-agent-memory")
34
+ expect(result.plugin).toEqual(["opencode-agent-memory"])
35
+ })
36
+
37
+ it("preserves other config fields", () => {
38
+ const config: OpenCodeConfig = {
39
+ model: "gpt-4",
40
+ plugin: ["existing"],
41
+ provider: { cursor: { name: "Cursor" } },
42
+ }
43
+ const result = mergePlugins(config, "opencode-agent-memory")
44
+ expect(result.model).toBe("gpt-4")
45
+ expect(result.provider).toEqual({ cursor: { name: "Cursor" } })
46
+ expect(result.plugin).toEqual(["existing", "opencode-agent-memory"])
47
+ })
48
+
49
+ it("readOttoConfig returns defaults when otto.json missing", () => {
50
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
51
+ try {
52
+ const result = readOttoConfig(tmpDir)
53
+ expect(result.subagentThreads).toEqual({
54
+ enabled: true,
55
+ askBeforeDelete: true,
56
+ autoDeleteOnComplete: false,
57
+ })
58
+ } finally {
59
+ fs.rmSync(tmpDir, { recursive: true, force: true })
60
+ }
61
+ })
62
+
63
+ it("writeOttoConfig + readOttoConfig round-trip", () => {
64
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
65
+ try {
66
+ const config: OttoConfig = {
67
+ subagentThreads: {
68
+ enabled: false,
69
+ askBeforeDelete: false,
70
+ autoDeleteOnComplete: true,
71
+ },
72
+ }
73
+ const written = writeOttoConfig(config, tmpDir)
74
+ expect(written).toBe(true)
75
+
76
+ const read = readOttoConfig(tmpDir)
77
+ expect(read.subagentThreads).toEqual({
78
+ enabled: false,
79
+ askBeforeDelete: false,
80
+ autoDeleteOnComplete: true,
81
+ })
82
+
83
+ // Idempotent
84
+ const writtenAgain = writeOttoConfig(config, tmpDir)
85
+ expect(writtenAgain).toBe(false)
86
+ } finally {
87
+ fs.rmSync(tmpDir, { recursive: true, force: true })
88
+ }
89
+ })
90
+
91
+ it("readOttoConfig fills defaults for partial config", () => {
92
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
93
+ try {
94
+ fs.mkdirSync(tmpDir, { recursive: true })
95
+ fs.writeFileSync(
96
+ path.join(tmpDir, "otto.json"),
97
+ JSON.stringify({ subagentThreads: { enabled: false } }, null, 2) + "\n",
98
+ "utf-8",
99
+ )
100
+ const result = readOttoConfig(tmpDir)
101
+ expect(result.subagentThreads.enabled).toBe(false)
102
+ expect(result.subagentThreads.askBeforeDelete).toBe(true) // default
103
+ expect(result.subagentThreads.autoDeleteOnComplete).toBe(false) // default
104
+ } finally {
105
+ fs.rmSync(tmpDir, { recursive: true, force: true })
106
+ }
107
+ })
108
+
109
+ it("policy includes kimaki send commands when enabled", () => {
110
+ const ottoConfig: OttoConfig = {
111
+ subagentThreads: { enabled: true, askBeforeDelete: true, autoDeleteOnComplete: false },
112
+ }
113
+ const policy = buildSubagentThreadPolicy(ottoConfig)
114
+
115
+ expect(policy).toContain("Otto subagent policy (must follow):")
116
+ expect(policy).toContain("subagent_threads_enabled: true")
117
+ expect(policy).toContain("kimaki send")
118
+ expect(policy).toContain("--channel")
119
+ expect(policy).toContain("--wait")
120
+ expect(policy).toContain("ask the user")
121
+ })
122
+
123
+ it("policy is short when threads disabled", () => {
124
+ const ottoConfig: OttoConfig = {
125
+ subagentThreads: { enabled: false, askBeforeDelete: true, autoDeleteOnComplete: false },
126
+ }
127
+ const policy = buildSubagentThreadPolicy(ottoConfig)
128
+
129
+ expect(policy).toContain("subagent_threads_enabled: false")
130
+ expect(policy).toContain("without creating Discord threads")
131
+ expect(policy).not.toContain("kimaki send")
132
+ })
133
+
134
+ it("policy includes auto-archive when autoDeleteOnComplete is true", () => {
135
+ const ottoConfig: OttoConfig = {
136
+ subagentThreads: { enabled: true, askBeforeDelete: false, autoDeleteOnComplete: true },
137
+ }
138
+ const policy = buildSubagentThreadPolicy(ottoConfig)
139
+
140
+ expect(policy).toContain("auto_delete_on_complete: true")
141
+ expect(policy).toContain("kimaki session archive")
142
+ expect(policy).not.toContain("ask the user")
143
+ })
144
+
145
+ it("injects policy into existing agent prompt", () => {
146
+ const config: OpenCodeConfig = {
147
+ agent: { build: { prompt: "existing rule" } },
148
+ }
149
+
150
+ const ottoConfig: OttoConfig = OTTO_DEFAULTS
151
+ const policy = buildSubagentThreadPolicy(ottoConfig)
152
+ const result = mergeAgentPrompts(config, policy)
153
+ const prompt = (result.agent as Record<string, { prompt?: string }>).build.prompt
154
+
155
+ expect(prompt).toContain("existing rule")
156
+ expect(prompt).toContain("Otto subagent policy (must follow):")
157
+ expect(prompt).toContain("subagent_threads_enabled: true")
158
+ })
159
+
160
+ it("does not duplicate policy when already present", () => {
161
+ const ottoConfig: OttoConfig = OTTO_DEFAULTS
162
+ const policy = buildSubagentThreadPolicy(ottoConfig)
163
+ const config: OpenCodeConfig = {
164
+ agent: { build: { prompt: policy } },
165
+ }
166
+
167
+ const result = mergeAgentPrompts(config, policy)
168
+ const prompt = (result.agent as Record<string, { prompt?: string }>).build.prompt ?? ""
169
+ expect(prompt.match(/Otto subagent policy \(must follow\):/g)?.length).toBe(1)
170
+ })
171
+
172
+ it("creates build agent with policy when agent section is empty", () => {
173
+ const ottoConfig: OttoConfig = OTTO_DEFAULTS
174
+ const policy = buildSubagentThreadPolicy(ottoConfig)
175
+ const result = mergeAgentPrompts({}, policy)
176
+ const agents = result.agent as Record<string, { prompt?: string }>
177
+
178
+ expect(agents.build?.prompt).toContain("Otto subagent policy (must follow):")
179
+ })
180
+
181
+ it("readOpenCodeConfigState returns missing when file absent", () => {
182
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
183
+ try {
184
+ const result = readOpenCodeConfigState(tmpDir)
185
+ expect(result.status).toBe("missing")
186
+ expect(result.config).toEqual({})
187
+ } finally {
188
+ fs.rmSync(tmpDir, { recursive: true, force: true })
189
+ }
190
+ })
191
+
192
+ it("readOpenCodeConfigState returns ok for valid JSON", () => {
193
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
194
+ try {
195
+ fs.mkdirSync(tmpDir, { recursive: true })
196
+ fs.writeFileSync(path.join(tmpDir, "opencode.json"), "{\"model\":\"x\"}\n", "utf-8")
197
+ const result = readOpenCodeConfigState(tmpDir)
198
+ expect(result.status).toBe("ok")
199
+ expect(result.config).toEqual({ model: "x" })
200
+ } finally {
201
+ fs.rmSync(tmpDir, { recursive: true, force: true })
202
+ }
203
+ })
204
+
205
+ it("readOpenCodeConfigState returns invalid for broken JSON", () => {
206
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
207
+ try {
208
+ fs.mkdirSync(tmpDir, { recursive: true })
209
+ fs.writeFileSync(path.join(tmpDir, "opencode.json"), "{not json", "utf-8")
210
+ const result = readOpenCodeConfigState(tmpDir)
211
+ expect(result.status).toBe("invalid")
212
+ expect(result.config).toEqual({})
213
+ } finally {
214
+ fs.rmSync(tmpDir, { recursive: true, force: true })
215
+ }
216
+ })
217
+
218
+ it("ensureSubagentThreadSkill creates skill file", () => {
219
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "otto-test-"))
220
+ try {
221
+ const created = ensureSubagentThreadSkill(tmpDir)
222
+ expect(created).toBe(true)
223
+
224
+ const skillPath = path.join(tmpDir, "skills", "otto-subagent-threads", "SKILL.md")
225
+ const content = fs.readFileSync(skillPath, "utf-8")
226
+ expect(content).toContain("name: otto-subagent-threads")
227
+ expect(content).toContain("kimaki send")
228
+ expect(content).toContain("kimaki session archive")
229
+
230
+ // Idempotent — second call returns false
231
+ const second = ensureSubagentThreadSkill(tmpDir)
232
+ expect(second).toBe(false)
233
+ } finally {
234
+ fs.rmSync(tmpDir, { recursive: true, force: true })
235
+ }
236
+ })
237
+ })
package/src/config.ts ADDED
@@ -0,0 +1,362 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { OPENCODE_CONFIG_DIR } from "./manifest.js"
4
+
5
+ export interface OttoSubagentThreads {
6
+ enabled?: boolean
7
+ askBeforeDelete?: boolean
8
+ autoDeleteOnComplete?: boolean
9
+ }
10
+
11
+ export interface OttoConfig {
12
+ subagentThreads: {
13
+ enabled: boolean
14
+ askBeforeDelete: boolean
15
+ autoDeleteOnComplete: boolean
16
+ }
17
+ }
18
+
19
+ /** Defaults used when otto.json is missing or partial. */
20
+ export const OTTO_DEFAULTS: OttoConfig = {
21
+ subagentThreads: {
22
+ enabled: true,
23
+ askBeforeDelete: true,
24
+ autoDeleteOnComplete: false,
25
+ },
26
+ }
27
+
28
+ export interface OpenCodeConfig {
29
+ $schema?: string
30
+ model?: string
31
+ plugin?: string[]
32
+ provider?: Record<string, unknown>
33
+ agent?: Record<string, unknown>
34
+ [key: string]: unknown
35
+ }
36
+
37
+ export function mergePlugins(config: OpenCodeConfig, pluginToAdd: string): OpenCodeConfig {
38
+ const plugins = config.plugin ?? []
39
+ if (plugins.includes(pluginToAdd)) {
40
+ return config
41
+ }
42
+ return { ...config, plugin: [...plugins, pluginToAdd] }
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Otto's own config (stored in ~/.config/opencode/otto.json, NOT opencode.json)
47
+ // opencode rejects unknown keys in its config — Otto must not pollute it.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type OttoConfigReadStatus = "missing" | "ok" | "invalid"
51
+
52
+ export function readOttoConfig(dir?: string): OttoConfig {
53
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
54
+ const configPath = path.join(configDir, "otto.json")
55
+ try {
56
+ const raw = fs.readFileSync(configPath, "utf-8")
57
+ const parsed = JSON.parse(raw) as Partial<OttoConfig>
58
+ return {
59
+ subagentThreads: {
60
+ enabled: parsed.subagentThreads?.enabled ?? OTTO_DEFAULTS.subagentThreads.enabled,
61
+ askBeforeDelete: parsed.subagentThreads?.askBeforeDelete ?? OTTO_DEFAULTS.subagentThreads.askBeforeDelete,
62
+ autoDeleteOnComplete: parsed.subagentThreads?.autoDeleteOnComplete ?? OTTO_DEFAULTS.subagentThreads.autoDeleteOnComplete,
63
+ },
64
+ }
65
+ } catch {
66
+ return { ...OTTO_DEFAULTS }
67
+ }
68
+ }
69
+
70
+ export function writeOttoConfig(config: OttoConfig, dir?: string): boolean {
71
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
72
+ const configPath = path.join(configDir, "otto.json")
73
+
74
+ let existing: string | null = null
75
+ try {
76
+ existing = fs.readFileSync(configPath, "utf-8")
77
+ } catch {
78
+ // file doesn't exist
79
+ }
80
+
81
+ const newContent = JSON.stringify(config, null, 2) + "\n"
82
+ if (existing === newContent) {
83
+ return false
84
+ }
85
+
86
+ fs.mkdirSync(configDir, { recursive: true })
87
+ fs.writeFileSync(configPath, newContent, "utf-8")
88
+ return true
89
+ }
90
+
91
+ export function buildSubagentThreadPolicy(ottoConfig: OttoConfig): string {
92
+ const policy = ottoConfig.subagentThreads
93
+ const enabled = policy.enabled
94
+ const askBeforeDelete = policy.askBeforeDelete
95
+ const autoDeleteOnComplete = policy.autoDeleteOnComplete
96
+
97
+ const lines = [
98
+ "Otto subagent policy (must follow):",
99
+ `- subagent_threads_enabled: ${enabled}`,
100
+ `- subagent_threads_ask_before_delete: ${askBeforeDelete}`,
101
+ `- subagent_threads_auto_delete_on_complete: ${autoDeleteOnComplete}`,
102
+ ]
103
+
104
+ if (!enabled) {
105
+ lines.push(
106
+ "- subagent_threads_enabled is false — you may spawn subagents normally (Task tool or otherwise) without creating Discord threads.",
107
+ )
108
+ return lines.join("\n")
109
+ }
110
+
111
+ lines.push(
112
+ "",
113
+ "## Spawning subagents with Discord threads",
114
+ "",
115
+ "When you need to spawn a subagent (parallel task, background investigation, etc.), do NOT use the Task tool directly.",
116
+ "Instead, create a visible Discord thread using `kimaki send` via Bash:",
117
+ "",
118
+ "```bash",
119
+ 'kimaki send --channel <channel_id> --prompt "detailed task description" --user "<username>"',
120
+ "```",
121
+ "",
122
+ "The channel_id and username are in your system prompt (\"Your current Discord channel ID\" / \"Discord\" user field).",
123
+ "",
124
+ "For sequential tasks where you need the result before continuing, use --wait (set Bash timeout to 1_500_000+):",
125
+ "```bash",
126
+ 'kimaki send --channel <channel_id> --prompt "task" --user "<username>" --wait',
127
+ "```",
128
+ "",
129
+ "## After subagent completes",
130
+ "",
131
+ )
132
+
133
+ if (autoDeleteOnComplete) {
134
+ lines.push(
135
+ "- subagent_threads_auto_delete_on_complete is true → automatically archive the thread after the subagent finishes:",
136
+ " ```bash",
137
+ ' kimaki session archive --session <session_id>',
138
+ " ```",
139
+ )
140
+ } else if (askBeforeDelete) {
141
+ lines.push(
142
+ "- subagent_threads_ask_before_delete is true → after the subagent finishes, ask the user:",
143
+ ' "Subagent thread created. Should I archive it or keep it open?"',
144
+ " If user says archive → `kimaki session archive --session <session_id>`",
145
+ )
146
+ } else {
147
+ lines.push(
148
+ "- Keep the subagent thread open after completion (no auto-archive, no prompt).",
149
+ )
150
+ }
151
+
152
+ lines.push(
153
+ "",
154
+ "## Required: load skills before subagent execution",
155
+ "",
156
+ "Before dispatching any subagent, load the skill: `otto-subagent-threads`.",
157
+ "This ensures the subagent follows the same thread creation rules if it spawns further subagents.",
158
+ )
159
+
160
+ return lines.join("\n")
161
+ }
162
+
163
+ export function mergeAgentPrompts(config: OpenCodeConfig, policyText: string): OpenCodeConfig {
164
+ const marker = "Otto subagent policy (must follow):"
165
+ const agent = (config.agent ?? {}) as Record<string, unknown>
166
+ const entries = Object.entries(agent)
167
+
168
+ if (entries.length === 0) {
169
+ return {
170
+ ...config,
171
+ agent: {
172
+ build: { prompt: policyText },
173
+ },
174
+ }
175
+ }
176
+
177
+ const nextAgent: Record<string, unknown> = {}
178
+ for (const [name, value] of entries) {
179
+ if (value && typeof value === "object" && !Array.isArray(value)) {
180
+ const typed = value as Record<string, unknown>
181
+ const existingPrompt = typeof typed.prompt === "string" ? typed.prompt : ""
182
+ const hasPolicy = existingPrompt.includes(marker)
183
+ nextAgent[name] = {
184
+ ...typed,
185
+ prompt: hasPolicy
186
+ ? existingPrompt
187
+ : (existingPrompt.length > 0 ? `${existingPrompt}\n\n${policyText}` : policyText),
188
+ }
189
+ } else {
190
+ nextAgent[name] = value
191
+ }
192
+ }
193
+
194
+ return { ...config, agent: nextAgent }
195
+ }
196
+
197
+ export type OpenCodeConfigReadStatus = "missing" | "ok" | "invalid"
198
+
199
+ /** Distinguishes a missing file from invalid JSON (both previously surfaced as `{}`). */
200
+ export function readOpenCodeConfigState(dir?: string): {
201
+ config: OpenCodeConfig
202
+ status: OpenCodeConfigReadStatus
203
+ } {
204
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
205
+ const configPath = path.join(configDir, "opencode.json")
206
+ if (!fs.existsSync(configPath)) {
207
+ return { config: {}, status: "missing" }
208
+ }
209
+ try {
210
+ const raw = fs.readFileSync(configPath, "utf-8")
211
+ return { config: JSON.parse(raw) as OpenCodeConfig, status: "ok" }
212
+ } catch {
213
+ return { config: {}, status: "invalid" }
214
+ }
215
+ }
216
+
217
+ export function readOpenCodeConfig(dir?: string): OpenCodeConfig {
218
+ return readOpenCodeConfigState(dir).config
219
+ }
220
+
221
+ export function writeOpenCodeConfig(config: OpenCodeConfig, dir?: string): boolean {
222
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
223
+ const configPath = path.join(configDir, "opencode.json")
224
+
225
+ let existing: string | null = null
226
+ try {
227
+ existing = fs.readFileSync(configPath, "utf-8")
228
+ } catch {
229
+ // file doesn't exist
230
+ }
231
+
232
+ const newContent = JSON.stringify(config, null, 2) + "\n"
233
+ if (existing === newContent) {
234
+ return false
235
+ }
236
+
237
+ fs.mkdirSync(configDir, { recursive: true })
238
+ fs.writeFileSync(configPath, newContent, "utf-8")
239
+ return true
240
+ }
241
+
242
+ export function ensureAgentMemoryConfig(dir?: string): boolean {
243
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
244
+ const configPath = path.join(configDir, "agent-memory.json")
245
+
246
+ try {
247
+ fs.readFileSync(configPath, "utf-8")
248
+ return false
249
+ } catch {
250
+ // create with defaults
251
+ }
252
+
253
+ const defaults = {
254
+ journal: {
255
+ enabled: true,
256
+ tags: [
257
+ { name: "infra", description: "Infrastructure changes, host/service topology, runtime environment" },
258
+ { name: "debugging", description: "Bug investigations, root-cause analysis, and troubleshooting" },
259
+ { name: "decision", description: "Architecture and implementation decisions with rationale" },
260
+ { name: "incident", description: "Service failures, outages, and recovery actions" },
261
+ { name: "automation", description: "Agent workflows, scripts, and repeatable operational routines" },
262
+ ],
263
+ },
264
+ }
265
+
266
+ fs.mkdirSync(configDir, { recursive: true })
267
+ fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2) + "\n", "utf-8")
268
+ return true
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Skill file: otto-subagent-threads
273
+ // ---------------------------------------------------------------------------
274
+
275
+ const SKILL_NAME = "otto-subagent-threads"
276
+
277
+ const SKILL_MD = `---
278
+ name: otto-subagent-threads
279
+ description: Enforce visible Discord threads for every subagent spawn. Use before dispatching any subagent via kimaki send.
280
+ ---
281
+
282
+ # Otto Subagent Threads
283
+
284
+ ## When to Use
285
+
286
+ Use this skill EVERY TIME you are about to spawn a subagent (parallel task, background investigation, code review, etc.).
287
+
288
+ ## Rules
289
+
290
+ ### 1. NEVER use Task tool directly for subagents
291
+
292
+ The Task tool spawns a subagent in "shadow mode" — invisible to the user in Discord.
293
+ Instead, create a visible Discord thread.
294
+
295
+ ### 2. Create a Discord thread with kimaki send
296
+
297
+ \`\`\`bash
298
+ kimaki send --channel <channel_id> --prompt "detailed task description" --user "<username>"
299
+ \`\`\`
300
+
301
+ Get channel_id and username from your system prompt:
302
+ - "Your current Discord channel ID is: ..."
303
+ - Discord user info section
304
+
305
+ ### 3. Wait for result if needed
306
+
307
+ If you need the subagent's output before continuing, add \`--wait\` and set Bash timeout to 1_500_000+:
308
+
309
+ \`\`\`bash
310
+ kimaki send --channel <channel_id> --prompt "task" --user "<username>" --wait
311
+ \`\`\`
312
+
313
+ ### 4. After subagent completes
314
+
315
+ Check the otto.subagentThreads config:
316
+ - **autoDeleteOnComplete = true** → archive the thread:
317
+ \`\`\`bash
318
+ kimaki session archive --session <session_id>
319
+ \`\`\`
320
+ - **askBeforeDelete = true** → ask the user:
321
+ "Subagent finished. Should I archive the thread or keep it open?"
322
+ - **both false** → keep the thread open.
323
+
324
+ ### 5. Nested subagents
325
+
326
+ If your subagent itself needs to spawn further subagents, include these same rules in its prompt so it also creates visible threads.
327
+
328
+ ## Quick Reference
329
+
330
+ | Scenario | Command |
331
+ |----------|---------|
332
+ | Parallel task (fire & forget) | \`kimaki send -c <ch> -p "task" -u <user>\` |
333
+ | Sequential task (need result) | \`kimaki send -c <ch> -p "task" -u <user> --wait\` |
334
+ | Archive thread | \`kimaki session archive -s <session_id>\` |
335
+ | List sessions | \`kimaki session list\` |
336
+ | Read session output | \`kimaki session read <session_id> > ./tmp/session.md 2>/dev/null\` |
337
+
338
+ ## Anti-patterns
339
+
340
+ - **DO NOT** use the Task tool to spawn subagents (creates invisible shadow sessions).
341
+ - **DO NOT** create worktrees (\`--worktree\`) unless the user explicitly asks.
342
+ - **DO NOT** forget to include your channel_id in the kimaki send command.
343
+ `
344
+
345
+ export function ensureSubagentThreadSkill(dir?: string): boolean {
346
+ const configDir = dir ?? OPENCODE_CONFIG_DIR()
347
+ const skillDir = path.join(configDir, "skills", SKILL_NAME)
348
+ const skillPath = path.join(skillDir, "SKILL.md")
349
+
350
+ try {
351
+ const existing = fs.readFileSync(skillPath, "utf-8")
352
+ if (existing === SKILL_MD) {
353
+ return false // already up to date
354
+ }
355
+ } catch {
356
+ // file doesn't exist — will create
357
+ }
358
+
359
+ fs.mkdirSync(skillDir, { recursive: true })
360
+ fs.writeFileSync(skillPath, SKILL_MD, "utf-8")
361
+ return true
362
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { getInstalledVersion, detectPackage, type InstalledPackage } from "./detect.js"
3
+
4
+ describe("detect", () => {
5
+ it("returns null for non-existent package", () => {
6
+ const result = getInstalledVersion("nonexistent-package-xyz-123")
7
+ expect(result).toBeNull()
8
+ })
9
+
10
+ // These tests require kimaki to be globally installed (skipped on CI)
11
+ const describeIfInstalled = process.env.CI ? describe.skip : describe
12
+
13
+ describeIfInstalled("with kimaki installed", () => {
14
+ it("detects an existing global package", () => {
15
+ const result = getInstalledVersion("kimaki")
16
+ expect(result).not.toBeNull()
17
+ expect(result).toMatch(/^\d+\.\d+\.\d+/)
18
+ })
19
+
20
+ it("detectPackage returns full info", async () => {
21
+ const result = await detectPackage("kimaki")
22
+ expect(result).toEqual({
23
+ name: "kimaki",
24
+ installed: expect.any(String),
25
+ })
26
+ })
27
+ })
28
+ })
package/src/detect.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { execFileSync } from "node:child_process"
2
+
3
+ export interface InstalledPackage {
4
+ name: string
5
+ installed: string | null
6
+ }
7
+
8
+ let globalVersionCache: Map<string, string> | null = null
9
+
10
+ function loadGlobalPackageVersions(): Map<string, string> {
11
+ if (globalVersionCache) {
12
+ return globalVersionCache
13
+ }
14
+ const map = new Map<string, string>()
15
+ try {
16
+ const output = execFileSync(
17
+ process.platform === "win32" ? "npm.cmd" : "npm",
18
+ ["list", "-g", "--json", "--depth=0"],
19
+ {
20
+ encoding: "utf-8",
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ },
23
+ )
24
+ const parsed = JSON.parse(output) as { dependencies?: Record<string, { version?: string }> }
25
+ const deps = parsed.dependencies ?? {}
26
+ for (const [name, info] of Object.entries(deps)) {
27
+ if (info?.version) {
28
+ map.set(name, info.version)
29
+ }
30
+ }
31
+ } catch {
32
+ // leave map empty — callers treat missing entries as not installed
33
+ }
34
+ globalVersionCache = map
35
+ return map
36
+ }
37
+
38
+ /** For tests: reset cached `npm list -g` result between cases. */
39
+ export function clearGlobalNpmListCache(): void {
40
+ globalVersionCache = null
41
+ }
42
+
43
+ export function getInstalledVersion(packageName: string): string | null {
44
+ return loadGlobalPackageVersions().get(packageName) ?? null
45
+ }
46
+
47
+ export async function detectPackage(name: string): Promise<InstalledPackage> {
48
+ return {
49
+ name,
50
+ installed: getInstalledVersion(name),
51
+ }
52
+ }