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,1129 @@
1
+ import { appendFile, cp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { agentSinInstallRoot, loadModels } from "../core/config.js";
4
+ import { findSkillManifest, listBuiltinSkillIds, loadSkillManifest, } from "../core/skill-registry.js";
5
+ import { getAiProvider, } from "../core/ai-provider.js";
6
+ import { findMissingRequiredEnv, runSkill } from "../core/runtime.js";
7
+ import { loadKnownModelIds, validateSkillDirectory, validateSkillId, } from "../core/skill-scaffold.js";
8
+ import { appendConversationLog } from "../core/logger.js";
9
+ import { dotenvPath, loadDotenv } from "../core/secrets.js";
10
+ import { formatProfileMemoryPromptSection, readProfileMemoryForPrompt, } from "../core/profile-memory.js";
11
+ import { maybePromoteDailyMemory } from "../core/daily-memory-promotion.js";
12
+ import { l } from "../core/i18n.js";
13
+ export async function createBuildSession(config, skillId, options = {}) {
14
+ const id = skillId || `new-skill-${timestampId()}`;
15
+ validateSkillId(id);
16
+ // Builder writes into the user workspace skill dir. Builder bookkeeping
17
+ // (session.json, events.jsonl, etc.) is hidden under skills/<id>/.builder/.
18
+ const root = options.root ?? (await resolveSessionRoot(config, id));
19
+ const draft = root;
20
+ const builderDir = path.join(root, ".builder");
21
+ const logs = path.join(builderDir, "logs");
22
+ const review = path.join(builderDir, "review.md");
23
+ const sessionPath = path.join(builderDir, "session.json");
24
+ const resultPath = path.join(builderDir, "builder-result.json");
25
+ await mkdir(draft, { recursive: true });
26
+ await mkdir(builderDir, { recursive: true });
27
+ await mkdir(logs, { recursive: true });
28
+ await writeTextIfMissing(review, `# Registration Review\n\n- skill id: ${id}\n- AI steps: TBD\n- allowed outputs: TBD\n- local test command: TBD\n- registration decision: pending\n`);
29
+ await writeSessionIfMissing(sessionPath, {
30
+ version: 1,
31
+ skill_id: id,
32
+ builder: config.builder_model_id,
33
+ status: "drafting",
34
+ runtime: options.runtime || "python",
35
+ access_mode: options.accessMode || "full",
36
+ messages: [],
37
+ updated_at: new Date().toISOString(),
38
+ });
39
+ return {
40
+ skill_id: id,
41
+ workspace: root,
42
+ draft_dir: draft,
43
+ logs_dir: logs,
44
+ review_path: review,
45
+ session_path: sessionPath,
46
+ result_path: resultPath,
47
+ };
48
+ }
49
+ export async function buildDraftWithAgent(config, skillId, userMessage, options = {}) {
50
+ emitBuildProgress(options.onProgress, l("Preparing build", "ビルド準備中"));
51
+ await maybePromoteDailyMemory(config, { eventSource: "build" });
52
+ const session = await createBuildSession(config, skillId, {
53
+ runtime: options.runtime,
54
+ accessMode: options.accessMode,
55
+ });
56
+ emitBuildProgress(options.onProgress, l(`Organizing requirements (${session.skill_id})`, `要件を整理しています (${session.skill_id})`));
57
+ const state = await readBuildState(session);
58
+ // Always reflect the latest roles.builder from models.yaml: when no explicit
59
+ // override is passed, prefer the current config value over any stale builder
60
+ // id left in the session.
61
+ const resolvedBuilder = await resolveBuilderEntry(config, options.builder);
62
+ const handoffMessage = formatHandoffSummary(options.handoff || []);
63
+ const handoffTs = new Date().toISOString();
64
+ const messagesWithHandoff = handoffMessage
65
+ ? [
66
+ ...state.messages,
67
+ {
68
+ role: "system",
69
+ content: handoffMessage,
70
+ ts: handoffTs,
71
+ },
72
+ ]
73
+ : state.messages;
74
+ const nextState = {
75
+ ...state,
76
+ builder: resolvedBuilder,
77
+ runtime: options.runtime || state.runtime || "python",
78
+ access_mode: options.accessMode || state.access_mode || "full",
79
+ status: "drafting",
80
+ messages: [
81
+ ...messagesWithHandoff,
82
+ {
83
+ role: "user",
84
+ content: userMessage,
85
+ ts: new Date().toISOString(),
86
+ },
87
+ ],
88
+ updated_at: new Date().toISOString(),
89
+ };
90
+ await writeBuildState(session, nextState);
91
+ await appendBuildEvent(session, "user_message", { content: userMessage });
92
+ await appendConversationLog(config, {
93
+ source: "builder",
94
+ role: "user",
95
+ content: userMessage,
96
+ skill_id: session.skill_id,
97
+ model_id: nextState.builder,
98
+ session_id: session.skill_id,
99
+ });
100
+ const runtimeContext = await collectBuilderRuntimeContext(config, session, options.eventSource);
101
+ const profileMemory = await readProfileMemoryForPrompt(config);
102
+ const filesBefore = await listDraftFiles(session.draft_dir);
103
+ const beforeMtimes = await snapshotMtimes(session.draft_dir, filesBefore);
104
+ emitBuildProgress(options.onProgress, l("AI is implementing", "AIが実装しています"));
105
+ let turn = await runBuilderTurn(config, session, nextState, {
106
+ messages: buildBuilderMessages(session, nextState, runtimeContext, profileMemory),
107
+ onProgress: options.onProgress,
108
+ });
109
+ let filesAfter = await listDraftFiles(session.draft_dir);
110
+ await ensureBuiltinOverrideForSession(config, session);
111
+ let hasSkillSourceFiles = await hasMinimumSkillSourceFiles(session.draft_dir, filesAfter, nextState.runtime);
112
+ let repairedMissingSource = false;
113
+ if (!hasSkillSourceFiles) {
114
+ repairedMissingSource = true;
115
+ emitBuildProgress(options.onProgress, l("Recreating required files", "必要なファイルを作り直しています"));
116
+ await appendBuildEvent(session, "builder_repair_requested", {
117
+ reason: "missing_skill_source_files",
118
+ });
119
+ turn = await runBuilderTurn(config, session, nextState, {
120
+ messages: buildBuilderRepairMessages(session, nextState, turn.response.text, runtimeContext, profileMemory),
121
+ onProgress: options.onProgress,
122
+ });
123
+ filesAfter = await listDraftFiles(session.draft_dir);
124
+ await ensureBuiltinOverrideForSession(config, session);
125
+ hasSkillSourceFiles = await hasMinimumSkillSourceFiles(session.draft_dir, filesAfter, nextState.runtime);
126
+ }
127
+ emitBuildProgress(options.onProgress, l("Checking generated files", "作成したファイルを確認しています"));
128
+ let readiness = hasSkillSourceFiles ? await inspectBuildSessionReadiness(config, session, session.skill_id) : null;
129
+ let repairedVerification = false;
130
+ if (hasSkillSourceFiles && readiness?.status === "failed") {
131
+ repairedVerification = true;
132
+ emitBuildProgress(options.onProgress, l("Fixing verification errors", "動作確認のエラーを修正しています"));
133
+ await appendBuildEvent(session, "builder_repair_requested", {
134
+ reason: "verification_failed",
135
+ summary: readiness.summary,
136
+ errors: readiness.validation.errors,
137
+ });
138
+ turn = await runBuilderTurn(config, session, nextState, {
139
+ messages: buildBuilderVerificationRepairMessages(session, nextState, turn.response.text, readiness, runtimeContext, profileMemory),
140
+ onProgress: options.onProgress,
141
+ });
142
+ filesAfter = await listDraftFiles(session.draft_dir);
143
+ await ensureBuiltinOverrideForSession(config, session);
144
+ hasSkillSourceFiles = await hasMinimumSkillSourceFiles(session.draft_dir, filesAfter, nextState.runtime);
145
+ emitBuildProgress(options.onProgress, l("Verifying after fixes", "修正後の動作確認をしています"));
146
+ readiness = hasSkillSourceFiles ? await inspectBuildSessionReadiness(config, session, session.skill_id) : null;
147
+ }
148
+ const finalWritten = await detectWrittenFiles(session.draft_dir, filesBefore, beforeMtimes, filesAfter);
149
+ // Use the agent's free-form text as the chat reply. Fall back to legacy summary
150
+ // block if the agent wrapped its reply (older fake providers etc.).
151
+ const rawSummary = turn.parsedSummary || stripBuilderArtifacts(turn.response.text) || l("(no response)", "(応答なし)");
152
+ const summary = hasSkillSourceFiles
153
+ ? sanitizeBuilderSummary(rawSummary, runtimeContext)
154
+ : missingSkillSourceSummary(rawSummary);
155
+ const status = buildStateStatusFromReadiness(hasSkillSourceFiles, readiness);
156
+ emitBuildProgress(options.onProgress, formatBuildStatusProgress(status));
157
+ const finishedState = {
158
+ ...nextState,
159
+ status,
160
+ messages: [
161
+ ...nextState.messages,
162
+ {
163
+ role: "assistant",
164
+ content: summary,
165
+ ts: new Date().toISOString(),
166
+ },
167
+ ],
168
+ updated_at: new Date().toISOString(),
169
+ };
170
+ await writeBuildState(session, finishedState);
171
+ await writeBuilderResult(session.result_path, {
172
+ status,
173
+ safe_to_register: readiness?.safe_to_register === true,
174
+ summary,
175
+ readiness_summary: readiness?.summary,
176
+ missing_env: readiness?.missing_env,
177
+ builder: nextState.builder,
178
+ provider: turn.response.provider,
179
+ files_written: finalWritten,
180
+ repaired_missing_source: repairedMissingSource,
181
+ repaired_verification: repairedVerification,
182
+ generated_at: new Date().toISOString(),
183
+ });
184
+ await appendBuildEvent(session, "assistant_draft", {
185
+ builder: nextState.builder,
186
+ provider: turn.response.provider,
187
+ summary,
188
+ files_written: finalWritten,
189
+ repaired_missing_source: repairedMissingSource,
190
+ repaired_verification: repairedVerification,
191
+ status,
192
+ });
193
+ await appendConversationLog(config, {
194
+ source: "builder",
195
+ role: "assistant",
196
+ content: turn.response.text,
197
+ skill_id: session.skill_id,
198
+ model_id: nextState.builder,
199
+ session_id: session.skill_id,
200
+ details: {
201
+ summary,
202
+ files_written: finalWritten,
203
+ provider: turn.response.provider,
204
+ repaired_missing_source: repairedMissingSource,
205
+ repaired_verification: repairedVerification,
206
+ status,
207
+ },
208
+ });
209
+ return {
210
+ session,
211
+ state: finishedState,
212
+ files_written: finalWritten,
213
+ summary,
214
+ model_id: turn.response.model_id,
215
+ provider: turn.response.provider,
216
+ };
217
+ }
218
+ function emitBuildProgress(onProgress, text) {
219
+ if (!onProgress) {
220
+ return;
221
+ }
222
+ try {
223
+ onProgress({ kind: "info", text });
224
+ }
225
+ catch {
226
+ // Progress callbacks must not affect the build result.
227
+ }
228
+ }
229
+ function formatBuildStatusProgress(status) {
230
+ switch (status) {
231
+ case "ready":
232
+ return l("Ready to run", "動かせる状態です");
233
+ case "needs_config":
234
+ return l("Waiting for settings", "設定待ちです");
235
+ case "failed":
236
+ return l("Still needs fixes", "まだ修正が必要です");
237
+ case "testing":
238
+ return l("Verifying", "動作確認しています");
239
+ case "drafting":
240
+ default:
241
+ return l("Drafting", "作成途中です");
242
+ }
243
+ }
244
+ export async function testBuildDraft(config, skillId, payload) {
245
+ const session = await createBuildSession(config, skillId);
246
+ const state = await readBuildState(session);
247
+ await writeBuildState(session, { ...state, status: "testing", updated_at: new Date().toISOString() });
248
+ await appendBuildEvent(session, "test_started", {});
249
+ const readiness = await inspectBuildSessionReadiness(config, session, skillId, payload);
250
+ const status = readiness.status === "ready" ? "ready" : readiness.status === "needs_config" ? "needs_config" : "failed";
251
+ await writeReview(session.review_path, {
252
+ skillId,
253
+ validation: readiness.validation,
254
+ dryRun: undefined,
255
+ payload: readiness.payload,
256
+ decision: readiness.safe_to_register ? "approved" : "pending",
257
+ summary: readiness.summary,
258
+ });
259
+ await writeBuilderResult(session.result_path, {
260
+ status,
261
+ safe_to_register: readiness.safe_to_register,
262
+ summary: readiness.summary,
263
+ validation: {
264
+ ok: readiness.validation.ok,
265
+ errors: readiness.validation.errors,
266
+ warnings: readiness.validation.warnings,
267
+ },
268
+ missing_env: readiness.missing_env,
269
+ error: !readiness.safe_to_register ? readiness.summary : undefined,
270
+ tested_at: new Date().toISOString(),
271
+ });
272
+ await writeBuildState(session, { ...state, status, updated_at: new Date().toISOString() });
273
+ await appendBuildEvent(session, "test_finished", {
274
+ status,
275
+ safe_to_register: readiness.safe_to_register,
276
+ summary: readiness.summary,
277
+ });
278
+ return {
279
+ session,
280
+ validation: readiness.validation,
281
+ payload: readiness.payload,
282
+ safe_to_register: readiness.safe_to_register,
283
+ status,
284
+ summary: readiness.summary,
285
+ details: readiness.details,
286
+ missing_env: readiness.missing_env,
287
+ };
288
+ }
289
+ export async function inspectBuildReadiness(config, skillId, payload) {
290
+ const session = await createBuildSession(config, skillId);
291
+ return inspectBuildSessionReadiness(config, session, skillId, payload);
292
+ }
293
+ async function inspectBuildSessionReadiness(config, session, skillId, payload) {
294
+ const validation = await validateSkillDirectory(session.draft_dir, skillId, {
295
+ knownModelIds: await loadKnownModelIds(config),
296
+ });
297
+ const testPayload = payload ?? (await readFixturePayload(session));
298
+ let missingEnv = [];
299
+ if (validation.ok && validation.manifest) {
300
+ await loadDotenv(config.workspace);
301
+ missingEnv = findMissingRequiredEnv(validation.manifest);
302
+ }
303
+ let status = "failed";
304
+ let summary = "";
305
+ let details = "";
306
+ if (!validation.ok) {
307
+ status = validation.errors.some((error) => /Entry file not found|skill\.yaml/i.test(error))
308
+ ? "incomplete"
309
+ : "failed";
310
+ summary = validation.errors[0] || l("The skill definition is inconsistent.", "スキルの定義に不整合があります。");
311
+ }
312
+ else if (missingEnv.length > 0) {
313
+ status = "needs_config";
314
+ summary = l(`Required settings are missing: ${missingEnv.map((entry) => entry.name).join(", ")}`, `必要な設定が未入力です: ${missingEnv.map((entry) => entry.name).join(", ")}`);
315
+ }
316
+ else {
317
+ try {
318
+ const dryRunSkillsDir = path.dirname(session.draft_dir);
319
+ const dryRun = await runSkill({ ...config, skills_dir: dryRunSkillsDir }, skillId, testPayload, { dryRun: true });
320
+ if (dryRun.result.status === "error") {
321
+ status = "failed";
322
+ summary = dryRun.result.summary || l("The skill failed at runtime.", "実行時にエラーになりました。");
323
+ details = dryRun.result.summary || "";
324
+ }
325
+ else {
326
+ status = "ready";
327
+ summary = dryRun.result.summary || dryRun.result.title || l("Ready to run.", "動かせる状態です。");
328
+ details = dryRun.result.summary || "";
329
+ }
330
+ }
331
+ catch (error) {
332
+ status = "failed";
333
+ summary = error instanceof Error ? error.message : String(error);
334
+ details = summary;
335
+ }
336
+ }
337
+ return {
338
+ session,
339
+ validation,
340
+ payload: testPayload,
341
+ safe_to_register: status === "ready",
342
+ status,
343
+ summary,
344
+ details,
345
+ missing_env: missingEnv.length > 0 ? missingEnv : undefined,
346
+ };
347
+ }
348
+ function buildStateStatusFromReadiness(hasSkillSourceFiles, readiness) {
349
+ if (!hasSkillSourceFiles || !readiness)
350
+ return "drafting";
351
+ if (readiness.status === "ready")
352
+ return "ready";
353
+ if (readiness.status === "needs_config")
354
+ return "needs_config";
355
+ return "failed";
356
+ }
357
+ export async function listBuildDrafts(config) {
358
+ const summaries = [];
359
+ // Scan skills_dir for entries that have a .builder/session.json
360
+ let entries;
361
+ try {
362
+ entries = await readdir(config.skills_dir, { withFileTypes: true });
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ for (const entry of entries) {
368
+ if (!entry.isDirectory())
369
+ continue;
370
+ const skillId = entry.name;
371
+ const skillDir = path.join(config.skills_dir, skillId);
372
+ const sessionPath = path.join(skillDir, ".builder", "session.json");
373
+ let state = null;
374
+ try {
375
+ const raw = await readFile(sessionPath, "utf8");
376
+ state = JSON.parse(raw);
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ const hasSkillYaml = await pathExists(path.join(skillDir, "skill.yaml"));
382
+ const result = await readJsonIfExists(path.join(skillDir, ".builder", "builder-result.json"));
383
+ const summary = typeof result?.summary === "string" ? result.summary : undefined;
384
+ summaries.push({
385
+ skill_id: skillId,
386
+ status: state.status || "drafting",
387
+ runtime: state.runtime === "typescript" ? "typescript" : "python",
388
+ builder: state.builder || config.builder_model_id,
389
+ message_count: Array.isArray(state.messages) ? state.messages.length : 0,
390
+ updated_at: state.updated_at || "",
391
+ draft_dir: skillDir,
392
+ has_skill_yaml: hasSkillYaml,
393
+ safe_to_register: Boolean(result && result.safe_to_register === true),
394
+ summary,
395
+ });
396
+ }
397
+ summaries.sort((a, b) => {
398
+ if (a.updated_at && b.updated_at) {
399
+ return b.updated_at.localeCompare(a.updated_at);
400
+ }
401
+ return a.skill_id.localeCompare(b.skill_id);
402
+ });
403
+ return summaries;
404
+ }
405
+ async function pathExists(file) {
406
+ try {
407
+ await stat(file);
408
+ return true;
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
414
+ function isPathInside(root, target) {
415
+ const relative = path.relative(root, target);
416
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
417
+ }
418
+ export async function readBuildStatus(config, skillId) {
419
+ const session = await createBuildSession(config, skillId);
420
+ return {
421
+ session,
422
+ state: await readBuildState(session),
423
+ result: await readJsonIfExists(session.result_path),
424
+ };
425
+ }
426
+ export async function registerBuildSkill(config, skillId, _options = {}) {
427
+ // Builder writes into the user skill dir (an override copy when editing a
428
+ // builtin). Registration is a no-op: verify skill.yaml is in place at the
429
+ // resolved root and report success.
430
+ const target = await resolveSessionRoot(config, skillId);
431
+ const manifest = await assertRegisterableSkillSource(config, target, skillId);
432
+ return { skill_id: manifest.id, source_dir: target, target_dir: target };
433
+ }
434
+ function formatHandoffSummary(handoff) {
435
+ const trimmed = handoff
436
+ .filter((turn) => turn.content && turn.content.trim().length > 0)
437
+ .slice(-12);
438
+ if (trimmed.length === 0) {
439
+ return "";
440
+ }
441
+ const lines = [
442
+ "[Chat handoff context — conversation from the previous chat. Use it to extract requirements.]",
443
+ ];
444
+ for (const turn of trimmed) {
445
+ const role = turn.role === "assistant" ? "assistant" : turn.role === "tool" ? "tool" : "user";
446
+ const content = turn.content.replaceAll("```", "´´´").slice(0, 800);
447
+ lines.push(`<${role}>\n${content}\n</${role}>`);
448
+ }
449
+ return lines.join("\n");
450
+ }
451
+ async function assertRegisterableSkillSource(config, skillDir, skillId) {
452
+ const validation = await validateSkillDirectory(skillDir, skillId, {
453
+ knownModelIds: await loadKnownModelIds(config),
454
+ });
455
+ if (validation.ok && validation.manifest) {
456
+ return validation.manifest;
457
+ }
458
+ const hasManifest = await pathExists(path.join(skillDir, "skill.yaml"));
459
+ const isMissingEntry = validation.errors.some((error) => /Entry file not found/i.test(error));
460
+ if (!hasManifest || isMissingEntry) {
461
+ throw new Error(l("Creation is not complete yet. Describe what the skill should do a little more specifically.", "まだ作成が完了していません。何をするスキルかをもう少し具体的に伝えてください。"));
462
+ }
463
+ const hasIdMismatch = validation.errors.some((error) => /does not match/i.test(error));
464
+ if (hasIdMismatch) {
465
+ throw new Error(l("The skill name and save location do not match. Describe the fix again.", "スキルの名前と保存先が一致していません。もう一度、修正内容を伝えてください。"));
466
+ }
467
+ throw new Error(l("The skill definition is inconsistent. Describe the fix again.", "スキルの定義に不整合があります。もう一度、修正内容を伝えてください。"));
468
+ }
469
+ // Respect `candidate` only when the CLI explicitly passes `--builder` or
470
+ // similar. Normal calls arrive without a candidate, so config.builder_model_id
471
+ // (i.e. roles.builder from models.yaml) is always used.
472
+ async function resolveBuilderEntry(config, candidate) {
473
+ const fallback = config.builder_model_id;
474
+ if (!candidate)
475
+ return fallback;
476
+ try {
477
+ const models = await loadModels(config.workspace);
478
+ if (models.models[candidate])
479
+ return candidate;
480
+ }
481
+ catch {
482
+ // models.yaml unreadable — fall through to fallback
483
+ }
484
+ return fallback;
485
+ }
486
+ async function runBuilderTurn(config, session, state, options) {
487
+ emitBuildProgress(options.onProgress, l("Starting builder", "ビルダーを起動しています"));
488
+ const response = await getAiProvider()(config, {
489
+ model_id: state.builder,
490
+ messages: options.messages,
491
+ temperature: 0.2,
492
+ role: "builder",
493
+ onProgress: options.onProgress,
494
+ permission_mode: "bypass",
495
+ cwd: session.draft_dir,
496
+ });
497
+ // Backward compat: if the AI returned legacy fenced file blocks, write them.
498
+ let parsedSummary;
499
+ try {
500
+ const parsed = parseBuilderFiles(response.text);
501
+ parsedSummary = parsed.summary;
502
+ if (Object.keys(parsed.files).length > 0) {
503
+ emitBuildProgress(options.onProgress, l(`Writing ${Object.keys(parsed.files).length} file(s)`, `${Object.keys(parsed.files).length}件のファイルを書き込んでいます`));
504
+ await writeBuilderFiles(session.draft_dir, parsed.files);
505
+ }
506
+ }
507
+ catch {
508
+ // No fenced blocks — agent wrote (or didn't) directly via cwd.
509
+ }
510
+ return { response, parsedSummary };
511
+ }
512
+ export async function prepareEditDraft(config, skillId) {
513
+ let manifest;
514
+ try {
515
+ manifest = await findSkillManifest(config.skills_dir, skillId);
516
+ }
517
+ catch {
518
+ throw new Error(l(`Skill not found for edit: ${skillId}`, `編集するスキルが見つかりません: ${skillId}`));
519
+ }
520
+ const root = await resolveSessionRoot(config, skillId, manifest);
521
+ const session = await createBuildSession(config, skillId, {
522
+ runtime: manifest.runtime,
523
+ root,
524
+ });
525
+ if (manifest.source === "builtin") {
526
+ await appendBuildEvent(session, "builtin_override_prepared", {
527
+ source_dir: manifest.dir,
528
+ target_dir: session.draft_dir,
529
+ });
530
+ }
531
+ return session;
532
+ }
533
+ export async function prepareRepairDraft(config, manifest) {
534
+ return prepareEditDraft(config, manifest.id);
535
+ }
536
+ /**
537
+ * Resolve the skill directory the builder should treat as its working root.
538
+ *
539
+ * - User skills (or brand new skills without a manifest yet) → `skills_dir/<id>/`.
540
+ * - Builtin skills → copy into `skills_dir/<id>/` with `override: true` set,
541
+ * so the packaged `builtin-skills/<id>/` stays untouched.
542
+ */
543
+ async function resolveSessionRoot(config, skillId, preloadedManifest) {
544
+ let manifest = preloadedManifest ?? null;
545
+ if (!manifest) {
546
+ try {
547
+ manifest = await findSkillManifest(config.skills_dir, skillId);
548
+ }
549
+ catch {
550
+ manifest = null;
551
+ }
552
+ }
553
+ if (!manifest || manifest.source !== "builtin") {
554
+ return path.join(config.skills_dir, skillId);
555
+ }
556
+ const target = path.join(config.skills_dir, skillId);
557
+ await mkdir(config.skills_dir, { recursive: true });
558
+ if (!(await pathExists(path.join(target, "skill.yaml")))) {
559
+ await cp(manifest.dir, target, { recursive: true, force: false, errorOnExist: false });
560
+ }
561
+ await ensureOverrideFlag(path.join(target, "skill.yaml"));
562
+ return target;
563
+ }
564
+ async function ensureBuiltinOverrideForSession(config, session) {
565
+ const builtinIds = await listBuiltinSkillIds();
566
+ if (!builtinIds.has(session.skill_id))
567
+ return;
568
+ const manifestPath = path.join(session.draft_dir, "skill.yaml");
569
+ if (!(await pathExists(manifestPath)))
570
+ return;
571
+ if (!isPathInside(path.resolve(config.skills_dir), path.resolve(session.draft_dir)))
572
+ return;
573
+ await ensureOverrideFlag(manifestPath);
574
+ }
575
+ async function ensureOverrideFlag(manifestPath) {
576
+ const raw = await readFile(manifestPath, "utf8");
577
+ const next = /^override:\s+/m.test(raw)
578
+ ? raw.replace(/^override:\s+.*$/m, "override: true")
579
+ : `${raw.replace(/\s+$/g, "")}\noverride: true\n`;
580
+ await writeFile(manifestPath, next, "utf8");
581
+ }
582
+ function buildBuilderRepairMessages(session, state, previousText, runtimeContext, profileMemory) {
583
+ const previous = stripBuilderArtifacts(previousText) || previousText || "(no response)";
584
+ return [
585
+ ...buildBuilderMessages(session, state, runtimeContext, profileMemory),
586
+ { role: "assistant", content: previous },
587
+ {
588
+ role: "user",
589
+ content: [
590
+ "Your previous reply did not create skill.yaml / main.* in the draft.",
591
+ "Do not ask the user to rephrase. In this turn, write a minimal working implementation directly under cwd.",
592
+ "Fill any unknowns with reasonable assumptions, and declare required_env if credentials are needed.",
593
+ "After writing, return only a short user-facing report in the user's language.",
594
+ ].join("\n"),
595
+ },
596
+ ];
597
+ }
598
+ function buildBuilderVerificationRepairMessages(session, state, previousText, readiness, runtimeContext, profileMemory) {
599
+ const previous = stripBuilderArtifacts(previousText) || previousText || "(no response)";
600
+ const errors = readiness.validation.errors.length > 0
601
+ ? readiness.validation.errors.join("\n")
602
+ : readiness.summary;
603
+ return [
604
+ ...buildBuilderMessages(session, state, runtimeContext, profileMemory),
605
+ { role: "assistant", content: previous },
606
+ {
607
+ role: "user",
608
+ content: [
609
+ "The post-creation self-check still cannot run the skill.",
610
+ "Do not ask the user to rephrase. In this turn, fix the implementation directly under cwd.",
611
+ "If the only missing piece is credentials, declare them in required_env and still finish the implementation.",
612
+ "",
613
+ "<verification_result>",
614
+ errors,
615
+ "</verification_result>",
616
+ ].join("\n"),
617
+ },
618
+ ];
619
+ }
620
+ function missingSkillSourceSummary(rawSummary) {
621
+ const trimmed = rawSummary.trim();
622
+ if (!trimmed || trimmed === "(no response)" || trimmed === "(応答なし)") {
623
+ return l("I could not continue creating it. Please describe in one sentence what you want the skill to do.", "作成に進めませんでした。何をするスキルにしたいか、1文で教えてください。");
624
+ }
625
+ return l(`${trimmed}\n\nI need more detail to create it. Please describe in one sentence what you want the skill to do.`, `${trimmed}\n\n作成に必要な内容が足りません。何をするスキルにしたいか、1文で教えてください。`);
626
+ }
627
+ function sanitizeBuilderSummary(summary, _context) {
628
+ return summary
629
+ .replace(/^.*(?:override|上書きコピー|workspace override|packaged builtin|packaged original|ビルトイン.*本体.*変更).*$\n?/gim, "")
630
+ .replace(/\n{3,}/g, "\n\n")
631
+ .trim();
632
+ }
633
+ async function collectBuilderRuntimeContext(config, session, eventSource) {
634
+ const envPath = await dotenvPath(config.workspace);
635
+ const dotenvKeys = await readDotenvKeyNames(envPath);
636
+ await loadDotenv(config.workspace);
637
+ let enabledModels = [];
638
+ let disabledModels = [];
639
+ try {
640
+ const models = await loadModels(config.workspace);
641
+ const entries = Object.entries(models.models || {});
642
+ enabledModels = entries
643
+ .filter(([, model]) => model.enabled !== false)
644
+ .map(([id, model]) => formatModelSummary(id, model));
645
+ disabledModels = entries
646
+ .filter(([, model]) => model.enabled === false)
647
+ .map(([id, model]) => formatModelSummary(id, model));
648
+ }
649
+ catch {
650
+ enabledModels = [];
651
+ disabledModels = [];
652
+ }
653
+ return {
654
+ dotenv_path: envPath,
655
+ dotenv_loaded: dotenvKeys !== null,
656
+ dotenv_keys: dotenvKeys || [],
657
+ default_chat_model: config.chat_model_id,
658
+ enabled_models: enabledModels,
659
+ disabled_models: disabledModels,
660
+ workspace_dir: config.workspace,
661
+ skills_dir: config.skills_dir,
662
+ install_root: agentSinInstallRoot(),
663
+ draft_dir: session.draft_dir,
664
+ event_source: eventSource,
665
+ };
666
+ }
667
+ async function readDotenvKeyNames(envPath) {
668
+ let raw = "";
669
+ try {
670
+ raw = await readFile(envPath, "utf8");
671
+ }
672
+ catch {
673
+ return null;
674
+ }
675
+ const keys = [];
676
+ const seen = new Set();
677
+ for (const line of raw.split(/\r?\n/)) {
678
+ const match = line.trim().match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
679
+ if (!match || seen.has(match[1]))
680
+ continue;
681
+ seen.add(match[1]);
682
+ keys.push(match[1]);
683
+ }
684
+ return keys;
685
+ }
686
+ function formatModelSummary(id, model) {
687
+ const target = String(model.model || model.provider || model.type || "").trim();
688
+ return target ? `${id}(${target})` : id;
689
+ }
690
+ export function formatDiscordSlashGuidance(context) {
691
+ if (!context || context.event_source !== "discord")
692
+ return [];
693
+ return [
694
+ "# Discord-only extra spec (you may also add a slash command)",
695
+ "- This session was opened from Discord. The chat-triggered `invocation.phrases` remains required as before. `invocation.discord_slash` is an *additional* shortcut, not a replacement.",
696
+ "- Add `invocation.discord_slash` when the user explicitly asks for a slash command/button-style trigger, or when the skill is a simple action (todo add, memo add, delete, toggle, etc.) with 1-3 clearly-typed arguments. Generative / conversational / summarizing skills only need phrases.",
697
+ "- Shape:\n```\ninvocation:\n phrases: [...] # required (keep)\n discord_slash: # optional (add)\n description: Flip a coin\n options:\n - name: count\n type: integer # only string|integer|number|boolean\n description: number of flips\n required: false\n```",
698
+ "- Rules: the command name is taken from skill.id (one skill = one command, flat options, no subcommands). `options[*].type` must be string/integer/number/boolean. `choices` must be `[{name, value}]` for string/integer/number only. `required` defaults to false. A Japanese description can be placed in `description_ja`.",
699
+ "- Keep `input.schema` and `discord_slash.options` consistent. Use matching names/meanings (e.g. if the schema has `text`, the option should also be `name: text`).",
700
+ "- Activation timing: when a skill that adds, changes, or removes `discord_slash` is registered, the Discord slash-command list will only update after the bot restarts (reconnects). Always mention this in the completion message (e.g. \"Restart the bot to use it as a slash command. The chat trigger (phrases) is available now.\"). A skill that only uses `phrases`, or an edit that does not touch `discord_slash`, does not require a restart.",
701
+ "",
702
+ ];
703
+ }
704
+ function formatBuilderRuntimeContext(context) {
705
+ if (!context)
706
+ return [];
707
+ const envKeys = context.dotenv_keys.length > 0 ? summarizeList(context.dotenv_keys, 80) : "(none)";
708
+ const enabled = context.enabled_models.length > 0 ? summarizeList(context.enabled_models, 30) : "(none)";
709
+ const disabled = context.disabled_models.length > 0 ? summarizeList(context.disabled_models, 30) : "(none)";
710
+ return [
711
+ "# Workspace",
712
+ `- Working directory (cwd / writable): ${context.draft_dir}`,
713
+ `- agent-sin source tree (read-only, for reference): ${context.install_root}`,
714
+ `- agent-sin workspace: ${context.workspace_dir}`,
715
+ `- Other skills (read-only, for reference): ${context.skills_dir}`,
716
+ `- .env (update only the env vars this skill needs): ${context.dotenv_path}`,
717
+ "",
718
+ "# Areas you must not touch directly, and how to handle them",
719
+ `- ${context.dotenv_path}: you may add/update only the keys declared in this skill's required_env, or skill-specific keys the user explicitly mentioned. Do not change unrelated keys. Never write agent-sin runtime settings (AGENT_SIN_*).`,
720
+ `- ${context.skills_dir}/<other skill>: when another skill needs to be fixed, ask the user to say "fix XXX". The host will switch cwd to that skill and restart build mode.`,
721
+ `- ${context.workspace_dir}/schedules.yaml: do not edit directly. If recurring execution is needed, instruct the user to call the schedule-add built-in skill.`,
722
+ `- ${context.workspace_dir}/models.yaml / config.toml: do not touch. These are agent-sin runtime settings.`,
723
+ `- ${context.install_root}: the agent-sin source. Read it for reference if useful, but never write to it.`,
724
+ "",
725
+ "# Existing settings (values hidden)",
726
+ `- .env: ${context.dotenv_loaded ? "loaded" : "not created"} (${context.dotenv_path})`,
727
+ `- .env keys: ${envKeys}`,
728
+ `- Default model: ${context.default_chat_model}`,
729
+ `- Enabled model ids: ${enabled}`,
730
+ `- Disabled model ids: ${disabled}`,
731
+ `- Registration: handled automatically. Do not ask the user to register the skill afterwards.`,
732
+ ];
733
+ }
734
+ function summarizeList(values, limit) {
735
+ const visible = values.slice(0, limit);
736
+ const rest = values.length - visible.length;
737
+ return rest > 0 ? `${visible.join(", ")} and ${rest} more` : visible.join(", ");
738
+ }
739
+ function buildBuilderMessages(session, state, runtimeContext, profileMemory) {
740
+ const profileLines = formatProfileMemoryPromptSection(profileMemory);
741
+ const system = [
742
+ "# Context",
743
+ "- You are the skill-building module of the agent-sin AI agent.",
744
+ "- Your job is to build a skill that runs on agent-sin from the user's request, and to leave it in a state where it can be used immediately after writing.",
745
+ "- The user is not an engineer. Do not ask about function names, commands, or technical jargon. Take requirements in plain language.",
746
+ "",
747
+ "# How to proceed",
748
+ "1. Read the user's requirements. Only when something truly cannot be built without clarification, ask at most three questions at a time. You may ask another batch of up to three if the answer is still insufficient. Iterate as many times as needed. Conversely, when you can decide on your own, go straight to implementation.",
749
+ "2. Once requirements are clear, you *must* emit file blocks in the response body to write the files. The format is `\\`\\`\\`file:<relative-path>` to open, the raw file content, and `\\`\\`\\`` to close. Always emit both skill.yaml and main.py (main.ts if TypeScript). If needed, emit fixtures/input.json in the same format. Do not use language fences like `\\`\\`\\`yaml` / `\\`\\`\\`python`, and do not use plain labels like \"File:\" alone. If you write only a completion report and omit the file blocks, agent-sin writes nothing and the skill stays unfinished. Example:\n```file:skill.yaml\nid: example\nname: Example\ndescription: ...\nruntime: python\ninvocation:\n phrases:\n - run the example\noutput_mode: raw\n```\n```file:main.py\nasync def run(ctx, input):\n return {\"status\": \"ok\", \"summary\": \"hello\"}\n```",
750
+ "3. After writing the files, write a completion report. The report is a short 1-2 sentence note about \"what is now possible\". Do not add confirmation questions like \"Want to test it?\" or \"Should I run it?\". If the user replies with \"run it\" / \"try it\" / etc., the system automatically switches back to chat mode and runs the skill — you do not need to prompt for that.",
751
+ "",
752
+ "# Handling existing settings",
753
+ "- Before building, consult the existing-settings list below and do not present already-configured keys as missing. Values are not visible to you; rely only on whether the key name exists.",
754
+ "- For `ai_steps[].model`, default to the logical role names `chat` (lightweight) or `builder` (high quality). Only specify a real model id (e.g. `codex-low`) when a specific model is required. Never write a non-existent model name.",
755
+ "- When the user says \"Discord notification\", they mean the agent-sin notification feature. Call `ctx.notify({ channel: \"discord\", ... })` from the skill. Do not create your own `DISCORD_WEBHOOK_URL` or POST to webhooks directly.",
756
+ "- `AGENT_SIN_DISCORD_*` for Discord notifications is an agent-sin runtime setting. Do not put it in the skill's `required_env` or .env examples. Only if it is not yet configured, briefly tell the user that agent-sin's Discord notification setup is needed.",
757
+ "- When the user says \"Telegram notification\", use `ctx.notify({ channel: \"telegram\", ... })` the same way. `AGENT_SIN_TELEGRAM_*` is also a runtime setting and must not appear in skill.yaml.",
758
+ "",
759
+ "# Hard rules",
760
+ "- Never finish with only a completion report and no skill.yaml / main.*. Don't stop at requirement confirmation.",
761
+ "- Even when credentials need to be acquired, write skill.yaml and main.* first. Declaring them in `required_env` simply blocks execution until values are present; the implementation can still be completed.",
762
+ "- *Never touch agent-sin runtime settings* (`AGENT_SIN_CODEX_*` / `AGENT_SIN_DISCORD_*` / `AGENT_SIN_TELEGRAM_*` / `AGENT_SIN_NOTIFY_*` / `AGENT_SIN_SLACK_*` / `AGENT_SIN_SMTP_*` / `AGENT_SIN_MAIL_*` / `AGENT_SIN_FAKE_*` / `AGENT_SIN_DISABLE_*` etc.). Never include them in .env examples. These are agent-sin runtime knobs, not skill settings. Only the skill's own keys (e.g. `OPENAI_API_KEY`, `GMAIL_USER`) belong in `required_env`.",
763
+ "- Keep the \"setup before use\" instructions to at most 4 steps. Do not paste long official documentation or suggest configuration values the user didn't ask about.",
764
+ "- Files written by `outputs` (notes, reports, etc.) must be material the user reads back later or another skill reuses. Do not write transient run results or internal state to outputs. Return ephemeral data via the return value's `data`, or store it in `memory`.",
765
+ "",
766
+ "# User-facing response rules (most important)",
767
+ "What the user wants to know is just \"what is now possible\" and \"what they need to do to use it\". Do not expose technical details.",
768
+ "Respond in the user's language. If the user wrote in Japanese, reply in Japanese; if in English, reply in English. Keep it short.",
769
+ "",
770
+ "Always respond to the user in plain text. Do not use any Markdown styling (bold, headings, bullet markers, numbered lists, inline code, fenced code blocks, links, tables, etc.). Completion reports and setup instructions are written as plain sentences without decoration. For list-like content, use line breaks instead of bullet markers. The internal file: / summary fences used for file writes are not shown to the user, so those may be used as before.",
771
+ "",
772
+ "Do not include",
773
+ "- The names of files you wrote (skill.yaml / main.py / fixtures/... etc.)",
774
+ "- Internal terms like test count, validation, ready status, provider names",
775
+ "- Internal storage details such as builtin, override copy, override flag",
776
+ "- Run logs like \"Python syntax check OK\"",
777
+ "- Implementation commentary like \"as a safe core\"",
778
+ "",
779
+ "Do include (completion-report template)",
780
+ "1-2 sentences of \"what is now possible\" (e.g. \"Classifies unread Gmail and summarizes it at 3pm.\"). If there is setup the user must do, follow with a brief description. Do not add confirmation prompts like \"Want to test it?\" or \"Should I run it?\". When the user replies with \"run it\" / \"try it\", the system auto-switches back to chat mode and runs the skill, so you do not need to prompt.",
781
+ "",
782
+ "## When credentials are required (API key / OAuth / token / cookie / etc.)",
783
+ "Skills that integrate with external services almost always need credentials. It is normal that the skill cannot run immediately after implementation, so always include the following:",
784
+ "- Where to obtain them: a concrete 2-4 step procedure pointing to the official console.",
785
+ " Example for Gmail: \"In Google Cloud Console (https://console.cloud.google.com/) create a new project → enable the Gmail API → set up the OAuth consent screen → Credentials → create an OAuth client ID (desktop) → download the JSON\".",
786
+ "- Where to save what: the key names and formats to write to `~/.agent-sin/.env`, one per line.",
787
+ " Example: `GMAIL_CREDENTIALS_PATH=~/credentials.json`, `OPENAI_API_KEY=sk-...`.",
788
+ "- If the user pastes a value into chat and you can tell which `required_env` entry it belongs to, you may update `~/.agent-sin/.env` directly. After saving, just reply \"Saved.\" briefly. Only when you cannot tell, ask the user to send it as `env NAME=value`.",
789
+ "- Do not push for \"test it\" until setup is finished. Instead write something like \"once you have obtained X and saved it to .env, let me know and I'll verify.\"",
790
+ "",
791
+ "# Skill authoring rules",
792
+ "- skill.yaml requires `id` (kebab-case), `runtime` (python|typescript), `name`, `description`, and `invocation.phrases`. `invocation.phrases` is required for chat invocation; list 3-6 entries combining the skill name, aliases, and example utterances (e.g. for id `flip-coin`: `[\"flip a coin\", \"toss a coin\", \"heads or tails\", \"flip-coin\"]`). `entry` defaults to `main.py` / `main.ts` per runtime, and `handler` defaults to `run`; both can be omitted. `invocation.command`, `input.schema`, `outputs` (only when leaving notes/reports the user reads back), `memory` (only when keeping state), `ai_steps` (only when calling AI), and `required_env` are optional. Do not write empty `outputs: []` / `ai_steps: []` / `retry: max_attempts: 0`. `schema_version` / `type` / `security` / `triggers` are deprecated.",
793
+ "- Handler signature: Python is `async def run(ctx, input)`, TS is `export async function run(ctx, input)`. `input` is a dict of `{args, trigger, sources, memory}`, and `input.args` is already validated against skill.yaml's `input.schema`.",
794
+ "- Return value: `{status: 'ok'|'skipped'|'error', title, summary, outputs, data, suggestions}`.",
795
+ "- The available ctx surface is: `log.info/warn/error`, `memory.get` (async) / `memory.set` (async), `ai.run(step_id, payload)`, `notify(args)`, and `now()`. Do not touch env or fs directly.",
796
+ "- File output is done by returning `{content, frontmatter}` under `outputs[id]`. The Runtime saves it according to skill.yaml's `outputs[].path/filename` (you can use `{{yyyy}}/{{MM}}/{{dd}}/{{date}}/{{datetime}}`; `append: true` appends). The skill itself must not open/write files. `outputs[].type` is either `markdown` or `json`.",
797
+ "- To call AI, first declare it in skill.yaml's `ai_steps` with `id / purpose / model` (optionally `optional: true`), then call `ctx.ai.run(id, payload)`. Ids that were not declared cannot be called.",
798
+ "- Environment variables must be declared in skill.yaml's `required_env: [{name, description, optional}]`. The Runtime checks them before execution and blocks the run if any are missing.",
799
+ "- For simple CRUD-style skills (add todo, list todos, mark done, etc.) where the result should be shown to the user verbatim, add `output_mode: raw` to skill.yaml. The summary then bypasses the LLM reformatting turn and is shown directly. Omit it when complex summarization/rewording is needed (the default behaviour lets the LLM format the result).",
800
+ "- Do not write recurring schedules into skill.yaml (the builder must not touch schedules.yaml). Instead, tell the user that the finished skill can be scheduled by calling the `schedule-add` built-in skill from chat or CLI (`agent-sin run schedule-add --payload '{\"id\":\"...\",\"cron\":\"min hour dom month dow\",\"skill\":\"<this-skill>\"}'`). If the user prefers to edit manually, mention that they may add `- id / cron / skill / args / approve` to the `schedules:` list in `~/.agent-sin/schedules.yaml`.",
801
+ "- Writable paths are only inside cwd: `skill.yaml` / `main.py` or `main.ts` / `README.md` / `fixtures/` / `tests` / `prompts/`. As an exception, you may add or update only the keys this skill needs in `~/.agent-sin/.env`. Do not write anywhere else outside cwd.",
802
+ "- Reading is unrestricted: feel free to read from the agent-sin source or other skills. A useful reference implementation is `~/.agent-sin/skills/memo-save/`.",
803
+ "- If you feel an urge to edit cross-cutting files (schedules.yaml, other skills, models.yaml, config.toml, etc.), do not touch them yourself. Add a one-line request in the completion report (\"please do XYZ\") and let the host handle it.",
804
+ "",
805
+ ...formatDiscordSlashGuidance(runtimeContext),
806
+ ...formatBuilderRuntimeContext(runtimeContext),
807
+ ...(profileLines.length > 0 ? ["", "# Long-term profile", ...profileLines] : []),
808
+ "",
809
+ `Skill id: ${session.skill_id}`,
810
+ `Runtime: ${state.runtime}`,
811
+ ].join("\n");
812
+ const messages = [{ role: "system", content: system }];
813
+ for (const message of state.messages) {
814
+ if (message.role === "system") {
815
+ messages.push({ role: "system", content: message.content });
816
+ continue;
817
+ }
818
+ messages.push({
819
+ role: message.role === "assistant" ? "assistant" : "user",
820
+ content: message.content,
821
+ });
822
+ }
823
+ return messages;
824
+ }
825
+ function parseBuilderFiles(text) {
826
+ // Preferred format: one fenced block per file.
827
+ // ```file:<path>
828
+ // <raw content>
829
+ // ```
830
+ // Fallbacks accept what models tend to emit naturally:
831
+ // ファイル: skill.yaml
832
+ // ```yaml
833
+ // ...
834
+ // ```
835
+ // Plus an optional summary block (```summary ... ```).
836
+ const files = {};
837
+ const filePattern = /```file:\s*([^\n`]+?)\s*\n([\s\S]*?)\n```/g;
838
+ let match;
839
+ while ((match = filePattern.exec(text)) !== null) {
840
+ const rawName = match[1].trim();
841
+ if (!rawName)
842
+ continue;
843
+ files[normalizeBuilderFilePath(rawName)] = match[2];
844
+ }
845
+ if (Object.keys(files).length === 0) {
846
+ extractLabeledFenceFiles(text, files);
847
+ }
848
+ if (Object.keys(files).length === 0) {
849
+ // Backward-compat: try the legacy ```builder-files JSON block once.
850
+ const legacy = parseLegacyBuilderFilesBlock(text);
851
+ if (legacy)
852
+ return legacy;
853
+ throw new Error(l("Builder did not return any ```file:<path> blocks.", "ビルダーが ```file:<path> ブロックを返しませんでした。"));
854
+ }
855
+ const summaryMatch = text.match(/```summary\s*\n([\s\S]*?)\n```/);
856
+ const summary = summaryMatch ? summaryMatch[1].trim() : undefined;
857
+ return { summary, files };
858
+ }
859
+ // Accept "ファイル: <path>" / "File: <path>" / "## <path>" labels followed by a
860
+ // language-tagged fenced block. Many models default to this when asked to write
861
+ // files without a strict format instruction.
862
+ function extractLabeledFenceFiles(text, files) {
863
+ const labelPattern = /(?:^|\n)[ \t#*]*(?:ファイル|File|file|FILE)\s*[::]\s*`?([^\n`]+?)`?[ \t]*\r?\n+```[a-zA-Z0-9_+\-.]*\s*\n([\s\S]*?)\n```/g;
864
+ let match;
865
+ while ((match = labelPattern.exec(text)) !== null) {
866
+ const rawName = match[1].trim();
867
+ if (!rawName)
868
+ continue;
869
+ try {
870
+ files[normalizeBuilderFilePath(rawName)] = match[2];
871
+ }
872
+ catch {
873
+ // unsafe / disallowed path — silently skip so other files still write.
874
+ }
875
+ }
876
+ }
877
+ function parseLegacyBuilderFilesBlock(text) {
878
+ const match = text.match(/```builder-files[^\n]*\n([\s\S]*?)```/i);
879
+ if (!match)
880
+ return null;
881
+ try {
882
+ const parsed = JSON.parse(extractJsonBody(match[1]));
883
+ const files = parsed.files;
884
+ if (!files || typeof files !== "object" || Array.isArray(files))
885
+ return null;
886
+ const normalized = {};
887
+ for (const [name, content] of Object.entries(files)) {
888
+ if (typeof content !== "string")
889
+ return null;
890
+ normalized[normalizeBuilderFilePath(name)] = content;
891
+ }
892
+ return {
893
+ summary: typeof parsed.summary === "string" ? parsed.summary : undefined,
894
+ files: normalized,
895
+ };
896
+ }
897
+ catch {
898
+ return null;
899
+ }
900
+ }
901
+ function extractJsonBody(content) {
902
+ // Some models prefix the fenced body with a language hint like "JSON\n" or
903
+ // "json\n" before the actual object. Strip leading non-JSON whitespace and
904
+ // optional language hints, then take from the first { to the matching }.
905
+ const trimmed = content.replace(/^\s*(?:json|JSON)\s*[\r\n]+/, "").trim();
906
+ const start = trimmed.indexOf("{");
907
+ if (start < 0)
908
+ return trimmed;
909
+ return trimmed.slice(start);
910
+ }
911
+ function normalizeBuilderFilePath(name) {
912
+ const normalized = path.posix.normalize(name.replaceAll("\\", "/"));
913
+ if (!normalized || normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
914
+ throw new Error(l(`Unsafe builder file path: ${name}`, `安全でないビルダーファイルパスです: ${name}`));
915
+ }
916
+ const allowed = normalized === "skill.yaml" ||
917
+ normalized === "main.py" ||
918
+ normalized === "main.ts" ||
919
+ normalized === "README.md" ||
920
+ normalized.startsWith("fixtures/") ||
921
+ normalized.startsWith("tests/") ||
922
+ normalized.startsWith("prompts/");
923
+ if (!allowed) {
924
+ throw new Error(l(`Builder may not write file: ${name}`, `ビルダーはこのファイルを書き込めません: ${name}`));
925
+ }
926
+ return normalized;
927
+ }
928
+ const DRAFT_IGNORE_DIRS = new Set(["__pycache__", "node_modules", ".pytest_cache", ".mypy_cache", "dist", "build"]);
929
+ const DRAFT_IGNORE_FILE_SUFFIXES = [".pyc", ".pyo"];
930
+ async function hasMinimumSkillSourceFiles(draftDir, files, runtime) {
931
+ const normalized = new Set(files.map((file) => file.replaceAll("\\", "/")));
932
+ if (!normalized.has("skill.yaml")) {
933
+ return false;
934
+ }
935
+ try {
936
+ const manifest = await loadSkillManifest(draftDir);
937
+ return normalized.has(manifest.entry.replaceAll("\\", "/"));
938
+ }
939
+ catch {
940
+ return runtime === "typescript" ? normalized.has("main.ts") : normalized.has("main.py");
941
+ }
942
+ }
943
+ function stripBuilderArtifacts(text) {
944
+ return text
945
+ .replace(/```summary\s*\n([\s\S]*?)\n```/g, "$1")
946
+ .replace(/```builder-files[^\n]*\n[\s\S]*?\n```/gi, "")
947
+ .replace(/```file:[^\n]*\n[\s\S]*?\n```/g, "")
948
+ .trim();
949
+ }
950
+ async function listDraftFiles(draftDir) {
951
+ const out = [];
952
+ async function walk(dir, rel) {
953
+ let entries;
954
+ try {
955
+ entries = await readdir(dir, { withFileTypes: true });
956
+ }
957
+ catch {
958
+ return;
959
+ }
960
+ for (const entry of entries) {
961
+ const name = entry.name;
962
+ if (name.startsWith("."))
963
+ continue;
964
+ if (DRAFT_IGNORE_DIRS.has(name))
965
+ continue;
966
+ const full = path.join(dir, name);
967
+ const rp = rel ? `${rel}/${name}` : name;
968
+ if (entry.isDirectory()) {
969
+ await walk(full, rp);
970
+ }
971
+ else if (entry.isFile()) {
972
+ if (DRAFT_IGNORE_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix)))
973
+ continue;
974
+ out.push(rp);
975
+ }
976
+ }
977
+ }
978
+ await walk(draftDir, "");
979
+ return out.sort();
980
+ }
981
+ async function snapshotMtimes(draftDir, files) {
982
+ const map = new Map();
983
+ for (const file of files) {
984
+ const t = await safeMtime(path.join(draftDir, file));
985
+ if (t !== undefined)
986
+ map.set(file, t);
987
+ }
988
+ return map;
989
+ }
990
+ async function detectWrittenFiles(draftDir, filesBefore, beforeMtimes, filesAfter) {
991
+ const written = [];
992
+ for (const file of filesAfter) {
993
+ if (!filesBefore.includes(file)) {
994
+ written.push(file);
995
+ continue;
996
+ }
997
+ const previous = beforeMtimes.get(file);
998
+ const current = await safeMtime(path.join(draftDir, file));
999
+ if (previous !== undefined && current !== undefined && current > previous) {
1000
+ written.push(file);
1001
+ }
1002
+ }
1003
+ return written.sort();
1004
+ }
1005
+ async function safeMtime(file) {
1006
+ try {
1007
+ const info = await stat(file);
1008
+ return info.mtimeMs;
1009
+ }
1010
+ catch {
1011
+ return undefined;
1012
+ }
1013
+ }
1014
+ async function writeBuilderFiles(draftDir, files) {
1015
+ const written = [];
1016
+ for (const [relative, content] of Object.entries(files)) {
1017
+ const target = path.join(draftDir, relative);
1018
+ const resolved = path.resolve(target);
1019
+ const root = path.resolve(draftDir);
1020
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
1021
+ throw new Error(l(`Builder file escaped draft dir: ${relative}`, `ビルダーファイルが下書きディレクトリの外に出ました: ${relative}`));
1022
+ }
1023
+ await mkdir(path.dirname(target), { recursive: true });
1024
+ await writeFile(target, content, "utf8");
1025
+ written.push(relative);
1026
+ }
1027
+ return written.sort();
1028
+ }
1029
+ async function writeSessionIfMissing(file, state) {
1030
+ try {
1031
+ await stat(file);
1032
+ }
1033
+ catch {
1034
+ await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
1035
+ }
1036
+ }
1037
+ async function readBuildState(session) {
1038
+ const raw = await readFile(session.session_path, "utf8");
1039
+ const parsed = JSON.parse(raw);
1040
+ return {
1041
+ ...parsed,
1042
+ messages: Array.isArray(parsed.messages) ? parsed.messages : [],
1043
+ runtime: parsed.runtime === "typescript" ? "typescript" : "python",
1044
+ status: parsed.status || "drafting",
1045
+ access_mode: parsed.access_mode === "approval" ? "approval" : "full",
1046
+ };
1047
+ }
1048
+ async function writeBuildState(session, state) {
1049
+ await writeFile(session.session_path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
1050
+ }
1051
+ async function appendBuildEvent(session, event, details) {
1052
+ await appendFile(path.join(session.logs_dir, "events.jsonl"), `${JSON.stringify({ ts: new Date().toISOString(), event, details })}\n`, "utf8");
1053
+ }
1054
+ async function readFixturePayload(session) {
1055
+ const fixture = path.join(session.draft_dir, "fixtures", "input.json");
1056
+ const parsed = await readJsonIfExists(fixture);
1057
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1058
+ return {};
1059
+ // Accept both flat args ({"text": ...}) and the wrapped form ({"args": {...}}).
1060
+ const obj = parsed;
1061
+ if ("args" in obj &&
1062
+ obj.args &&
1063
+ typeof obj.args === "object" &&
1064
+ !Array.isArray(obj.args) &&
1065
+ Object.keys(obj).length === 1) {
1066
+ return obj.args;
1067
+ }
1068
+ return obj;
1069
+ }
1070
+ async function readJsonIfExists(file) {
1071
+ try {
1072
+ const raw = await readFile(file, "utf8");
1073
+ const parsed = JSON.parse(raw);
1074
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1075
+ ? parsed
1076
+ : null;
1077
+ }
1078
+ catch {
1079
+ return null;
1080
+ }
1081
+ }
1082
+ async function writeBuilderResult(file, value) {
1083
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1084
+ }
1085
+ async function writeReview(file, input) {
1086
+ const manifest = input.validation.manifest;
1087
+ const aiSteps = manifest?.ai_steps?.map((step) => step.id).join(", ") || "none";
1088
+ const outputs = manifest?.outputs?.map((output) => `${output.id}:${output.type}`).join(", ") || "none";
1089
+ const lines = [
1090
+ "# Registration Review",
1091
+ "",
1092
+ `- skill id: ${input.skillId}`,
1093
+ `- AI steps: ${aiSteps}`,
1094
+ `- allowed outputs: ${outputs}`,
1095
+ `- local test command: agent-sin build test ${input.skillId}`,
1096
+ `- validation errors: ${input.validation.errors.length}`,
1097
+ `- validation warnings: ${input.validation.warnings.length}`,
1098
+ `- registration decision: ${input.decision}`,
1099
+ "",
1100
+ "## Summary",
1101
+ "",
1102
+ input.summary,
1103
+ "",
1104
+ "## Test Payload",
1105
+ "",
1106
+ "```json",
1107
+ JSON.stringify(input.payload, null, 2),
1108
+ "```",
1109
+ "",
1110
+ ];
1111
+ if (input.validation.errors.length > 0) {
1112
+ lines.push("## Errors", "", ...input.validation.errors.map((error) => `- ${error}`), "");
1113
+ }
1114
+ if (input.validation.warnings.length > 0) {
1115
+ lines.push("## Warnings", "", ...input.validation.warnings.map((warning) => `- ${warning}`), "");
1116
+ }
1117
+ await writeFile(file, lines.join("\n"), "utf8");
1118
+ }
1119
+ function timestampId() {
1120
+ return new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
1121
+ }
1122
+ async function writeTextIfMissing(file, text) {
1123
+ try {
1124
+ await stat(file);
1125
+ }
1126
+ catch {
1127
+ await writeFile(file, text, "utf8");
1128
+ }
1129
+ }