agent-sin 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 (150) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +81 -0
  4. package/assets/logo.png +0 -0
  5. package/builtin-skills/_shared/_models_lib.py +227 -0
  6. package/builtin-skills/_shared/_profile_lib.py +98 -0
  7. package/builtin-skills/_shared/_schedules_lib.py +313 -0
  8. package/builtin-skills/_shared/_skill_settings_lib.py +153 -0
  9. package/builtin-skills/_shared/i18n.py +26 -0
  10. package/builtin-skills/memo-delete/main.py +155 -0
  11. package/builtin-skills/memo-delete/skill.yaml +57 -0
  12. package/builtin-skills/memo-index/main.py +178 -0
  13. package/builtin-skills/memo-index/skill.yaml +53 -0
  14. package/builtin-skills/memo-save/README.md +5 -0
  15. package/builtin-skills/memo-save/main.py +74 -0
  16. package/builtin-skills/memo-save/skill.yaml +52 -0
  17. package/builtin-skills/memo-search/README.md +10 -0
  18. package/builtin-skills/memo-search/main.py +97 -0
  19. package/builtin-skills/memo-search/skill.yaml +51 -0
  20. package/builtin-skills/memo-vector-search/main.py +121 -0
  21. package/builtin-skills/memo-vector-search/skill.yaml +53 -0
  22. package/builtin-skills/model-add/main.py +180 -0
  23. package/builtin-skills/model-add/skill.yaml +112 -0
  24. package/builtin-skills/model-list/main.py +93 -0
  25. package/builtin-skills/model-list/skill.yaml +48 -0
  26. package/builtin-skills/model-set/main.py +123 -0
  27. package/builtin-skills/model-set/skill.yaml +69 -0
  28. package/builtin-skills/profile-delete/_profile_lib.py +98 -0
  29. package/builtin-skills/profile-delete/main.py +98 -0
  30. package/builtin-skills/profile-delete/skill.yaml +64 -0
  31. package/builtin-skills/profile-edit/_profile_lib.py +98 -0
  32. package/builtin-skills/profile-edit/main.py +97 -0
  33. package/builtin-skills/profile-edit/skill.yaml +72 -0
  34. package/builtin-skills/profile-save/main.py +52 -0
  35. package/builtin-skills/profile-save/skill.yaml +69 -0
  36. package/builtin-skills/schedule-add/_schedules_lib.py +303 -0
  37. package/builtin-skills/schedule-add/main.py +137 -0
  38. package/builtin-skills/schedule-add/skill.yaml +94 -0
  39. package/builtin-skills/schedule-list/_schedules_lib.py +303 -0
  40. package/builtin-skills/schedule-list/main.py +86 -0
  41. package/builtin-skills/schedule-list/skill.yaml +45 -0
  42. package/builtin-skills/schedule-remove/_schedules_lib.py +303 -0
  43. package/builtin-skills/schedule-remove/main.py +69 -0
  44. package/builtin-skills/schedule-remove/skill.yaml +49 -0
  45. package/builtin-skills/schedule-toggle/_schedules_lib.py +303 -0
  46. package/builtin-skills/schedule-toggle/main.py +78 -0
  47. package/builtin-skills/schedule-toggle/skill.yaml +61 -0
  48. package/builtin-skills/skills-disable/main.py +63 -0
  49. package/builtin-skills/skills-disable/skill.yaml +52 -0
  50. package/builtin-skills/skills-enable/main.py +62 -0
  51. package/builtin-skills/skills-enable/skill.yaml +51 -0
  52. package/builtin-skills/todo-add/main.py +68 -0
  53. package/builtin-skills/todo-add/skill.yaml +53 -0
  54. package/builtin-skills/todo-delete/main.py +65 -0
  55. package/builtin-skills/todo-delete/skill.yaml +47 -0
  56. package/builtin-skills/todo-done/main.py +75 -0
  57. package/builtin-skills/todo-done/skill.yaml +47 -0
  58. package/builtin-skills/todo-list/main.py +91 -0
  59. package/builtin-skills/todo-list/skill.yaml +48 -0
  60. package/builtin-skills/todo-tick/main.py +125 -0
  61. package/builtin-skills/todo-tick/skill.yaml +48 -0
  62. package/dist/builder/build-action-classifier.d.ts +18 -0
  63. package/dist/builder/build-action-classifier.js +142 -0
  64. package/dist/builder/build-commands.d.ts +19 -0
  65. package/dist/builder/build-commands.js +133 -0
  66. package/dist/builder/build-flow.d.ts +72 -0
  67. package/dist/builder/build-flow.js +416 -0
  68. package/dist/builder/builder-session.d.ts +117 -0
  69. package/dist/builder/builder-session.js +1129 -0
  70. package/dist/builder/conversation-router.d.ts +22 -0
  71. package/dist/builder/conversation-router.js +69 -0
  72. package/dist/builder/intent-runtime-store.d.ts +7 -0
  73. package/dist/builder/intent-runtime-store.js +60 -0
  74. package/dist/builder/progress-format.d.ts +7 -0
  75. package/dist/builder/progress-format.js +46 -0
  76. package/dist/cli/index.d.ts +2 -0
  77. package/dist/cli/index.js +2835 -0
  78. package/dist/cli/spinner.d.ts +30 -0
  79. package/dist/cli/spinner.js +164 -0
  80. package/dist/core/ai-provider.d.ts +75 -0
  81. package/dist/core/ai-provider.js +678 -0
  82. package/dist/core/builtin-skills.d.ts +27 -0
  83. package/dist/core/builtin-skills.js +120 -0
  84. package/dist/core/chat-engine.d.ts +70 -0
  85. package/dist/core/chat-engine.js +812 -0
  86. package/dist/core/config.d.ts +127 -0
  87. package/dist/core/config.js +1379 -0
  88. package/dist/core/daily-memory-promotion.d.ts +21 -0
  89. package/dist/core/daily-memory-promotion.js +422 -0
  90. package/dist/core/i18n.d.ts +23 -0
  91. package/dist/core/i18n.js +167 -0
  92. package/dist/core/info-lines.d.ts +5 -0
  93. package/dist/core/info-lines.js +39 -0
  94. package/dist/core/input-schema.d.ts +2 -0
  95. package/dist/core/input-schema.js +156 -0
  96. package/dist/core/intent-router.d.ts +27 -0
  97. package/dist/core/intent-router.js +160 -0
  98. package/dist/core/logger.d.ts +60 -0
  99. package/dist/core/logger.js +240 -0
  100. package/dist/core/memory.d.ts +10 -0
  101. package/dist/core/memory.js +72 -0
  102. package/dist/core/message-utils.d.ts +13 -0
  103. package/dist/core/message-utils.js +104 -0
  104. package/dist/core/notifier.d.ts +17 -0
  105. package/dist/core/notifier.js +424 -0
  106. package/dist/core/output-writer.d.ts +13 -0
  107. package/dist/core/output-writer.js +100 -0
  108. package/dist/core/plan-decision.d.ts +16 -0
  109. package/dist/core/plan-decision.js +88 -0
  110. package/dist/core/profile-memory.d.ts +17 -0
  111. package/dist/core/profile-memory.js +142 -0
  112. package/dist/core/runtime.d.ts +50 -0
  113. package/dist/core/runtime.js +187 -0
  114. package/dist/core/scheduler.d.ts +28 -0
  115. package/dist/core/scheduler.js +155 -0
  116. package/dist/core/secrets.d.ts +31 -0
  117. package/dist/core/secrets.js +214 -0
  118. package/dist/core/service.d.ts +35 -0
  119. package/dist/core/service.js +479 -0
  120. package/dist/core/skill-planner.d.ts +24 -0
  121. package/dist/core/skill-planner.js +100 -0
  122. package/dist/core/skill-registry.d.ts +98 -0
  123. package/dist/core/skill-registry.js +319 -0
  124. package/dist/core/skill-scaffold.d.ts +33 -0
  125. package/dist/core/skill-scaffold.js +256 -0
  126. package/dist/core/skill-settings.d.ts +11 -0
  127. package/dist/core/skill-settings.js +63 -0
  128. package/dist/core/transfer.d.ts +31 -0
  129. package/dist/core/transfer.js +270 -0
  130. package/dist/core/update-notifier.d.ts +2 -0
  131. package/dist/core/update-notifier.js +140 -0
  132. package/dist/discord/bot.d.ts +96 -0
  133. package/dist/discord/bot.js +2424 -0
  134. package/dist/runtimes/codex-app-server.d.ts +53 -0
  135. package/dist/runtimes/codex-app-server.js +305 -0
  136. package/dist/runtimes/python-runner.d.ts +7 -0
  137. package/dist/runtimes/python-runner.js +302 -0
  138. package/dist/runtimes/typescript-runner.d.ts +5 -0
  139. package/dist/runtimes/typescript-runner.js +172 -0
  140. package/dist/skills-sdk/types.d.ts +38 -0
  141. package/dist/skills-sdk/types.js +1 -0
  142. package/dist/telegram/bot.d.ts +94 -0
  143. package/dist/telegram/bot.js +1219 -0
  144. package/install.ps1 +132 -0
  145. package/install.sh +130 -0
  146. package/package.json +60 -0
  147. package/templates/skill-python/main.py +74 -0
  148. package/templates/skill-python/skill.yaml +48 -0
  149. package/templates/skill-typescript/main.ts +87 -0
  150. package/templates/skill-typescript/skill.yaml +42 -0
@@ -0,0 +1,1379 @@
1
+ import { mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
+ import { statSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import YAML from "yaml";
8
+ import { ensureDotenvSkeleton } from "./secrets.js";
9
+ import { ensureProfileMemoryFiles } from "./profile-memory.js";
10
+ import { migrateLegacyBuiltinCopies } from "./builtin-skills.js";
11
+ import { l, setLocale } from "./i18n.js";
12
+ let cachedInstallRoot = null;
13
+ export function agentSinInstallRoot() {
14
+ if (cachedInstallRoot)
15
+ return cachedInstallRoot;
16
+ const start = (() => {
17
+ try {
18
+ return fileURLToPath(import.meta.url);
19
+ }
20
+ catch {
21
+ return process.argv[1] || process.cwd();
22
+ }
23
+ })();
24
+ let current = path.dirname(start);
25
+ for (let i = 0; i < 8; i += 1) {
26
+ try {
27
+ const info = statSync(path.join(current, "package.json"));
28
+ if (info.isFile()) {
29
+ cachedInstallRoot = current;
30
+ return current;
31
+ }
32
+ }
33
+ catch {
34
+ // keep walking up
35
+ }
36
+ const parent = path.dirname(current);
37
+ if (parent === current)
38
+ break;
39
+ current = parent;
40
+ }
41
+ cachedInstallRoot = path.dirname(start);
42
+ return cachedInstallRoot;
43
+ }
44
+ // セットアップで提示するプロバイダの正典。models.yaml の ID もここを起点に決まる。
45
+ export const PROVIDER_CATALOG = [
46
+ {
47
+ id: "codex",
48
+ label: "Codex CLI",
49
+ type: "cli",
50
+ binary: "codex",
51
+ defaultModel: "gpt-5.5",
52
+ needsEffort: true,
53
+ defaultChatEffort: "low",
54
+ defaultBuilderEffort: "xhigh",
55
+ },
56
+ {
57
+ id: "claude-code",
58
+ label: "Claude Code CLI",
59
+ type: "cli",
60
+ binary: "claude",
61
+ defaultModel: "opus",
62
+ needsEffort: true,
63
+ defaultChatEffort: "medium",
64
+ defaultBuilderEffort: "xhigh",
65
+ },
66
+ {
67
+ id: "openai",
68
+ label: "OpenAI API",
69
+ type: "api",
70
+ envKeys: ["OPENAI_API_KEY", "OPENAI_API_KEYS"],
71
+ defaultModel: "gpt-5.5",
72
+ },
73
+ {
74
+ id: "gemini",
75
+ label: "Google Gemini API",
76
+ type: "api",
77
+ envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
78
+ defaultModel: "gemini-2.5-flash",
79
+ },
80
+ {
81
+ id: "anthropic",
82
+ label: "Anthropic API",
83
+ type: "api",
84
+ envKeys: ["ANTHROPIC_API_KEY"],
85
+ defaultModel: "claude-opus-4-7",
86
+ },
87
+ {
88
+ id: "ollama",
89
+ label: "Ollama (local)",
90
+ type: "ollama",
91
+ defaultModel: "gemma4:26b",
92
+ },
93
+ ];
94
+ export class SetupRequiredError extends Error {
95
+ configPath;
96
+ constructor(configPath) {
97
+ super(l("Agent-Sin is not set up. Run: agent-sin setup", "Agent-Sin はセットアップされていません。実行: agent-sin setup"));
98
+ this.configPath = configPath;
99
+ }
100
+ }
101
+ export function defaultWorkspace() {
102
+ return process.env.AGENT_SIN_HOME
103
+ ? expandHome(process.env.AGENT_SIN_HOME)
104
+ : path.join(os.homedir(), ".agent-sin");
105
+ }
106
+ export function configPath(workspace = defaultWorkspace()) {
107
+ return path.join(workspace, "config.toml");
108
+ }
109
+ export function legacyConfigPath(workspace = defaultWorkspace()) {
110
+ return path.join(workspace, "config.yaml");
111
+ }
112
+ export function modelsPath(workspace = defaultWorkspace()) {
113
+ return path.join(workspace, "models.yaml");
114
+ }
115
+ export function schedulesPath(workspace = defaultWorkspace()) {
116
+ return path.join(workspace, "schedules.yaml");
117
+ }
118
+ export function legacySchedulesPath(workspace = defaultWorkspace()) {
119
+ return path.join(workspace, "schedules", "schedules.yaml");
120
+ }
121
+ export function defaultConfig(workspace = defaultWorkspace()) {
122
+ return {
123
+ version: 1,
124
+ workspace,
125
+ notes_dir: path.join(workspace, "notes"),
126
+ skills_dir: path.join(workspace, "skills"),
127
+ memory_dir: path.join(workspace, "memory"),
128
+ index_dir: path.join(workspace, "index"),
129
+ logs_dir: path.join(workspace, "logs"),
130
+ log_retention_days: 14,
131
+ event_log_retention_days: 90,
132
+ defaults: {
133
+ note_format: "daily_markdown",
134
+ locale: detectInstallLocale(),
135
+ },
136
+ chat_model_id: "codex-low",
137
+ builder_model_id: "codex-xhigh",
138
+ };
139
+ }
140
+ function detectInstallLocale() {
141
+ const explicit = (process.env.AGENT_SIN_LOCALE || "").trim().toLowerCase();
142
+ if (explicit === "ja" || explicit === "en")
143
+ return explicit;
144
+ const lang = (process.env.LC_ALL || process.env.LANG || "").trim();
145
+ if (lang) {
146
+ return /^ja(_|$|-)/i.test(lang) ? "ja" : "en";
147
+ }
148
+ try {
149
+ const intlLocale = (Intl.DateTimeFormat().resolvedOptions().locale || "").toLowerCase();
150
+ if (/^ja(-|$)/.test(intlLocale))
151
+ return "ja";
152
+ if (/^en(-|$)/.test(intlLocale))
153
+ return "en";
154
+ }
155
+ catch {
156
+ // ignored
157
+ }
158
+ return undefined;
159
+ }
160
+ export function defaultModels() {
161
+ return {
162
+ roles: {
163
+ chat: "codex-low",
164
+ builder: "codex-xhigh",
165
+ },
166
+ models: {
167
+ "codex-low": {
168
+ type: "cli",
169
+ provider: "codex",
170
+ model: "gpt-5.5",
171
+ effort: "low",
172
+ enabled: true,
173
+ },
174
+ "codex-xhigh": {
175
+ type: "cli",
176
+ provider: "codex",
177
+ model: "gpt-5.5",
178
+ effort: "xhigh",
179
+ enabled: true,
180
+ },
181
+ "ollama-gemma": {
182
+ type: "ollama",
183
+ model: "gemma4:26b",
184
+ enabled: false,
185
+ },
186
+ openai: {
187
+ type: "api",
188
+ provider: "openai",
189
+ model: "gpt-5.5",
190
+ enabled: false,
191
+ },
192
+ gemini: {
193
+ type: "api",
194
+ provider: "gemini",
195
+ model: "gemini-2.5-flash",
196
+ enabled: false,
197
+ },
198
+ anthropic: {
199
+ type: "api",
200
+ provider: "anthropic",
201
+ model: "claude-opus-4-7",
202
+ enabled: false,
203
+ },
204
+ "claude-code": {
205
+ type: "cli",
206
+ provider: "claude-code",
207
+ enabled: false,
208
+ },
209
+ },
210
+ };
211
+ }
212
+ // 初回 setup 時に書き出すモデル定義テンプレート。
213
+ // プロバイダごとの追加例をコメントアウトで併記し、ユーザーが必要なものだけ
214
+ // `enabled: true` に切り替えて使えるようにする。
215
+ // 既存ファイルがある場合は yaml.parseDocument 経由で更新するので、
216
+ // このコメントブロックはユーザーが手動で残したコメントごと保持される。
217
+ export const MODELS_YAML_TEMPLATE = `# agent-sin model registry
218
+ #
219
+ # \`roles\` maps logical roles (chat / builder) to concrete model entries.
220
+ # \`models\` contains only concrete model definitions.
221
+ #
222
+ # Types:
223
+ # api ... HTTP API (write API keys in ~/.agent-sin/.env)
224
+ # ollama ... local Ollama (defaults to localhost:11434 unless OLLAMA_HOST is set)
225
+ # cli ... separately authenticated external CLI (codex / claude-code)
226
+ #
227
+ # Provider examples are commented out.
228
+ # Uncomment only the providers you want to use and set \`enabled: true\`.
229
+
230
+ roles:
231
+ chat: codex-low
232
+ builder: codex-xhigh
233
+
234
+ models:
235
+ codex-low:
236
+ type: cli
237
+ provider: codex
238
+ model: gpt-5.5
239
+ effort: low
240
+ enabled: true
241
+
242
+ codex-xhigh:
243
+ type: cli
244
+ provider: codex
245
+ model: gpt-5.5
246
+ effort: xhigh
247
+ enabled: true
248
+
249
+ # --- Examples. Uncomment only the providers you use. ---
250
+
251
+ # OpenAI API (set OPENAI_API_KEY in .env)
252
+ # openai:
253
+ # type: api
254
+ # provider: openai
255
+ # model: gpt-5.5
256
+ # enabled: true
257
+
258
+ # Google Gemini API (set GEMINI_API_KEY in .env)
259
+ # gemini:
260
+ # type: api
261
+ # provider: gemini
262
+ # model: gemini-2.5-flash
263
+ # enabled: true
264
+
265
+ # Anthropic Claude API (set ANTHROPIC_API_KEY in .env)
266
+ # anthropic:
267
+ # type: api
268
+ # provider: anthropic
269
+ # model: claude-opus-4-7
270
+ # enabled: true
271
+
272
+ # Ollama (local LLM; run \`ollama pull <model>\` first)
273
+ # ollama-gemma:
274
+ # type: ollama
275
+ # model: gemma4:26b
276
+ # enabled: true
277
+
278
+ # Claude Code CLI (already logged in with \`claude\`)
279
+ # claude-code:
280
+ # type: cli
281
+ # provider: claude-code
282
+ # model: opus
283
+ # effort: xhigh
284
+ # enabled: true
285
+
286
+ `;
287
+ const MODELS_YAML_TEMPLATE_JA = `# agent-sin model registry
288
+ #
289
+ # \`roles\` は論理的な役割(chat / builder)から実体モデルへの参照です。
290
+ # \`models\` には実体モデルの定義だけを並べます。
291
+ #
292
+ # 種別 (type):
293
+ # api ... HTTP API (~/.agent-sin/.env に API キーを書く)
294
+ # ollama ... ローカル Ollama (OLLAMA_HOST 未設定なら localhost:11434)
295
+ # cli ... 別途ログイン済みの外部 CLI (codex / claude-code)
296
+ #
297
+ # 例として用意したプロバイダはコメントアウトしてあります。
298
+ # 使いたいものだけ \`#\` を外して \`enabled: true\` にしてください。
299
+
300
+ roles:
301
+ chat: codex-low
302
+ builder: codex-xhigh
303
+
304
+ models:
305
+ codex-low:
306
+ type: cli
307
+ provider: codex
308
+ model: gpt-5.5
309
+ effort: low
310
+ enabled: true
311
+
312
+ codex-xhigh:
313
+ type: cli
314
+ provider: codex
315
+ model: gpt-5.5
316
+ effort: xhigh
317
+ enabled: true
318
+
319
+ # --- 以下は例。使うものだけコメントアウトを解除 ---
320
+
321
+ # OpenAI API (.env に OPENAI_API_KEY を設定)
322
+ # openai:
323
+ # type: api
324
+ # provider: openai
325
+ # model: gpt-5.5
326
+ # enabled: true
327
+
328
+ # Google Gemini API (.env に GEMINI_API_KEY を設定)
329
+ # gemini:
330
+ # type: api
331
+ # provider: gemini
332
+ # model: gemini-2.5-flash
333
+ # enabled: true
334
+
335
+ # Anthropic Claude API (.env に ANTHROPIC_API_KEY を設定)
336
+ # anthropic:
337
+ # type: api
338
+ # provider: anthropic
339
+ # model: claude-opus-4-7
340
+ # enabled: true
341
+
342
+ # Ollama (ローカル LLM。\`ollama pull <model>\` 済みであること)
343
+ # ollama-gemma:
344
+ # type: ollama
345
+ # model: gemma4:26b
346
+ # enabled: true
347
+
348
+ # Claude Code CLI (\`claude\` ログイン済み)
349
+ # claude-code:
350
+ # type: cli
351
+ # provider: claude-code
352
+ # model: opus
353
+ # effort: xhigh
354
+ # enabled: true
355
+
356
+ `;
357
+ function localizedModelsYamlTemplate() {
358
+ return l(MODELS_YAML_TEMPLATE, MODELS_YAML_TEMPLATE_JA);
359
+ }
360
+ // セットアップ時に「実際にこの環境で使えそうなプロバイダ」を検出する。
361
+ // 検出は早く・副作用なしで終わるものだけにする (バイナリの存在 / 環境変数 / 短い HTTP 確認)。
362
+ // API キー系プロバイダはワークスペースの .env に書かれているキーだけを対象にする。
363
+ // シェルから export されただけのキーは「このワークスペース用」とは限らないので拾わない。
364
+ export async function detectAvailableProviders(workspaceForEnv) {
365
+ let dotenvKeys = new Set();
366
+ if (workspaceForEnv) {
367
+ try {
368
+ const { readDotenvKeys } = await import("./secrets.js");
369
+ dotenvKeys = await readDotenvKeys(workspaceForEnv);
370
+ }
371
+ catch {
372
+ // .env が無くても続行する
373
+ }
374
+ }
375
+ const results = [];
376
+ await Promise.all(PROVIDER_CATALOG.map(async (entry) => {
377
+ const detection = await detectProvider(entry, dotenvKeys);
378
+ if (detection)
379
+ results.push({ id: entry.id, label: entry.label, hint: detection });
380
+ }));
381
+ // PROVIDER_CATALOG の並びを保持
382
+ const order = new Map(PROVIDER_CATALOG.map((p, i) => [p.id, i]));
383
+ results.sort((a, b) => (order.get(a.id) ?? 99) - (order.get(b.id) ?? 99));
384
+ return results;
385
+ }
386
+ async function detectProvider(entry, dotenvKeys) {
387
+ if (entry.binary) {
388
+ const found = await findExecutable(entry.binary);
389
+ if (found)
390
+ return l(`${entry.binary} CLI found`, `${entry.binary} CLI 検出`);
391
+ return null;
392
+ }
393
+ if (entry.envKeys) {
394
+ for (const key of entry.envKeys) {
395
+ if (dotenvKeys.has(key))
396
+ return l(`${key} found in .env`, `.env に ${key} を検出`);
397
+ }
398
+ return null;
399
+ }
400
+ if (entry.id === "ollama") {
401
+ return (await ollamaReachable()) ? l("server responded", "サーバー応答あり") : null;
402
+ }
403
+ return null;
404
+ }
405
+ async function findExecutable(name) {
406
+ const pathEnv = process.env.PATH || "";
407
+ const sep = path.delimiter;
408
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
409
+ for (const dir of pathEnv.split(sep)) {
410
+ if (!dir)
411
+ continue;
412
+ for (const ext of exts) {
413
+ try {
414
+ const candidate = path.join(dir, name + ext);
415
+ const info = await stat(candidate);
416
+ if (info.isFile())
417
+ return true;
418
+ }
419
+ catch {
420
+ // not found, keep searching
421
+ }
422
+ }
423
+ }
424
+ return false;
425
+ }
426
+ async function ollamaReachable() {
427
+ const host = process.env.OLLAMA_HOST || "http://localhost:11434";
428
+ const controller = new AbortController();
429
+ const timer = setTimeout(() => controller.abort(), 400);
430
+ try {
431
+ const response = await fetch(`${host}/api/version`, { signal: controller.signal });
432
+ return response.ok;
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ finally {
438
+ clearTimeout(timer);
439
+ }
440
+ }
441
+ // 初回 setup で対話的に決めた chat / builder の構成から、コメント付き models.yaml を組み立てる。
442
+ // 使ったプロバイダは active な実体定義として、それ以外は <PROVIDER_CATALOG> 由来のコメント例として残す。
443
+ export function renderModelsYamlFromChoices(chat, builder) {
444
+ const { chat: chatEntry, builder: builderEntry } = buildSetupDescriptorPair(chat, builder);
445
+ // 両方が同じ ID に解決される場合 (同じプロバイダ・モデル・effort) は 1 つだけ書く
446
+ const usedIds = new Map();
447
+ usedIds.set(chatEntry.id, chatEntry);
448
+ if (!usedIds.has(builderEntry.id)) {
449
+ usedIds.set(builderEntry.id, builderEntry);
450
+ }
451
+ // 例ブロックでは「現在 active なプロバイダ」と被らないように除外
452
+ const usedProviders = new Set();
453
+ if (chat.provider)
454
+ usedProviders.add(chat.provider);
455
+ if (builder.provider)
456
+ usedProviders.add(builder.provider);
457
+ const lines = [];
458
+ lines.push("# agent-sin model registry");
459
+ lines.push("#");
460
+ lines.push(l("# `roles` maps logical roles (chat / builder) to concrete model entries.", "# `roles` は論理的な役割(chat / builder)から実体モデルへの参照です。"));
461
+ lines.push(l("# `models` contains only concrete model definitions.", "# `models` には実体モデルの定義だけを並べます。"));
462
+ lines.push("#");
463
+ lines.push(l("# Types:", "# 種別 (type):"));
464
+ lines.push(l("# api ... HTTP API (write API keys in ~/.agent-sin/.env)", "# api ... HTTP API (~/.agent-sin/.env に API キーを書く)"));
465
+ lines.push(l("# ollama ... local Ollama (defaults to localhost:11434 unless OLLAMA_HOST is set)", "# ollama ... ローカル Ollama (OLLAMA_HOST 未設定なら localhost:11434)"));
466
+ lines.push(l("# cli ... separately authenticated external CLI (codex / claude-code)", "# cli ... 別途ログイン済みの外部 CLI (codex / claude-code)"));
467
+ lines.push("#");
468
+ lines.push(l("# Model IDs can be named freely. They only need to match the IDs pointed to by `roles.chat` / `roles.builder`.", "# モデル ID は自由に名付けられます。`roles.chat` / `roles.builder` が指す ID と"));
469
+ lines.push(l("#", "# `models:` 配下の ID が一致していれば OK です。"));
470
+ lines.push("");
471
+ lines.push("roles:");
472
+ lines.push(` chat: ${chatEntry.id}`);
473
+ lines.push(` builder: ${builderEntry.id}`);
474
+ lines.push("");
475
+ lines.push("models:");
476
+ for (const descriptor of usedIds.values()) {
477
+ lines.push(...renderEntry(descriptor, false));
478
+ lines.push("");
479
+ }
480
+ const exampleProviders = PROVIDER_CATALOG.filter((p) => !usedProviders.has(p.id));
481
+ if (exampleProviders.length > 0) {
482
+ lines.push(l(" # --- Examples. Uncomment only the providers you use. ---", " # --- 以下は例。使うものだけコメントアウトを解除 ---"));
483
+ lines.push("");
484
+ for (const provider of exampleProviders) {
485
+ const example = buildEntryDescriptor({ provider: provider.id, model: provider.defaultModel, effort: provider.defaultBuilderEffort }, "chat");
486
+ example.id = provider.id;
487
+ lines.push(` # ${provider.label}${exampleEnvHint(provider)}`);
488
+ lines.push(...renderEntry(example, true));
489
+ lines.push("");
490
+ }
491
+ }
492
+ return lines.join("\n").replace(/\n+$/, "\n");
493
+ }
494
+ function exampleEnvHint(entry) {
495
+ if (entry.envKeys && entry.envKeys[0])
496
+ return l(` (set ${entry.envKeys[0]} in .env)`, ` (.env に ${entry.envKeys[0]} を設定)`);
497
+ if (entry.binary)
498
+ return l(` (already logged in with \`${entry.binary}\`)`, ` (\`${entry.binary}\` ログイン済み)`);
499
+ if (entry.id === "ollama")
500
+ return l(" (run `ollama pull <model>` first)", " (`ollama pull <model>` 済みであること)");
501
+ return "";
502
+ }
503
+ function buildEntryDescriptor(choice, role) {
504
+ const catalog = PROVIDER_CATALOG.find((p) => p.id === choice.provider);
505
+ const type = catalog?.type ?? "api";
506
+ const effort = choice.effort ??
507
+ (role === "chat" ? catalog?.defaultChatEffort : catalog?.defaultBuilderEffort);
508
+ const model = choice.model ?? catalog?.defaultModel;
509
+ const id = deriveSetupId(choice.provider, effort, type);
510
+ return {
511
+ id,
512
+ type,
513
+ provider: choice.provider,
514
+ model,
515
+ effort: catalog?.needsEffort ? effort : undefined,
516
+ };
517
+ }
518
+ // 外部から「この選択は何 ID になる?」を問い合わせるためのヘルパー。
519
+ // (config.defaults を選択後の ID に揃える用)
520
+ export function deriveSetupChoiceId(choice, role) {
521
+ return buildEntryDescriptor(choice, role).id;
522
+ }
523
+ // chat と builder のペアを見て、同じ ID に解決されるが内容が異なる場合は
524
+ // `-chat` / `-builder` を付けて衝突を解消する。
525
+ // (例: 同じプロバイダ openai を chat=gpt-5.4-mini, builder=gpt-5.5 にしたいケース)
526
+ export function deriveSetupChoicePairIds(chat, builder) {
527
+ const pair = buildSetupDescriptorPair(chat, builder);
528
+ return { chat: pair.chat.id, builder: pair.builder.id };
529
+ }
530
+ function buildSetupDescriptorPair(chat, builder) {
531
+ const chatEntry = buildEntryDescriptor(chat, "chat");
532
+ const builderEntry = buildEntryDescriptor(builder, "builder");
533
+ if (chatEntry.id === builderEntry.id && entriesDiffer(chatEntry, builderEntry)) {
534
+ chatEntry.id = `${chatEntry.id}-chat`;
535
+ builderEntry.id = `${builderEntry.id}-builder`;
536
+ }
537
+ return { chat: chatEntry, builder: builderEntry };
538
+ }
539
+ function entriesDiffer(a, b) {
540
+ return ((a.model ?? "") !== (b.model ?? "") ||
541
+ (a.effort ?? "") !== (b.effort ?? "") ||
542
+ a.type !== b.type ||
543
+ (a.provider ?? "") !== (b.provider ?? ""));
544
+ }
545
+ function deriveSetupId(provider, effort, type) {
546
+ if (type === "cli" && effort) {
547
+ return `${provider}-${effort}`;
548
+ }
549
+ if (type === "ollama") {
550
+ return provider === "ollama" ? "ollama" : provider;
551
+ }
552
+ return provider;
553
+ }
554
+ function renderEntry(descriptor, commented) {
555
+ const prefix = commented ? " # " : " ";
556
+ const childPrefix = commented ? " # " : " ";
557
+ const fields = [];
558
+ fields.push(["type", descriptor.type]);
559
+ if (descriptor.provider && descriptor.provider !== descriptor.id) {
560
+ fields.push(["provider", descriptor.provider]);
561
+ }
562
+ else if (descriptor.provider && descriptor.type !== "ollama") {
563
+ fields.push(["provider", descriptor.provider]);
564
+ }
565
+ if (descriptor.model)
566
+ fields.push(["model", descriptor.model]);
567
+ if (descriptor.effort)
568
+ fields.push(["effort", descriptor.effort]);
569
+ fields.push(["enabled", "true"]);
570
+ const lines = [];
571
+ lines.push(`${prefix}${descriptor.id}:`);
572
+ for (const [key, value] of fields) {
573
+ lines.push(`${childPrefix}${key}: ${value}`);
574
+ }
575
+ return lines;
576
+ }
577
+ function applySetupChoicesToModels(models, choices) {
578
+ const { chat: chatEntry, builder: builderEntry } = buildSetupDescriptorPair(choices.chat, choices.builder);
579
+ models.roles = {
580
+ ...(models.roles || {}),
581
+ chat: chatEntry.id,
582
+ builder: builderEntry.id,
583
+ };
584
+ for (const descriptor of [chatEntry, builderEntry]) {
585
+ models.models[descriptor.id] = modelEntryFromDescriptor(descriptor);
586
+ }
587
+ }
588
+ function modelEntryFromDescriptor(descriptor) {
589
+ const entry = {
590
+ type: descriptor.type,
591
+ enabled: true,
592
+ };
593
+ if (descriptor.provider && descriptor.type !== "ollama") {
594
+ entry.provider = descriptor.provider;
595
+ }
596
+ if (descriptor.model) {
597
+ entry.model = descriptor.model;
598
+ }
599
+ if (descriptor.effort) {
600
+ entry.effort = descriptor.effort;
601
+ }
602
+ return entry;
603
+ }
604
+ export async function setupWorkspace(options = {}) {
605
+ const workspace = options.workspace ? expandHome(options.workspace) : defaultWorkspace();
606
+ const initial = defaultConfig(workspace);
607
+ await mkdir(initial.workspace, { recursive: true });
608
+ let config = initial;
609
+ let legacyRoleIds;
610
+ try {
611
+ const raw = await readFile(configPath(initial.workspace), "utf8");
612
+ const parsed = parseTomlConfigWithLegacy(raw);
613
+ config = normalizeConfig(parsed.config);
614
+ legacyRoleIds = parsed.legacy_role_ids;
615
+ }
616
+ catch (error) {
617
+ if (!isMissingFile(error)) {
618
+ throw error;
619
+ }
620
+ config = await loadLegacyConfigOrDefault(initial.workspace, initial);
621
+ }
622
+ config = applySetupOptions(config, options);
623
+ const modelsFile = modelsPath(config.workspace);
624
+ const hadExistingModels = await pathExists(modelsFile);
625
+ if (!hadExistingModels) {
626
+ await mkdir(path.dirname(modelsFile), { recursive: true });
627
+ if (options.initialModels) {
628
+ const yaml = renderModelsYamlFromChoices(options.initialModels.chat, options.initialModels.builder);
629
+ await writeFile(modelsFile, yaml, "utf8");
630
+ const pairIds = deriveSetupChoicePairIds(options.initialModels.chat, options.initialModels.builder);
631
+ config.chat_model_id = pairIds.chat;
632
+ config.builder_model_id = pairIds.builder;
633
+ }
634
+ else {
635
+ await writeFile(modelsFile, localizedModelsYamlTemplate(), "utf8");
636
+ }
637
+ }
638
+ else {
639
+ // 旧形式 (type: login / roles なし) を新形式へ移行。
640
+ // 旧 config.toml の `[defaults] chat_model` / `builder` があればそれを優先する。
641
+ const preferredChat = legacyRoleIds?.chat || config.chat_model_id;
642
+ const preferredBuilder = legacyRoleIds?.builder || config.builder_model_id;
643
+ const migration = await migrateModelsYamlIfLegacy(config.workspace, {
644
+ preferredRoles: {
645
+ chat: preferredChat,
646
+ builder: preferredBuilder,
647
+ },
648
+ });
649
+ if (migration.changed) {
650
+ if (migration.renamed.chat && config.chat_model_id === "chat") {
651
+ config.chat_model_id = migration.renamed.chat;
652
+ }
653
+ if (migration.renamed.builder && config.builder_model_id === "builder") {
654
+ config.builder_model_id = migration.renamed.builder;
655
+ }
656
+ }
657
+ if (options.initialModels) {
658
+ const models = await loadModelsOrDefault(config.workspace);
659
+ applySetupChoicesToModels(models, options.initialModels);
660
+ await writeModelsYaml(modelsFile, models);
661
+ const pairIds = deriveSetupChoicePairIds(options.initialModels.chat, options.initialModels.builder);
662
+ config.chat_model_id = pairIds.chat;
663
+ config.builder_model_id = pairIds.builder;
664
+ }
665
+ }
666
+ let models = await loadModelsOrDefault(config.workspace);
667
+ // 旧 config.toml の [defaults] chat_model / builder が残っていて、
668
+ // かつ models.yaml.roles が未設定なら一度だけ転記する。
669
+ let rolesChanged = false;
670
+ if (legacyRoleIds) {
671
+ const nextRoles = { ...(models.roles || {}) };
672
+ if (legacyRoleIds.chat && !nextRoles.chat && models.models[legacyRoleIds.chat]) {
673
+ nextRoles.chat = legacyRoleIds.chat;
674
+ rolesChanged = true;
675
+ }
676
+ if (legacyRoleIds.builder && !nextRoles.builder && models.models[legacyRoleIds.builder]) {
677
+ nextRoles.builder = legacyRoleIds.builder;
678
+ rolesChanged = true;
679
+ }
680
+ if (rolesChanged) {
681
+ models = { ...models, roles: nextRoles };
682
+ }
683
+ }
684
+ // models.yaml.roles から実体 ID を導出する。
685
+ config.chat_model_id = resolveRoleIdFromModels(models, "chat", config.chat_model_id);
686
+ config.builder_model_id = resolveRoleIdFromModels(models, "builder", config.builder_model_id);
687
+ const enabledModelIds = new Set(options.enableModels || []);
688
+ enabledModelIds.add(config.chat_model_id);
689
+ enabledModelIds.add(config.builder_model_id);
690
+ validateKnownModel(config.chat_model_id, models);
691
+ validateKnownModel(config.builder_model_id, models);
692
+ let modelsChanged = rolesChanged;
693
+ for (const modelId of enabledModelIds) {
694
+ validateKnownModel(modelId, models);
695
+ if (models.models[modelId].enabled !== true) {
696
+ models.models[modelId].enabled = true;
697
+ modelsChanged = true;
698
+ }
699
+ }
700
+ await ensureWorkspaceDirs(config);
701
+ await writeConfig(configPath(config.workspace), config);
702
+ if (modelsChanged) {
703
+ await writeModelsYaml(modelsFile, models);
704
+ }
705
+ await migrateLegacySchedules(config.workspace);
706
+ await ensureSchedulesSkeleton(schedulesPath(config.workspace));
707
+ await ensureDailyMemoIndexSchedule(config);
708
+ await migrateLegacyBuiltinCopies(config.skills_dir);
709
+ await ensureProfileMemoryFiles(config);
710
+ await ensureDotenvSkeleton(config.workspace);
711
+ return config;
712
+ }
713
+ // models.yaml の roles から実体モデル ID を取り出す。未設定 / 不正の場合は fallback を返す。
714
+ function resolveRoleIdFromModels(models, role, fallback) {
715
+ const fromRoles = role === "chat" ? models.roles?.chat : models.roles?.builder;
716
+ if (fromRoles && models.models[fromRoles]) {
717
+ return fromRoles;
718
+ }
719
+ if (fallback && models.models[fallback]) {
720
+ return fallback;
721
+ }
722
+ return fromRoles || fallback;
723
+ }
724
+ async function migrateLegacySchedules(workspace) {
725
+ const legacy = legacySchedulesPath(workspace);
726
+ const target = schedulesPath(workspace);
727
+ try {
728
+ await stat(target);
729
+ return;
730
+ }
731
+ catch {
732
+ // Target missing — see if a legacy copy exists.
733
+ }
734
+ let legacyContent;
735
+ try {
736
+ legacyContent = await readFile(legacy, "utf8");
737
+ }
738
+ catch {
739
+ return;
740
+ }
741
+ await writeFile(target, legacyContent, "utf8");
742
+ await rm(legacy, { force: true }).catch(() => undefined);
743
+ await rmdir(path.dirname(legacy)).catch(() => undefined);
744
+ }
745
+ async function ensureSchedulesSkeleton(file) {
746
+ try {
747
+ await stat(file);
748
+ return;
749
+ }
750
+ catch {
751
+ // Missing — create empty skeleton.
752
+ }
753
+ const skeleton = l(`# Agent-Sin schedules
754
+ # Run "agent-sin daemon" to start the scheduler. Cron format: "min hour dom month dow"
755
+ # Edit this file and restart the daemon for changes to take effect.
756
+ schedules: []
757
+ `, `# Agent-Sin schedules
758
+ # scheduler を起動するには "agent-sin daemon" を実行します。Cron形式: "min hour dom month dow"
759
+ # 変更を反映するにはこのファイルを編集して daemon を再起動してください。
760
+ schedules: []
761
+ `);
762
+ await mkdir(path.dirname(file), { recursive: true });
763
+ await writeFile(file, skeleton, "utf8");
764
+ }
765
+ const DAILY_MEMO_INDEX_ID = "daily-memo-index";
766
+ function dailyMemoIndexMarkerPath(workspace) {
767
+ return path.join(workspace, ".daily-memo-index-installed");
768
+ }
769
+ export async function ensureDailyMemoIndexSchedule(config) {
770
+ const marker = dailyMemoIndexMarkerPath(config.workspace);
771
+ try {
772
+ await stat(marker);
773
+ return;
774
+ }
775
+ catch {
776
+ // No marker — proceed with detection.
777
+ }
778
+ const file = schedulesPath(config.workspace);
779
+ let raw;
780
+ try {
781
+ raw = await readFile(file, "utf8");
782
+ }
783
+ catch {
784
+ return;
785
+ }
786
+ let parsed;
787
+ try {
788
+ parsed = YAML.parse(raw) ?? {};
789
+ }
790
+ catch {
791
+ return;
792
+ }
793
+ const schedules = Array.isArray(parsed.schedules) ? [...parsed.schedules] : [];
794
+ const alreadyRegistered = schedules.some((entry) => entry && typeof entry === "object" && entry.id === DAILY_MEMO_INDEX_ID);
795
+ if (alreadyRegistered) {
796
+ await writeFile(marker, "", "utf8");
797
+ return;
798
+ }
799
+ const detected = await detectMemoIndexDependencies(config.workspace);
800
+ if (!detected)
801
+ return;
802
+ schedules.push({
803
+ id: DAILY_MEMO_INDEX_ID,
804
+ description: "意味検索用のメモ索引を毎日更新する",
805
+ cron: "0 3 * * *",
806
+ skill: "memo-index",
807
+ enabled: true,
808
+ });
809
+ await writeFile(file, YAML.stringify({ ...parsed, schedules }), "utf8");
810
+ await writeFile(marker, "", "utf8");
811
+ }
812
+ async function detectMemoIndexDependencies(workspace) {
813
+ const venvPython = path.join(workspace, ".venv", "bin", "python");
814
+ const candidates = [];
815
+ try {
816
+ await stat(venvPython);
817
+ candidates.push(venvPython);
818
+ }
819
+ catch {
820
+ // venv not present — fall through to system python.
821
+ }
822
+ candidates.push("python3");
823
+ for (const python of candidates) {
824
+ if (await tryImportMemoIndexDeps(python))
825
+ return true;
826
+ }
827
+ return false;
828
+ }
829
+ function tryImportMemoIndexDeps(python) {
830
+ return new Promise((resolve) => {
831
+ let settled = false;
832
+ const finish = (result) => {
833
+ if (settled)
834
+ return;
835
+ settled = true;
836
+ resolve(result);
837
+ };
838
+ let proc;
839
+ try {
840
+ proc = spawn(python, ["-c", "import chromadb, sentence_transformers"], { stdio: "ignore" });
841
+ }
842
+ catch {
843
+ finish(false);
844
+ return;
845
+ }
846
+ const timer = setTimeout(() => {
847
+ proc.kill();
848
+ finish(false);
849
+ }, 4000);
850
+ proc.on("error", () => {
851
+ clearTimeout(timer);
852
+ finish(false);
853
+ });
854
+ proc.on("exit", (code) => {
855
+ clearTimeout(timer);
856
+ finish(code === 0);
857
+ });
858
+ });
859
+ }
860
+ export async function ensureWorkspaceDirs(config) {
861
+ await mkdir(config.workspace, { recursive: true });
862
+ await mkdir(config.notes_dir, { recursive: true });
863
+ await mkdir(config.skills_dir, { recursive: true });
864
+ await mkdir(config.memory_dir, { recursive: true });
865
+ await mkdir(path.join(config.memory_dir, "skill-memory"), { recursive: true });
866
+ await mkdir(path.join(config.memory_dir, "profile"), { recursive: true });
867
+ await mkdir(path.join(config.memory_dir, "daily"), { recursive: true });
868
+ await mkdir(path.join(config.index_dir, "local-index"), { recursive: true });
869
+ await mkdir(config.logs_dir, { recursive: true });
870
+ await mkdir(path.join(config.logs_dir, "runs"), { recursive: true });
871
+ }
872
+ export async function loadConfig() {
873
+ const file = configPath();
874
+ let config;
875
+ try {
876
+ const raw = await readFile(file, "utf8");
877
+ config = normalizeConfig(parseTomlConfig(raw));
878
+ }
879
+ catch (error) {
880
+ if (!isMissingFile(error)) {
881
+ throw error;
882
+ }
883
+ try {
884
+ const legacyRaw = await readFile(legacyConfigPath(), "utf8");
885
+ config = normalizeConfig(YAML.parse(legacyRaw));
886
+ }
887
+ catch (legacyError) {
888
+ if (!isMissingFile(legacyError)) {
889
+ throw legacyError;
890
+ }
891
+ throw new SetupRequiredError(file);
892
+ }
893
+ }
894
+ applyConfigLocale(config);
895
+ return applyModelRoles(config);
896
+ }
897
+ function applyConfigLocale(config) {
898
+ const explicit = (process.env.AGENT_SIN_LOCALE || "").trim().toLowerCase();
899
+ if (explicit === "ja" || explicit === "en") {
900
+ setLocale(explicit);
901
+ return;
902
+ }
903
+ const locale = config.defaults?.locale;
904
+ if (locale === "ja" || locale === "en") {
905
+ setLocale(locale);
906
+ return;
907
+ }
908
+ setLocale(null);
909
+ }
910
+ // models.yaml の roles を AppConfig.chat_model_id / builder_model_id に反映する。
911
+ // config.toml には保存しないため、loadConfig のたびにここで導出する。
912
+ async function applyModelRoles(config) {
913
+ try {
914
+ const models = await loadModels(config.workspace);
915
+ config.chat_model_id = resolveRoleIdFromModels(models, "chat", config.chat_model_id);
916
+ config.builder_model_id = resolveRoleIdFromModels(models, "builder", config.builder_model_id);
917
+ }
918
+ catch (error) {
919
+ if (!isMissingFile(error)) {
920
+ throw error;
921
+ }
922
+ }
923
+ return config;
924
+ }
925
+ export async function loadModels(workspace = defaultWorkspace()) {
926
+ const raw = await readFile(modelsPath(workspace), "utf8");
927
+ const parsed = YAML.parse(raw);
928
+ return normalizeModelConfig(parsed);
929
+ }
930
+ // 旧 type: login を type: cli にメモリ上だけ寄せる正規化。
931
+ // ファイル自体の書き換えは migrateModelsYamlIfLegacy が担当する。
932
+ export function normalizeModelConfig(input) {
933
+ const result = {
934
+ roles: input.roles ? { ...input.roles } : undefined,
935
+ models: {},
936
+ };
937
+ for (const [id, entry] of Object.entries(input.models || {})) {
938
+ const normalizedType = entry.type === "login" ? "cli" : entry.type;
939
+ result.models[id] = { ...entry, type: normalizedType };
940
+ }
941
+ return result;
942
+ }
943
+ // 論理ロールから実体モデル ID を解決する。models.yaml の roles を真実の源とする。
944
+ export function resolveRoleId(config, models, role) {
945
+ const fromRoles = role === "chat" ? models?.roles?.chat : models?.roles?.builder;
946
+ if (fromRoles)
947
+ return fromRoles;
948
+ return role === "chat" ? config.chat_model_id : config.builder_model_id;
949
+ }
950
+ export async function setRoleModel(role, modelId) {
951
+ const config = await loadConfig();
952
+ const models = await loadModels(config.workspace);
953
+ if (!models.models[modelId]) {
954
+ throw new Error(l(`Unknown model: ${modelId}`, `不明なモデルです: ${modelId}`));
955
+ }
956
+ if (role === "chat") {
957
+ config.chat_model_id = modelId;
958
+ }
959
+ else {
960
+ config.builder_model_id = modelId;
961
+ }
962
+ models.models[modelId].enabled = true;
963
+ models.roles = { ...(models.roles || {}), [role]: modelId };
964
+ await writeModelsYaml(modelsPath(config.workspace), models);
965
+ return config;
966
+ }
967
+ export async function setDefaultModel(modelId) {
968
+ return setRoleModel("chat", modelId);
969
+ }
970
+ export async function writeYaml(file, value) {
971
+ await mkdir(path.dirname(file), { recursive: true });
972
+ await writeFile(file, YAML.stringify(value), "utf8");
973
+ }
974
+ async function pathExists(file) {
975
+ try {
976
+ await stat(file);
977
+ return true;
978
+ }
979
+ catch (error) {
980
+ if (isMissingFile(error)) {
981
+ return false;
982
+ }
983
+ throw error;
984
+ }
985
+ }
986
+ // 既存の models.yaml にあるコメント・並び順・ユーザー独自のエントリを保ったまま
987
+ // 値だけ更新するための書き込みヘルパー。
988
+ // 内部表現と一致しないキーはユーザー編集として保持し、テンプレに残しておいた
989
+ // コメントアウト済みの例も残す。
990
+ export async function writeModelsYaml(file, models) {
991
+ await mkdir(path.dirname(file), { recursive: true });
992
+ let doc = null;
993
+ try {
994
+ const raw = await readFile(file, "utf8");
995
+ doc = YAML.parseDocument(raw);
996
+ if (doc.errors.length > 0) {
997
+ doc = null;
998
+ }
999
+ }
1000
+ catch (error) {
1001
+ if (!isMissingFile(error)) {
1002
+ throw error;
1003
+ }
1004
+ }
1005
+ if (!doc || doc.contents == null) {
1006
+ // ファイル無し / パース失敗時はテンプレを起点に書き戻す。
1007
+ doc = YAML.parseDocument(localizedModelsYamlTemplate());
1008
+ }
1009
+ if (models.roles) {
1010
+ for (const role of ["chat", "builder"]) {
1011
+ const id = models.roles[role];
1012
+ if (id)
1013
+ doc.setIn(["roles", role], id);
1014
+ }
1015
+ }
1016
+ for (const [id, entry] of Object.entries(models.models)) {
1017
+ const keyPath = ["models", id];
1018
+ if (!doc.hasIn(keyPath)) {
1019
+ doc.setIn(keyPath, entry);
1020
+ continue;
1021
+ }
1022
+ for (const [field, value] of Object.entries(entry)) {
1023
+ doc.setIn([...keyPath, field], value);
1024
+ }
1025
+ }
1026
+ await writeFile(file, doc.toString(), "utf8");
1027
+ }
1028
+ // 旧形式 (type: login / IDが chat・builder / roles なし) を検出して、
1029
+ // コメントを保ったまま新形式へ書き換える。
1030
+ // 既に新形式なら何もせず { changed: false } を返す。
1031
+ export async function migrateModelsYamlIfLegacy(workspace = defaultWorkspace(), options = {}) {
1032
+ const file = modelsPath(workspace);
1033
+ let raw;
1034
+ try {
1035
+ raw = await readFile(file, "utf8");
1036
+ }
1037
+ catch (error) {
1038
+ if (isMissingFile(error)) {
1039
+ return { changed: false, renamed: {}, notes: [] };
1040
+ }
1041
+ throw error;
1042
+ }
1043
+ const doc = YAML.parseDocument(raw);
1044
+ if (doc.errors.length > 0 || doc.contents == null) {
1045
+ return { changed: false, renamed: {}, notes: [] };
1046
+ }
1047
+ const renamed = {};
1048
+ const notes = [];
1049
+ let changed = false;
1050
+ const modelsNode = doc.get("models");
1051
+ if (!modelsNode || !YAML.isMap(modelsNode)) {
1052
+ return { changed: false, renamed: {}, notes: [] };
1053
+ }
1054
+ // type: login → type: cli を一括正規化
1055
+ for (const item of modelsNode.items) {
1056
+ const value = item.value;
1057
+ if (YAML.isMap(value)) {
1058
+ const typeVal = value.get("type");
1059
+ if (typeVal === "login") {
1060
+ value.set("type", "cli");
1061
+ changed = true;
1062
+ }
1063
+ }
1064
+ }
1065
+ if (changed)
1066
+ notes.push("type: login → type: cli に統一");
1067
+ // chat / builder をエンティティ名にリネーム(コンフリクト時はスキップ)
1068
+ const existingIds = new Set(modelsNode.items
1069
+ .map((it) => (YAML.isScalar(it.key) ? String(it.key.value) : null))
1070
+ .filter((id) => !!id));
1071
+ for (const oldId of ["chat", "builder"]) {
1072
+ if (!existingIds.has(oldId))
1073
+ continue;
1074
+ const entry = modelsNode.get(oldId);
1075
+ if (!entry || !YAML.isMap(entry))
1076
+ continue;
1077
+ const newId = deriveEntityId(entry, oldId, existingIds);
1078
+ if (!newId || newId === oldId)
1079
+ continue;
1080
+ // 既存キーを差し替え (順序保持)
1081
+ for (const item of modelsNode.items) {
1082
+ if (YAML.isScalar(item.key) && item.key.value === oldId) {
1083
+ item.key = doc.createNode(newId);
1084
+ break;
1085
+ }
1086
+ }
1087
+ existingIds.delete(oldId);
1088
+ existingIds.add(newId);
1089
+ renamed[oldId] = newId;
1090
+ changed = true;
1091
+ notes.push(`${oldId} → ${newId} にリネーム`);
1092
+ }
1093
+ // roles 未設定なら追加。優先順:
1094
+ // 1. options.preferredRoles で渡された値(config.toml の現在値など)。
1095
+ // 旧 ID ("chat"/"builder") はリネーム後 ID に解決する。
1096
+ // 2. リネーム済みなら新 ID。
1097
+ // 3. 既存に旧 ID があればそのまま参照。
1098
+ if (!doc.has("roles")) {
1099
+ const resolveTarget = (preferred, role) => {
1100
+ if (preferred) {
1101
+ // 旧 ID を渡された場合はリネーム後 ID に置き換える
1102
+ if (renamed[preferred])
1103
+ return renamed[preferred];
1104
+ if (existingIds.has(preferred))
1105
+ return preferred;
1106
+ }
1107
+ if (renamed[role])
1108
+ return renamed[role];
1109
+ if (existingIds.has(role))
1110
+ return role;
1111
+ return undefined;
1112
+ };
1113
+ const chatTarget = resolveTarget(options.preferredRoles?.chat, "chat");
1114
+ const builderTarget = resolveTarget(options.preferredRoles?.builder, "builder");
1115
+ if (chatTarget || builderTarget) {
1116
+ const rolesMap = doc.createNode({});
1117
+ if (chatTarget)
1118
+ rolesMap.set("chat", chatTarget);
1119
+ if (builderTarget)
1120
+ rolesMap.set("builder", builderTarget);
1121
+ // roles: ブロックを先頭近く(models: の前)に挿入。
1122
+ const rootMap = doc.contents;
1123
+ const newPair = doc.createPair("roles", rolesMap);
1124
+ const modelsIdx = rootMap.items.findIndex((it) => YAML.isScalar(it.key) && it.key.value === "models");
1125
+ if (modelsIdx >= 0) {
1126
+ rootMap.items.splice(modelsIdx, 0, newPair);
1127
+ }
1128
+ else {
1129
+ rootMap.items.push(newPair);
1130
+ }
1131
+ changed = true;
1132
+ notes.push("roles: ブロックを追加");
1133
+ }
1134
+ }
1135
+ if (changed) {
1136
+ await writeFile(file, doc.toString(), "utf8");
1137
+ }
1138
+ return { changed, renamed, notes };
1139
+ }
1140
+ function deriveEntityId(entry, fallbackRole, taken) {
1141
+ const provider = entry.get("provider");
1142
+ const type = entry.get("type");
1143
+ const effort = entry.get("effort");
1144
+ const modelName = entry.get("model");
1145
+ let candidate = null;
1146
+ if (provider === "codex") {
1147
+ candidate = `codex-${effort || (fallbackRole === "chat" ? "low" : "xhigh")}`;
1148
+ }
1149
+ else if (provider === "claude-code") {
1150
+ candidate = effort ? `claude-code-${effort}` : "claude-code";
1151
+ }
1152
+ else if (type === "api" && typeof provider === "string") {
1153
+ candidate = provider;
1154
+ }
1155
+ else if (type === "ollama" && typeof modelName === "string") {
1156
+ candidate = `ollama-${modelName.split(":")[0]}`;
1157
+ }
1158
+ if (!candidate)
1159
+ return null;
1160
+ // 衝突回避
1161
+ let final = candidate;
1162
+ let n = 2;
1163
+ while (taken.has(final)) {
1164
+ final = `${candidate}-${n}`;
1165
+ n += 1;
1166
+ }
1167
+ return final;
1168
+ }
1169
+ export async function writeConfig(file, config) {
1170
+ await mkdir(path.dirname(file), { recursive: true });
1171
+ await writeFile(file, stringifyTomlConfig(config), "utf8");
1172
+ }
1173
+ export function expandHome(value) {
1174
+ if (value === "~") {
1175
+ return os.homedir();
1176
+ }
1177
+ if (value.startsWith("~/")) {
1178
+ return path.join(os.homedir(), value.slice(2));
1179
+ }
1180
+ return value;
1181
+ }
1182
+ export function normalizeConfig(config) {
1183
+ const workspace = expandHome(config.workspace || defaultWorkspace());
1184
+ return {
1185
+ ...config,
1186
+ workspace,
1187
+ notes_dir: expandHome(config.notes_dir || path.join(workspace, "notes")),
1188
+ skills_dir: expandHome(config.skills_dir || path.join(workspace, "skills")),
1189
+ memory_dir: expandHome(config.memory_dir || path.join(workspace, "memory")),
1190
+ index_dir: expandHome(config.index_dir || path.join(workspace, "index")),
1191
+ logs_dir: expandHome(config.logs_dir || path.join(workspace, "logs")),
1192
+ log_retention_days: typeof config.log_retention_days === "number" && config.log_retention_days >= 0
1193
+ ? config.log_retention_days
1194
+ : 14,
1195
+ event_log_retention_days: typeof config.event_log_retention_days === "number" && config.event_log_retention_days >= 0
1196
+ ? config.event_log_retention_days
1197
+ : 90,
1198
+ defaults: {
1199
+ note_format: config.defaults?.note_format || "daily_markdown",
1200
+ ...(normalizeLocaleValue(config.defaults?.locale)
1201
+ ? { locale: normalizeLocaleValue(config.defaults?.locale) }
1202
+ : {}),
1203
+ },
1204
+ // chat_model_id / builder_model_id は loadConfig / setupWorkspace で
1205
+ // models.yaml.roles から導出される。ここでは空文字を許容する。
1206
+ chat_model_id: config.chat_model_id || "",
1207
+ builder_model_id: config.builder_model_id || "",
1208
+ };
1209
+ }
1210
+ function normalizeLocaleValue(value) {
1211
+ if (typeof value !== "string")
1212
+ return undefined;
1213
+ const lower = value.trim().toLowerCase();
1214
+ if (lower === "ja" || lower === "en")
1215
+ return lower;
1216
+ return undefined;
1217
+ }
1218
+ async function loadLegacyConfigOrDefault(workspace, fallback) {
1219
+ try {
1220
+ const raw = await readFile(legacyConfigPath(workspace), "utf8");
1221
+ return normalizeConfig(YAML.parse(raw));
1222
+ }
1223
+ catch (error) {
1224
+ if (!isMissingFile(error)) {
1225
+ throw error;
1226
+ }
1227
+ return fallback;
1228
+ }
1229
+ }
1230
+ function applySetupOptions(config, options) {
1231
+ const next = {
1232
+ ...config,
1233
+ defaults: {
1234
+ ...config.defaults,
1235
+ },
1236
+ };
1237
+ if (options.workspace) {
1238
+ next.workspace = expandHome(options.workspace);
1239
+ }
1240
+ if (options.notesDir) {
1241
+ next.notes_dir = expandHome(options.notesDir);
1242
+ }
1243
+ if (options.skillsDir) {
1244
+ next.skills_dir = expandHome(options.skillsDir);
1245
+ }
1246
+ if (options.memoryDir) {
1247
+ next.memory_dir = expandHome(options.memoryDir);
1248
+ }
1249
+ if (options.indexDir) {
1250
+ next.index_dir = expandHome(options.indexDir);
1251
+ }
1252
+ if (options.logsDir) {
1253
+ next.logs_dir = expandHome(options.logsDir);
1254
+ }
1255
+ if (options.chatModel) {
1256
+ next.chat_model_id = options.chatModel;
1257
+ }
1258
+ if (options.builder) {
1259
+ next.builder_model_id = options.builder;
1260
+ }
1261
+ return normalizeConfig(next);
1262
+ }
1263
+ async function loadModelsOrDefault(workspace) {
1264
+ try {
1265
+ return await loadModels(workspace);
1266
+ }
1267
+ catch (error) {
1268
+ if (!isMissingFile(error)) {
1269
+ throw error;
1270
+ }
1271
+ return defaultModels();
1272
+ }
1273
+ }
1274
+ function validateKnownModel(modelId, models) {
1275
+ if (!models.models[modelId]) {
1276
+ const known = Object.keys(models.models).join(", ");
1277
+ throw new Error(l(`Unknown model: ${modelId}. Available models: ${known}`, `不明なモデルです: ${modelId}。利用可能なモデル: ${known}`));
1278
+ }
1279
+ }
1280
+ function stringifyTomlConfig(config) {
1281
+ const defaultsLines = [`note_format = ${tomlString(config.defaults.note_format)}`];
1282
+ if (config.defaults.locale === "ja" || config.defaults.locale === "en") {
1283
+ defaultsLines.push(`locale = ${tomlString(config.defaults.locale)}`);
1284
+ }
1285
+ return [
1286
+ `version = ${config.version}`,
1287
+ `workspace = ${tomlString(config.workspace)}`,
1288
+ `notes_dir = ${tomlString(config.notes_dir)}`,
1289
+ `skills_dir = ${tomlString(config.skills_dir)}`,
1290
+ `memory_dir = ${tomlString(config.memory_dir)}`,
1291
+ `index_dir = ${tomlString(config.index_dir)}`,
1292
+ `logs_dir = ${tomlString(config.logs_dir)}`,
1293
+ `log_retention_days = ${config.log_retention_days}`,
1294
+ `event_log_retention_days = ${config.event_log_retention_days}`,
1295
+ "",
1296
+ "[defaults]",
1297
+ ...defaultsLines,
1298
+ "",
1299
+ ].join("\n");
1300
+ }
1301
+ function parseTomlConfig(raw) {
1302
+ return parseTomlConfigWithLegacy(raw).config;
1303
+ }
1304
+ export function parseTomlConfigWithLegacy(raw) {
1305
+ const parsed = {
1306
+ defaults: {},
1307
+ };
1308
+ const legacy = {};
1309
+ let section = "root";
1310
+ for (const originalLine of raw.split(/\r?\n/)) {
1311
+ const line = originalLine.trim();
1312
+ if (!line || line.startsWith("#")) {
1313
+ continue;
1314
+ }
1315
+ if (line === "[defaults]") {
1316
+ section = "defaults";
1317
+ continue;
1318
+ }
1319
+ if (line.startsWith("[") && line.endsWith("]")) {
1320
+ section = "ignored";
1321
+ continue;
1322
+ }
1323
+ const match = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
1324
+ if (!match) {
1325
+ continue;
1326
+ }
1327
+ const [, key, rawValue] = match;
1328
+ const value = parseTomlValue(rawValue);
1329
+ if (section === "defaults") {
1330
+ // 旧フィールド (chat_model / builder) は models.yaml.roles に統合済み。
1331
+ // ここでは捕捉だけ行い、defaults セクションには書き戻さない。
1332
+ if (key === "chat_model") {
1333
+ legacy.chat = String(value);
1334
+ continue;
1335
+ }
1336
+ if (key === "builder") {
1337
+ legacy.builder = String(value);
1338
+ continue;
1339
+ }
1340
+ parsed.defaults = {
1341
+ ...parsed.defaults,
1342
+ [key]: String(value),
1343
+ };
1344
+ }
1345
+ else if (section === "ignored") {
1346
+ continue;
1347
+ }
1348
+ else {
1349
+ Object.assign(parsed, { [key]: value });
1350
+ }
1351
+ }
1352
+ const config = parsed;
1353
+ const result = { config };
1354
+ if (legacy.chat || legacy.builder) {
1355
+ result.legacy_role_ids = legacy;
1356
+ }
1357
+ return result;
1358
+ }
1359
+ function parseTomlValue(value) {
1360
+ const trimmed = value.trim();
1361
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
1362
+ return JSON.parse(trimmed);
1363
+ }
1364
+ if (trimmed === "true")
1365
+ return true;
1366
+ if (trimmed === "false")
1367
+ return false;
1368
+ const numeric = Number(trimmed);
1369
+ return Number.isFinite(numeric) ? numeric : trimmed;
1370
+ }
1371
+ function tomlString(value) {
1372
+ return JSON.stringify(value);
1373
+ }
1374
+ function isMissingFile(error) {
1375
+ return (typeof error === "object" &&
1376
+ error !== null &&
1377
+ "code" in error &&
1378
+ error.code === "ENOENT");
1379
+ }