agent-sin 0.1.12 → 0.1.15

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 (97) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -1
  3. package/builtin-skills/_shared/_todo_lib.py +290 -0
  4. package/builtin-skills/even-g2-setup/main.ts +896 -0
  5. package/builtin-skills/even-g2-setup/skill.yaml +133 -0
  6. package/builtin-skills/memo-delete/main.py +28 -107
  7. package/builtin-skills/memo-delete/skill.yaml +10 -21
  8. package/builtin-skills/memo-index/main.py +96 -64
  9. package/builtin-skills/memo-index/skill.yaml +4 -10
  10. package/builtin-skills/memo-list/main.py +126 -72
  11. package/builtin-skills/memo-list/skill.yaml +8 -14
  12. package/builtin-skills/memo-save/main.py +191 -25
  13. package/builtin-skills/memo-save/skill.yaml +29 -5
  14. package/builtin-skills/memo-search/main.py +38 -18
  15. package/builtin-skills/memo-vector-search/main.py +11 -6
  16. package/builtin-skills/nightly-topic-knowledge/_feedback_lib.py +391 -0
  17. package/builtin-skills/nightly-topic-knowledge/_topics_lib.py +415 -0
  18. package/builtin-skills/nightly-topic-knowledge/main.py +403 -0
  19. package/builtin-skills/nightly-topic-knowledge/skill.yaml +88 -0
  20. package/builtin-skills/schedule-add/main.py +26 -0
  21. package/builtin-skills/service-restart/main.ts +249 -0
  22. package/builtin-skills/service-restart/skill.yaml +49 -0
  23. package/builtin-skills/todo-add/main.py +3 -1
  24. package/builtin-skills/todo-delete/main.py +3 -1
  25. package/builtin-skills/todo-done/main.py +3 -1
  26. package/builtin-skills/todo-list/main.py +4 -1
  27. package/builtin-skills/todo-tick/main.py +3 -1
  28. package/builtin-skills/topic-knowledge-read/main.py +118 -0
  29. package/builtin-skills/topic-knowledge-read/skill.yaml +49 -0
  30. package/dist/builder/build-action-classifier.d.ts +18 -0
  31. package/dist/builder/build-action-classifier.js +82 -1
  32. package/dist/builder/build-flow.d.ts +33 -4
  33. package/dist/builder/build-flow.js +251 -89
  34. package/dist/builder/builder-session.d.ts +1 -1
  35. package/dist/builder/builder-session.js +112 -7
  36. package/dist/builder/conversation-router.d.ts +4 -2
  37. package/dist/builder/conversation-router.js +19 -2
  38. package/dist/cli/index.js +323 -20
  39. package/dist/core/ai-provider.d.ts +1 -0
  40. package/dist/core/ai-provider.js +8 -3
  41. package/dist/core/chat-engine.d.ts +9 -3
  42. package/dist/core/chat-engine.js +1263 -146
  43. package/dist/core/config.d.ts +4 -0
  44. package/dist/core/config.js +82 -0
  45. package/dist/core/daily-memory-promotion.d.ts +7 -0
  46. package/dist/core/daily-memory-promotion.js +568 -14
  47. package/dist/core/image-attachments.d.ts +31 -0
  48. package/dist/core/image-attachments.js +237 -0
  49. package/dist/core/logger.d.ts +2 -1
  50. package/dist/core/logger.js +77 -1
  51. package/dist/core/memo-migration.d.ts +3 -0
  52. package/dist/core/memo-migration.js +422 -0
  53. package/dist/core/native-modules.d.ts +24 -0
  54. package/dist/core/native-modules.js +99 -0
  55. package/dist/core/notifier.d.ts +8 -3
  56. package/dist/core/notifier.js +191 -17
  57. package/dist/core/obsidian-vault.d.ts +19 -0
  58. package/dist/core/obsidian-vault.js +477 -0
  59. package/dist/core/operating-model.d.ts +2 -0
  60. package/dist/core/operating-model.js +15 -0
  61. package/dist/core/output-writer.d.ts +3 -2
  62. package/dist/core/output-writer.js +108 -7
  63. package/dist/core/profile-memory.js +22 -1
  64. package/dist/core/runtime.d.ts +2 -0
  65. package/dist/core/runtime.js +9 -1
  66. package/dist/core/secrets.d.ts +4 -0
  67. package/dist/core/secrets.js +34 -0
  68. package/dist/core/skill-history.d.ts +44 -0
  69. package/dist/core/skill-history.js +329 -0
  70. package/dist/core/skill-registry.d.ts +5 -0
  71. package/dist/core/skill-registry.js +11 -0
  72. package/dist/discord/bot.d.ts +1 -0
  73. package/dist/discord/bot.js +181 -10
  74. package/dist/even-g2/gateway.d.ts +15 -0
  75. package/dist/even-g2/gateway.js +868 -0
  76. package/dist/runtimes/codex-app-server.d.ts +5 -1
  77. package/dist/runtimes/codex-app-server.js +147 -8
  78. package/dist/runtimes/python-runner.js +82 -0
  79. package/dist/runtimes/typescript-runner.js +13 -1
  80. package/dist/skills-sdk/types.d.ts +19 -4
  81. package/dist/telegram/bot.d.ts +1 -0
  82. package/dist/telegram/bot.js +115 -7
  83. package/package.json +3 -1
  84. package/templates/even-g2-agent/README.md +83 -0
  85. package/templates/even-g2-agent/app.json +20 -0
  86. package/templates/even-g2-agent/index.html +31 -0
  87. package/templates/even-g2-agent/package-lock.json +1836 -0
  88. package/templates/even-g2-agent/package.json +22 -0
  89. package/templates/even-g2-agent/scripts/qr-auto.mjs +182 -0
  90. package/templates/even-g2-agent/src/embedded-config.ts +4 -0
  91. package/templates/even-g2-agent/src/main.ts +539 -0
  92. package/templates/even-g2-agent/src/style.css +70 -0
  93. package/templates/even-g2-agent/tsconfig.json +11 -0
  94. package/templates/skill-python/main.py +20 -2
  95. package/templates/skill-python/skill.yaml +9 -0
  96. package/templates/skill-typescript/main.ts +40 -5
  97. package/templates/skill-typescript/skill.yaml +9 -0
@@ -1,19 +1,34 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { loadModels } from "./config.js";
2
- import { appendConversationLog, appendEventLog, readRunLog } from "./logger.js";
5
+ import { appendConversationLog, appendEventLog, dailyConversationMemoryFile, readRunLog, } from "./logger.js";
3
6
  import { getAiProvider, } from "./ai-provider.js";
4
7
  import { listSkillManifests } from "./skill-registry.js";
5
8
  import { runSkill, SkillRunError } from "./runtime.js";
6
9
  import { buildDraftWithAgent, prepareRepairDraft, } from "../builder/builder-session.js";
7
- import { formatProfileMemoryPromptSection, readProfileMemoryForPrompt, } from "./profile-memory.js";
10
+ import { formatProfileMemoryPromptSection, profileMemoryPath, readProfileMemoryForPrompt, } from "./profile-memory.js";
8
11
  import { maybePromoteDailyMemory } from "./daily-memory-promotion.js";
9
12
  import { l, t } from "./i18n.js";
13
+ import { agentSinOperatingModelLines } from "./operating-model.js";
10
14
  export const HISTORY_LIMIT = 20;
11
- export const TOOL_CALL_MAX_ITERATIONS = 3;
15
+ export const TOOL_CALL_MAX_ITERATIONS = 8;
12
16
  const SKILL_CALL_BLOCK = "skill-call";
13
17
  const BUILD_SUGGESTION_BLOCK = "agent-sin-build-suggestion";
14
18
  const INTERNAL_BLOCK_NAMES = [SKILL_CALL_BLOCK, BUILD_SUGGESTION_BLOCK];
15
19
  const REPAIR_FAILURE_PATTERN = /(traceback|exception|runtimeerror|syntaxerror|typeerror|nameerror|valueerror|importerror|module not found|exited with code|did not return valid json|handler not found|entry file not found|実行時|例外|エラー|失敗|読み取れません|できませんでした)/i;
16
20
  const USER_FIXABLE_FAILURE_PATTERN = /(missing required env vars|invalid input for|is disabled|skill not found|not allowed|設定してください|必要な設定|未入力|アプリパスワード|api[_ -]?key|token|credentials?)/i;
21
+ const PATH_CANDIDATE_PATTERN = /(?:~\/|\/[A-Za-z0-9._-]+\/|Users\/)[^\s"'<>`]+/g;
22
+ const MAX_FILE_CONTEXT_CHARS = 16000;
23
+ const MAX_SINGLE_FILE_CHARS = 9000;
24
+ const MAX_FILE_CONTEXT_ITEMS = 5;
25
+ const MAX_DIRECTORY_ENTRIES = 40;
26
+ const MAX_TOOL_RESULT_DATA_CHARS = 8000;
27
+ const MAX_CONVERSATION_SEARCH_CONTEXT_CHARS = 12000;
28
+ const MAX_CONVERSATION_SEARCH_FILES = 30;
29
+ const MAX_CONVERSATION_SEARCH_RESULTS = 6;
30
+ const MAX_CONVERSATION_SEARCH_TURN_CHARS = 700;
31
+ const SERVICE_RESTART_SKILL_ID = "service-restart";
17
32
  export async function chatRespond(config, userText, history, options = {}) {
18
33
  const formatNarrative = options.formatNarrative ?? ((text) => text);
19
34
  const spinner = options.spinner;
@@ -32,10 +47,14 @@ export async function chatRespond(config, userText, history, options = {}) {
32
47
  const tools = skills.filter(isToolEligible);
33
48
  await maybePromoteDailyMemory(config, { eventSource });
34
49
  const profileMemory = await readProfileMemoryForPrompt(config);
35
- const systemPrompt = buildSystemPrompt(tools, options.preferredSkillId, profileMemory);
50
+ const systemPrompt = buildSystemPrompt(tools, options.preferredSkillId, profileMemory, config);
51
+ const readOnlyFileContext = await buildReadOnlyFileContext(config, userText);
52
+ const conversationSearchContext = await buildConversationSearchContext(config, userText);
53
+ const topicKnowledgeIndex = await buildTopicKnowledgeIndexContext(config);
36
54
  const modelDisplay = await resolveDisplayModelName(config);
37
55
  const directSkillCall = resolveDirectSkillCall(tools, userText, options.preferredSkillId);
38
- let queuedAssistantText = directSkillCall ? formatSkillCallBlock(directSkillCall) : null;
56
+ let queuedAssistantText = directSkillCall ? formatAssistantEnvelope({ reply: "", skill_calls: [directSkillCall] }) : null;
57
+ const startedFromDirectTrigger = queuedAssistantText !== null;
39
58
  appendHistory(history, { role: "user", content: userText });
40
59
  const userTurnIndex = history.length - 1;
41
60
  const userImages = options.userImages || [];
@@ -61,11 +80,20 @@ export async function chatRespond(config, userText, history, options = {}) {
61
80
  return lines;
62
81
  };
63
82
  const completedCallKeys = new Set();
83
+ const localAttachmentPathSet = new Set();
84
+ const emitLocalAttachments = (paths) => {
85
+ handleLocalAttachments(paths, lines, options.onLocalAttachments || options.onGeneratedImages, localAttachmentPathSet);
86
+ };
64
87
  let lastCompletedSummary = "";
65
- let pendingRawRangeStart = null;
88
+ // Buffer of skill-result display lines from the most recent iteration that
89
+ // ran skills. Intermediate iterations replace this buffer entirely — only
90
+ // the last batch survives. At terminal we show either the LLM's reply
91
+ // (reframe) OR this buffer (passthrough), never both, to avoid the
92
+ // duplication where the skill prints its own summary AND the LLM repeats it.
93
+ let lastSkillBuffer = [];
66
94
  for (let iteration = 0; iteration < TOOL_CALL_MAX_ITERATIONS; iteration += 1) {
67
95
  let assistantText;
68
- let buildSuggestion = null;
96
+ let assistantOutput = null;
69
97
  if (queuedAssistantText) {
70
98
  assistantText = queuedAssistantText;
71
99
  queuedAssistantText = null;
@@ -89,17 +117,23 @@ export async function chatRespond(config, userText, history, options = {}) {
89
117
  try {
90
118
  const messages = [
91
119
  { role: "system", content: systemPrompt },
120
+ ...(topicKnowledgeIndex ? [{ role: "system", content: topicKnowledgeIndex }] : []),
121
+ ...(readOnlyFileContext ? [{ role: "system", content: readOnlyFileContext }] : []),
122
+ ...(conversationSearchContext ? [{ role: "system", content: conversationSearchContext }] : []),
92
123
  ...toAiMessages(history, userImages.length > 0 ? { index: userTurnIndex, images: userImages } : undefined),
93
124
  ];
94
125
  const provider = getAiProvider();
95
126
  const response = await provider(config, {
96
127
  model_id: config.chat_model_id,
97
128
  messages,
129
+ role: "chat",
130
+ cwd: config.workspace,
98
131
  onProgress: providerProgress,
99
132
  });
100
- buildSuggestion = parseBuildSuggestion(response.text);
101
- assistantText = stripBuildSuggestions(response.text);
102
- if (shouldRetryEmptyAssistantReply(assistantText, buildSuggestion)) {
133
+ emitLocalAttachments(response.generated_images);
134
+ assistantText = response.text;
135
+ assistantOutput = parseAssistantOutput(assistantText);
136
+ if (shouldRetryEmptyAssistantReply(assistantOutput, lastSkillBuffer.length > 0)) {
103
137
  await appendEventLog(config, {
104
138
  level: "warn",
105
139
  source: eventSource,
@@ -114,10 +148,13 @@ export async function chatRespond(config, userText, history, options = {}) {
114
148
  ...messages,
115
149
  { role: "system", content: emptyReplyRetryPrompt() },
116
150
  ],
151
+ role: "chat",
152
+ cwd: config.workspace,
117
153
  onProgress: providerProgress,
118
154
  });
119
- buildSuggestion = parseBuildSuggestion(retryResponse.text);
120
- assistantText = stripBuildSuggestions(retryResponse.text);
155
+ emitLocalAttachments(retryResponse.generated_images);
156
+ assistantText = retryResponse.text;
157
+ assistantOutput = parseAssistantOutput(assistantText);
121
158
  }
122
159
  catch (retryError) {
123
160
  const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
@@ -130,9 +167,9 @@ export async function chatRespond(config, userText, history, options = {}) {
130
167
  });
131
168
  }
132
169
  }
133
- if (buildSuggestion && options.onBuildSuggestion) {
170
+ if (assistantOutput.buildSuggestion && options.onBuildSuggestion) {
134
171
  try {
135
- options.onBuildSuggestion(buildSuggestion);
172
+ options.onBuildSuggestion(assistantOutput.buildSuggestion);
136
173
  }
137
174
  catch {
138
175
  // Build suggestions are optional metadata; never break chat.
@@ -157,8 +194,18 @@ export async function chatRespond(config, userText, history, options = {}) {
157
194
  if (spinner)
158
195
  spinner.stop();
159
196
  }
160
- const calls = parseSkillCalls(assistantText);
161
- const narrative = stripSkillCalls(assistantText).trim();
197
+ assistantOutput = assistantOutput || parseAssistantOutput(assistantText);
198
+ const normalizedCalls = normalizeSkillCallsForExecution(assistantOutput.skillCalls);
199
+ const calls = normalizedCalls.calls;
200
+ const narrative = assistantOutput.reply.trim();
201
+ const buildSuggestion = assistantOutput.buildSuggestion;
202
+ const assistantTextForHistory = normalizedCalls.changed
203
+ ? formatAssistantEnvelope({
204
+ reply: assistantOutput.reply,
205
+ skill_calls: calls,
206
+ build_suggestion: buildSuggestion,
207
+ })
208
+ : assistantText;
162
209
  // When the response invokes a side-effect skill (add/delete/send/save),
163
210
  // drop the LLM's narrative entirely. The deterministic skill result is
164
211
  // the only record — both for the user and for future-turn history reads.
@@ -169,35 +216,9 @@ export async function chatRespond(config, userText, history, options = {}) {
169
216
  return tool?.side_effect === true;
170
217
  });
171
218
  const recordedAssistantText = hasSideEffectCall && narrative
172
- ? extractSkillCallBlocks(assistantText)
173
- : assistantText;
219
+ ? formatAssistantEnvelope({ reply: "", skill_calls: calls, build_suggestion: buildSuggestion })
220
+ : assistantTextForHistory;
174
221
  appendHistory(history, { role: "assistant", content: recordedAssistantText });
175
- if (pendingRawRangeStart !== null) {
176
- if (calls.length > 0) {
177
- // Previous turn ran an output_mode: raw skill, but the model wants to
178
- // call another tool (e.g. todo-list -> todo-done). The intermediate
179
- // result is plumbing the user does not need to see — drop it.
180
- lines.length = pendingRawRangeStart;
181
- pendingRawRangeStart = null;
182
- }
183
- else {
184
- // Previous raw skill result is the final answer; skip the LLM's
185
- // narrative-only follow-up so the raw output stands alone.
186
- return ensureVisibleReply();
187
- }
188
- }
189
- if (narrative && !hasSideEffectCall) {
190
- lines.push(formatNarrative(narrative));
191
- }
192
- else if (!narrative && calls.length === 0 && buildSuggestion) {
193
- const prompt = buildSuggestion.type === "edit"
194
- ? l("I can fix that in build mode. Should I continue?", "ビルドモードで直せます。進めますか?")
195
- : l("I can create that in build mode. Should I continue?", "ビルドモードで作れます。進めますか?");
196
- lines.push(formatNarrative(prompt));
197
- }
198
- else if (!narrative && calls.length === 0) {
199
- lines.push(formatNarrative(emptyChatFallback()));
200
- }
201
222
  await appendEventLog(config, {
202
223
  level: "info",
203
224
  source: eventSource,
@@ -208,34 +229,69 @@ export async function chatRespond(config, userText, history, options = {}) {
208
229
  await appendConversationLog(config, {
209
230
  source: "chat",
210
231
  role: "assistant",
211
- content: assistantText,
232
+ content: assistantTextForHistory,
212
233
  model_id: config.chat_model_id,
213
234
  details: { iteration, skill_calls: calls.map((call) => call.id) },
214
235
  });
215
- if (calls.length === 0) {
216
- return ensureVisibleReply();
217
- }
218
236
  const callsToRun = calls.filter((call) => !completedCallKeys.has(skillCallKey(call)));
219
- if (callsToRun.length === 0) {
220
- if (!narrative && lastCompletedSummary) {
237
+ // Terminal iteration: either model emitted no skill calls, or it tried to
238
+ // re-run already-completed calls (model is looping). Resolve the final
239
+ // user-facing output here. The rule is: show EITHER the model's reply OR
240
+ // the buffered skill output — never both — so the user never sees the
241
+ // same content twice.
242
+ if (calls.length === 0 || callsToRun.length === 0) {
243
+ if (callsToRun.length === 0 && calls.length > 0) {
244
+ await appendEventLog(config, {
245
+ level: "info",
246
+ source: eventSource,
247
+ event: "tool_repeat_skipped",
248
+ message: `model repeated already-completed skill calls: ${calls.map((call) => call.id).join(", ")}`,
249
+ details: { model_id: config.chat_model_id, iteration },
250
+ });
251
+ }
252
+ if (startedFromDirectTrigger && lastSkillBuffer.length > 0) {
253
+ // User typed the exact skill phrase — the skill output IS the answer.
254
+ // Any model commentary on top is noise.
255
+ lines.push(...lastSkillBuffer);
256
+ }
257
+ else if (narrative && !hasSideEffectCall) {
258
+ // Model wrote a reply — it's the user-facing answer. The buffered
259
+ // skill output has already been incorporated (or intentionally
260
+ // dropped by the model).
261
+ lines.push(formatNarrative(narrative));
262
+ }
263
+ else if (lastSkillBuffer.length > 0) {
264
+ // No model commentary — passthrough the skill output.
265
+ lines.push(...lastSkillBuffer);
266
+ }
267
+ else if (lastCompletedSummary) {
268
+ // Model looped on completed calls and wrote nothing new — fall back
269
+ // to the most recent successful skill summary so the turn is not
270
+ // silent.
221
271
  lines.push(formatNarrative(lastCompletedSummary));
222
272
  }
223
- await appendEventLog(config, {
224
- level: "info",
225
- source: eventSource,
226
- event: "tool_repeat_skipped",
227
- message: `model repeated already-completed skill calls: ${calls.map((call) => call.id).join(", ")}`,
228
- details: { model_id: config.chat_model_id, iteration },
229
- });
273
+ else if (!narrative && buildSuggestion) {
274
+ const prompt = buildSuggestion.type === "edit"
275
+ ? l("I can fix that with this approach. Should I proceed?", "この内容で直しますか?")
276
+ : l("I can create that with this approach. Should I proceed?", "この内容で作りますか?");
277
+ lines.push(formatNarrative(prompt));
278
+ }
279
+ else if (!narrative) {
280
+ lines.push(formatNarrative(emptyChatFallback()));
281
+ }
230
282
  return ensureVisibleReply();
231
283
  }
232
- const rawRangeStart = lines.length;
233
- let allRawOk = callsToRun.length > 0;
284
+ // Non-terminal: skill calls present. The model's reply on this turn is
285
+ // premature (e.g. "I'll do X now") and would duplicate either the skill
286
+ // output or its own follow-up reply — drop it from the user-facing
287
+ // output. History keeps it (or strips it for side-effect skills) via the
288
+ // existing recordedAssistantText logic above.
289
+ const currentBuffer = [];
234
290
  for (const call of callsToRun) {
235
291
  const tool = tools.find((skill) => skill.id === call.id);
236
292
  if (!tool) {
237
293
  const message = `[skill not allowed: ${call.id}]`;
238
- lines.push(message);
294
+ currentBuffer.push(message);
239
295
  appendHistory(history, { role: "tool", content: toolResultJson(call.id, "error", message) });
240
296
  await appendEventLog(config, {
241
297
  level: "warn",
@@ -244,11 +300,12 @@ export async function chatRespond(config, userText, history, options = {}) {
244
300
  message,
245
301
  details: { skill_id: call.id },
246
302
  });
247
- allRawOk = false;
248
303
  continue;
249
304
  }
250
305
  const isRawMode = tool.output_mode === "raw";
251
306
  if (!isRawMode) {
307
+ // Status hint stays in lines so the user sees progress; it is not
308
+ // part of the "answer" buffer.
252
309
  lines.push(t("chat.tool_call_announce", { skill: call.id }));
253
310
  }
254
311
  const execution = await runSkillCallWithSelfRepair(config, tool, call, userText, history, {
@@ -261,12 +318,13 @@ export async function chatRespond(config, userText, history, options = {}) {
261
318
  const result = execution.response;
262
319
  const summary = [result.result.title, result.result.summary].filter(Boolean).join(" / ");
263
320
  const display = result.result.summary || result.result.title;
321
+ emitLocalAttachments(extractLocalAttachmentPathsFromSkillResult(result.result.data));
264
322
  const saved = result.saved_outputs.filter((item) => item.show_saved !== false).map((item) => item.path);
265
323
  if (display) {
266
- lines.push(display);
324
+ currentBuffer.push(display);
267
325
  }
268
326
  for (const savedPath of saved) {
269
- lines.push(`saved: ${savedPath}`);
327
+ currentBuffer.push(`saved: ${savedPath}`);
270
328
  }
271
329
  if (result.result.status === "ok") {
272
330
  completedCallKeys.add(skillCallKey(call));
@@ -274,11 +332,14 @@ export async function chatRespond(config, userText, history, options = {}) {
274
332
  lastCompletedSummary = display;
275
333
  }
276
334
  }
277
- if (!isRawMode || result.result.status !== "ok") {
278
- allRawOk = false;
335
+ const historyData = prepareToolResultDataForHistory(result.result.data);
336
+ const skillNotes = [];
337
+ for (const entry of result.ctx_logs || []) {
338
+ if (entry.level === "warn" || entry.level === "error") {
339
+ skillNotes.push({ level: entry.level, message: entry.message });
340
+ }
279
341
  }
280
- const historyData = isRawMode ? result.result.data : undefined;
281
- const historyContent = toolResultJson(call.id, result.result.status, summary, saved, historyData);
342
+ const historyContent = toolResultJson(call.id, result.result.status, summary, saved, historyData, skillNotes.length > 0 ? skillNotes : undefined);
282
343
  appendHistory(history, {
283
344
  role: "tool",
284
345
  content: historyContent,
@@ -293,7 +354,7 @@ export async function chatRespond(config, userText, history, options = {}) {
293
354
  }
294
355
  else {
295
356
  const message = execution.errorMessage;
296
- lines.push(`[skill error: ${message}]`);
357
+ currentBuffer.push(`[skill error: ${message}]`);
297
358
  appendHistory(history, { role: "tool", content: toolResultJson(call.id, "error", message) });
298
359
  await appendEventLog(config, {
299
360
  level: "error",
@@ -302,15 +363,22 @@ export async function chatRespond(config, userText, history, options = {}) {
302
363
  message,
303
364
  details: { skill_id: call.id, args: call.args },
304
365
  });
305
- allRawOk = false;
306
366
  }
307
367
  }
308
- if (allRawOk) {
309
- pendingRawRangeStart = rawRangeStart;
368
+ // Each new skill-running iteration replaces the buffer. Intermediate
369
+ // results (e.g. todo-list output when the model chains into todo-done)
370
+ // are discarded — only the most recent batch survives to the terminal
371
+ // iteration.
372
+ lastSkillBuffer = currentBuffer;
373
+ // service-restart is a one-shot: the runtime restarts after this call so
374
+ // there is no point asking the model for a follow-up reply.
375
+ if (callsToRun.some((call) => call.id === SERVICE_RESTART_SKILL_ID)) {
376
+ lines.push(...lastSkillBuffer);
377
+ return ensureVisibleReply();
310
378
  }
311
379
  }
312
- if (pendingRawRangeStart !== null) {
313
- return ensureVisibleReply();
380
+ if (lastSkillBuffer.length > 0) {
381
+ lines.push(...lastSkillBuffer);
314
382
  }
315
383
  lines.push("[tool call iterations exhausted]");
316
384
  await appendEventLog(config, {
@@ -321,6 +389,73 @@ export async function chatRespond(config, userText, history, options = {}) {
321
389
  });
322
390
  return lines;
323
391
  }
392
+ function extractLocalAttachmentPathsFromSkillResult(data) {
393
+ if (!data)
394
+ return [];
395
+ const keys = [
396
+ "filePaths",
397
+ "file_paths",
398
+ "files",
399
+ "attachmentPaths",
400
+ "attachment_paths",
401
+ "attachments",
402
+ "generated_images",
403
+ "generated_image_paths",
404
+ "generated_files",
405
+ "generated_file_paths",
406
+ "imagePaths",
407
+ "image_paths",
408
+ "images",
409
+ ];
410
+ const paths = [];
411
+ for (const key of keys) {
412
+ const value = data[key];
413
+ if (Array.isArray(value)) {
414
+ paths.push(...value.filter((item) => typeof item === "string"));
415
+ }
416
+ }
417
+ for (const key of [
418
+ "filePath",
419
+ "file_path",
420
+ "file",
421
+ "attachmentPath",
422
+ "attachment_path",
423
+ "attachment",
424
+ "generated_image",
425
+ "generated_image_path",
426
+ "generated_file",
427
+ "generated_file_path",
428
+ "imagePath",
429
+ "image_path",
430
+ "image",
431
+ ]) {
432
+ const value = data[key];
433
+ if (typeof value === "string") {
434
+ paths.push(value);
435
+ }
436
+ }
437
+ return [...new Set(paths.filter((item) => item.trim().length > 0))];
438
+ }
439
+ function handleLocalAttachments(paths, lines, onLocalAttachments, seen) {
440
+ const unique = [...new Set((paths || []).filter((item) => typeof item === "string" && item.trim()))]
441
+ .filter((item) => {
442
+ if (!seen)
443
+ return true;
444
+ if (seen.has(item))
445
+ return false;
446
+ seen.add(item);
447
+ return true;
448
+ });
449
+ if (unique.length === 0)
450
+ return;
451
+ if (onLocalAttachments) {
452
+ onLocalAttachments(unique);
453
+ return;
454
+ }
455
+ for (const filePath of unique) {
456
+ lines.push(`attachment: ${filePath}`);
457
+ }
458
+ }
324
459
  async function runSkillCallWithSelfRepair(config, tool, call, userText, history, options) {
325
460
  const firstAttempt = await attemptSkillRun(config, call, options);
326
461
  const firstFailure = failureFromAttempt(firstAttempt);
@@ -511,6 +646,20 @@ function redactSensitiveValues(value) {
511
646
  }
512
647
  return out;
513
648
  }
649
+ function prepareToolResultDataForHistory(data) {
650
+ if (!data || Object.keys(data).length === 0) {
651
+ return undefined;
652
+ }
653
+ const redacted = redactSensitiveValues(data);
654
+ const json = safeJson(redacted);
655
+ if (json.length <= MAX_TOOL_RESULT_DATA_CHARS) {
656
+ return redacted;
657
+ }
658
+ return {
659
+ _truncated: true,
660
+ _json: truncateForPrompt(json, MAX_TOOL_RESULT_DATA_CHARS),
661
+ };
662
+ }
514
663
  function shouldAttemptSelfRepair(failure) {
515
664
  if (!failure.trim())
516
665
  return false;
@@ -604,7 +753,7 @@ export function formatProgressLabel(baseLabel, event) {
604
753
  case "tool":
605
754
  return `${baseLabel} — tool: ${event.name || "?"}${event.text ? ` ${event.text}` : ""}`;
606
755
  case "message":
607
- return event.text ? `${baseLabel} — ${l("response", "応答")}: ${event.text}` : null;
756
+ return `${baseLabel} — ${l("preparing response", "応答を整理中")}`;
608
757
  case "info":
609
758
  return `${baseLabel} — ${event.text}`;
610
759
  case "stderr":
@@ -616,25 +765,59 @@ export function formatProgressLabel(baseLabel, event) {
616
765
  export function isToolEligible(skill) {
617
766
  return skill.enabled !== false;
618
767
  }
619
- export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
768
+ export function buildSystemPrompt(skills, preferredSkillId, profileMemory, config) {
769
+ const workspacePath = config?.workspace;
620
770
  const lines = [
621
771
  "You are Agent-Sin conversation mode. Talk naturally with the user and call the registered skills below when useful.",
622
- "You cannot call unregistered skills or run arbitrary CLI commands directly.",
772
+ "You cannot call unregistered skills or perform arbitrary side-effect CLI operations directly.",
623
773
  "",
624
774
  "Output language: respond in the same language as the user's most recent message. If the user wrote in Japanese, respond in Japanese; otherwise respond in English. Match the user's level of formality.",
625
775
  "",
776
+ "Agent-Sin overview:",
777
+ "- Agent-Sin is a personal AI agent that uses conversation mode for discussion and routing, and registered Program Skills for repeatable actions.",
778
+ "- Conversation mode may answer from the current conversation, long-term profile, and readable local files. It may not write files directly.",
779
+ "- Build mode is for creating or editing skills when the user explicitly wants implementation work.",
780
+ "",
781
+ ...agentSinOperatingModelLines("chat"),
782
+ "",
783
+ ...agentSinStorageGuideLines(config),
784
+ "",
626
785
  "Important constraint: conversation mode cannot rewrite files.",
627
786
  "- Conversation mode runs in a read-only sandbox. Do not try to edit, create, or delete skill.yaml, main.py, or arbitrary files.",
628
- "- If a skill needs to be fixed or a new skill should be created, do not edit it yourself. Hand it to build mode after briefly confirming the direction.",
787
+ "- Reading relevant local files is allowed in conversation mode. A dedicated skill is not required just to inspect a file that the user asked about.",
788
+ workspacePath
789
+ ? `- Your working folder for this turn is: ${workspacePath}. Start file exploration there by default, but treat every file as read-only.`
790
+ : "- Your working folder is the runtime-provided cwd. Start file exploration there by default, but treat every file as read-only.",
791
+ "- You may inspect other readable paths outside the working folder when they are relevant to the user's question. Prefer targeted reads; do not broadly scan the user's home directory or system folders without a clear reason.",
792
+ "- If a read-only filesystem context is attached as a system message, use it directly. Do not say you need a separate reader skill for those files.",
793
+ "- When you rely on filesystem context in the final reply, briefly tell the user your working folder and the relevant high-level structure or locations you inspected. Keep this note short and natural.",
794
+ "- Do not claim that conversation mode can only read the Agent-Sin workspace just because it cannot write. If the information is not in the current conversation, long-term profile, or readable files, say that specific limit.",
795
+ "- Readable signals inside the workspace include: `logs/runs/<run-id>.json` (one file per skill execution: input.args, result.summary, result.data, ctx_logs), `logs/conversations/<YYYY-MM-DD>.jsonl` (daily chat history), and `data/<skill-id>.db` (per-skill SQLite store accumulated by `ctx.history`). Open them with sqlite3 / cat / jq when they help close a gap.",
796
+ "- Before sending your final reply, do a brief self-check: does this actually answer what the user asked? If a skill returned only aggregates while the user wanted detail (or vice versa), if numbers look implausible, or if the user mentioned something the skill result does not cover, verify against the readable signals above. Either correct the reply with the missing information, or state clearly what is missing/why and propose the next step (different skill call, build-mode handoff, or one clarifying question). Don't ship a reply that you yourself wouldn't accept as an answer.",
797
+ "- Files such as .env may contain secrets. Do not quote, summarize, or reveal API keys, tokens, cookies, passwords, or credentials. At most, say whether a needed setting appears present or missing.",
798
+ "- When the user pastes credentials (API keys, OAuth client id/secret, refresh tokens, calendar ids, app passwords, webhook URLs, etc.) anywhere in a message in any format, the host extracts them and saves them to ~/.agent-sin/.env automatically — you do NOT need to call a skill for that, and you must NOT say that chat mode cannot write files or that .env cannot be updated. If the user has just supplied the missing values, treat them as saved and continue. If something is still missing, just ask for the remaining piece in one short sentence — do not refuse.",
799
+ "- If the user wants to keep configuring a particular skill (filling in env vars, tweaking behavior, fixing details), suggest they send `/build <skill-id>` to jump into that skill's build mode. Mention it as a one-line hint, not as the entire reply.",
800
+ "- If a skill needs to be fixed or a new skill should be created and implementation should start now, do not edit it yourself. Hand it to build mode after the direction is clear.",
801
+ "- If the request is still exploratory, vague, or missing material requirements, keep the conversation in chat mode. Offer a concrete proposal plus one or two short clarifying questions instead of proposing build mode.",
629
802
  "- Do not say that you identified edits but were blocked, or that you could update it if write access were available. Ask only for build-mode handoff when editing is needed.",
630
803
  "",
631
- "Response style:",
632
- "- Always respond to the user in plain text. Do not use Markdown styling, headings, bullet markers, numbered lists, inline code, fenced code blocks, links, or tables. If content is list-like, write it as plain sentences or simple line breaks. The only exception is the hidden internal fenced block described below.",
804
+ "Required response envelope:",
805
+ "- Return exactly one JSON object. Do not wrap it in Markdown fences. Do not write text before or after the JSON.",
806
+ "- The JSON object must have this shape: {\"reply\":\"visible user text\",\"skill_calls\":[],\"build_suggestion\":null}.",
807
+ "- reply is the only text shown to the user. Keep reply as plain text: no Markdown styling, headings, bullet markers, numbered lists, inline code, fenced code blocks, links, or tables. If content is list-like, write it as plain sentences or simple line breaks.",
808
+ "- skill_calls is an array of skill calls: {\"id\":\"<skill-id>\",\"args\":{...}}. Use an empty array when no skill should run.",
809
+ "- build_suggestion is null unless you are ready to create or edit a skill now. For a handoff, use {\"type\":\"create|edit\",\"skill_id\":\"<kebab-case-id>\",\"reason\":\"<short reason>\"}.",
633
810
  "- Do not volunteer command syntax or usage lists. For typo-like input, infer the intent and answer directly, or ask one short question if needed.",
634
- "- If the user is naturally asking to create a new skill or edit an existing one, do not start implementing inside conversation mode. Briefly say what should be built or changed, then end with a one-line confirmation in the user's language (e.g. 'Should I switch to build mode to fix this?' / 'ビルドモードに入って直しますか?').",
635
- "- On any turn that asks for that confirmation, or that says you are handing off to build mode, include one hidden agent-sin-build-suggestion block at the end. If you mention a handoff without the block, no handoff happens.",
636
- "- If the user has already agreed with a short approval such as 'yes', 'go ahead', 'please do it', はい, お願い, 進めて, you may proceed with the handoff, but the same response must still include the block.",
637
- "- For agent-sin-build-suggestion, use type=create for a new skill and type=edit for an existing skill. skill_id must be a short kebab-case id. For edits, use an id from the available skills list.",
811
+ "- Use build_suggestion only when the user explicitly asks to create/fix/build now (e.g. 作って, 直して, 実装して, build it, fix it), or after prior discussion has narrowed the desired behavior enough that you are asking for final confirmation.",
812
+ "- Do not use build_suggestion just because the user says they want a skill, wonders if a skill is possible, or describes an idea. Phrases like 欲しい, できる?, あるといい, would be useful, or I want should usually be treated as discussion unless they also clearly ask you to make it now.",
813
+ "- If information is insufficient, reply with a concise proposed default behavior and ask the smallest useful question. Keep build_suggestion null.",
814
+ "- For example, 'URLを読んで調べられるスキルが欲しい' is not enough for build_suggestion. Propose what the URL reader should return and ask what page types or output format matter.",
815
+ "- If the user is ready to create a new skill or edit an existing one, do not start implementing inside conversation mode. Briefly say what should be built or changed, then end with a one-line confirmation only when the user has not already given an explicit make/fix instruction.",
816
+ "- Do not tell the user only that you are switching, handing off, or passing the task to build mode. Either ask a clear confirmation such as 'この内容で直しますか?' or emit build_suggestion so the host can start implementation.",
817
+ "- When you emit build_suggestion, the reply field must still briefly summarize WHAT you will create or change (1-3 short sentences), then end with the confirmation question (e.g. 'この内容で直しますか?' / 'この内容で作りますか?'). Do not return an empty reply just because build_suggestion is set, and do not mention 'build mode', 'handoff', '渡します', or similar internal terminology in the reply.",
818
+ "- On any turn that asks for that confirmation, or that says implementation should start, put the handoff object in build_suggestion. If you mention a handoff while build_suggestion is null, no handoff happens.",
819
+ "- If the user has already agreed with a short approval such as 'yes', 'go ahead', 'please do it', はい, お願い, 進めて, or explicitly says 作って/直して, you may proceed with the handoff, but the same response must still include build_suggestion.",
820
+ "- For build_suggestion, use type=create for a new skill and type=edit for an existing skill. skill_id must be a short kebab-case id. For edits, use an id from the available skills list.",
638
821
  "- For edits, never invent umbrella ids such as 'todo'. If the request spans several available skills, explain the split briefly and ask which exact skill should be changed first.",
639
822
  "- Do not explain internal storage details such as builtin packaging, workspace copies, or override flags unless the user explicitly asks.",
640
823
  "- If the user explicitly asks for help (!help, /help, etc.), another route shows help text. You do not need to create a command list yourself.",
@@ -654,16 +837,29 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
654
837
  "- Call read/search skills only when needed to answer the user's question.",
655
838
  "",
656
839
  "Tool result handling:",
657
- "- If the immediately previous turn has a tool result (skill-result block), do not emit the same skill-call again. Use the result and give only the short conclusion in the user's language.",
840
+ "- You may chain skills within one user request. After a skill-result, decide whether another different skill is needed, using summary/data; stop once the request is complete.",
841
+ "- If the immediately previous turn has a tool result (skill-result block), do not emit the same skill call again. Use the result and give only the short conclusion in the user's language.",
658
842
  "- Do not call the same skill with the same arguments more than once for one request. Once it succeeds, stop and tell the user it is done.",
659
- "- skill-call blocks in history are past call records. Do not copy them. Emit a new call only when truly needed.",
843
+ "- skill_calls in assistant history are past call records. Do not copy them. Emit a new call only when truly needed.",
660
844
  "- skill-result JSON may include a data field in addition to summary. If a later skill call needs values such as ids, read them from data instead of asking the user.",
661
845
  "- If history already contains the same id and args with a status: ok skill-result, it has already run. Do not rerun it; just say it is already done or already registered.",
846
+ "- A skill-result may include a notes field with {level: warn|error, message} entries. Treat them as internal diagnostics from the skill run. If status is ok but notes contain error/warn entries, the skill likely degraded silently (e.g. fell back to cached data). Investigate the affected skill (open its source, fix the root cause) instead of presenting it to the user as a feature.",
847
+ "",
848
+ "Reply vs. skill output — pick exactly one, never both:",
849
+ "- After a skill returns, the user sees EITHER your reply OR the skill's output, never both. Choose deliberately so the user does not read the same content twice.",
850
+ "- If the skill's summary already directly answers the user's question (lists, schedules, reports, confirmations from save/delete/send), leave reply empty (\"\") so the skill output passes through as-is. This is the right choice for most skills marked (raw output) or (side effect).",
851
+ "- If you need to summarize, filter, pick out a value, or add interpretation, write your reply with the relevant content included. The skill's own summary will NOT be shown — incorporate the necessary details into reply yourself.",
852
+ "- Never write reply that just restates or paraphrases what the skill already said. That is the duplication we are avoiding.",
853
+ "- When chaining skills, only the final iteration's resolution matters: intermediate skill outputs are dropped automatically.",
662
854
  "",
663
855
  "How to call side-effect skills:",
664
- "- For skills marked '(side effect)', do not write a preface or completion text in the response body. Output only the skill-call block.",
665
- "- Do not write narration such as 'I will add it now', 'Added', or 'I will register it' together with a skill-call. The skill result provides the definitive completion text.",
666
- "- If arguments need confirmation, do not emit a skill-call. Ask briefly in the body, wait for yes, then emit the call in the next response.",
856
+ "- For skills marked '(side effect)', set reply to an empty string and put only the call in skill_calls.",
857
+ "- Do not write narration such as 'I will add it now', 'Added', or 'I will register it' together with a side-effect skill call. The skill result provides the definitive completion text.",
858
+ "- If arguments need confirmation, do not emit a skill call. Ask briefly in reply, wait for yes, then emit the call in the next response.",
859
+ "- When calling memo-save, include args.tags with 1-3 short Obsidian tags based on the memo content. Use no leading #, avoid generic memo/note/date tags, and prefer stable project/topic tags.",
860
+ "- If Agent-Sin itself, the bot, gateway, service, .env, or settings need a restart to apply changes, do not just tell the user to restart manually. Ask for permission in one short sentence. If the user already clearly asked for the restart, or approves after you asked, call service-restart with empty args.",
861
+ "- Never call service-restart with any other skill in the same response, and do not pass delay_ms. If the user asks to restart and then test, call only service-restart; after reconnecting the user can send the test request again.",
862
+ "- If service-restart fails or is unavailable, then give the shortest terminal fallback: agent-sin service restart.",
667
863
  "",
668
864
  "ToDo handling:",
669
865
  "- When the user wants to complete/delete a specific ToDo, first call todo-list, identify the matching item id from data.items, then call todo-done or todo-delete with that id. Do not ask the user for the id.",
@@ -682,7 +878,11 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
682
878
  }
683
879
  else {
684
880
  for (const skill of skills) {
685
- const tag = skill.side_effect ? " (side effect)" : "";
881
+ const tags = [
882
+ skill.side_effect ? "side effect" : "",
883
+ skill.output_mode === "raw" ? "raw output" : "",
884
+ ].filter(Boolean);
885
+ const tag = tags.length > 0 ? ` (${tags.join(", ")})` : "";
686
886
  lines.push(`- ${skill.id}${tag}: ${skill.description || skill.name}`);
687
887
  const phrases = skill.invocation?.phrases?.filter((p) => typeof p === "string" && p.trim().length > 0) || [];
688
888
  if (phrases.length > 0) {
@@ -692,30 +892,735 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
692
892
  }
693
893
  }
694
894
  lines.push("");
695
- lines.push("When calling a skill, include a fenced block in this format:");
696
- lines.push("```skill-call");
697
- lines.push('{"id": "<skill-id>", "args": { ... }}');
698
- lines.push("```");
699
- lines.push("Multiple blocks are allowed. Run results are passed in the next turn with the `tool` role.");
700
- lines.push("When asking to create or edit a skill, include exactly one internal block in this format. Without it, build-mode handoff will not happen:");
701
- lines.push("```agent-sin-build-suggestion");
702
- lines.push('{"type":"create|edit","skill_id":"<kebab-case-id>","reason":"<short reason>"}');
703
- lines.push("```");
704
- lines.push("Conversation mode cannot rewrite files directly. When a skill must be fixed or created, do not write it yourself; ask for confirmation with the block above and hand off to build mode after the user's approval.");
705
- lines.push("If no skill call is needed, reply naturally in the user's language.");
895
+ lines.push("Examples:");
896
+ lines.push('{"reply":"わかりました。確認します。","skill_calls":[{"id":"memo-search","args":{"query":"King Coding Project","limit":10}}],"build_suggestion":null}');
897
+ lines.push('{"reply":"URLを読むスキルなら、本文要約、重要ポイント、気になる点、関連リンクまで返す形がよさそうです。対象は通常のWebページだけでよいですか?PDFやログインが必要なページも含めますか?","skill_calls":[],"build_suggestion":null}');
898
+ lines.push('{"reply":"その内容で通知分類を直せます。この内容で直しますか?","skill_calls":[],"build_suggestion":{"type":"edit","skill_id":"gmail-organizer-ai","reason":"notification classification needs improvement"}}');
899
+ lines.push('{"reply":"了解しました。","skill_calls":[],"build_suggestion":null}');
900
+ lines.push("Multiple skill calls are allowed in skill_calls. Skill results are fed back during the same request with the `tool` role, so dependent calls such as list -> done are allowed.");
901
+ lines.push("Conversation mode cannot rewrite files directly. When a skill must be fixed or created now, do not write it yourself; use build_suggestion only after the user's intent is explicit or the requirements are clear enough for final confirmation.");
902
+ lines.push("If no skill call is needed, put the natural reply in reply, use skill_calls: [], and build_suggestion: null.");
903
+ return lines.join("\n");
904
+ }
905
+ function agentSinStorageGuideLines(config) {
906
+ const workspace = config?.workspace || "~/.agent-sin";
907
+ const memoryDir = config?.memory_dir || path.join(workspace, "memory");
908
+ const notesDir = config?.notes_dir || path.join(workspace, "notes");
909
+ const skillsDir = config?.skills_dir || path.join(workspace, "skills");
910
+ const logsDir = config?.logs_dir || path.join(workspace, "logs");
911
+ return [
912
+ "Agent-Sin storage map:",
913
+ `- Workspace root: ${workspace}`,
914
+ `- Configuration: ${path.join(workspace, "config.toml")}, ${path.join(workspace, "models.yaml")}, ${path.join(workspace, "schedules.yaml")}`,
915
+ `- Secrets: ${path.join(workspace, ".env")} (sensitive; never reveal values)`,
916
+ `- Long-term profile: ${path.join(memoryDir, "profile", "soul.md")}, ${path.join(memoryDir, "profile", "user.md")}, ${path.join(memoryDir, "profile", "memory.md")}`,
917
+ `- Recent 7-day topics are kept as a generated section inside ${path.join(memoryDir, "profile", "memory.md")}`,
918
+ `- Daily conversation memory: ${path.join(memoryDir, "daily", "YYYY", "MM", "YYYY-MM-DD.md")}`,
919
+ `- Daily memory promotion state: ${path.join(memoryDir, "daily", ".promotion-state.json")}`,
920
+ `- Skill memory: ${path.join(memoryDir, "skill-memory", "<namespace>.json")}`,
921
+ `- User skills: ${path.join(skillsDir, "<skill-id>", "skill.yaml")} and the skill's entry file`,
922
+ `- Saved notes: ${path.join(notesDir, "YYYY", "MM", "YYYY-MM-DD.md")}`,
923
+ `- Logs: ${path.join(logsDir, "conversations", "YYYY-MM-DD.jsonl")}, ${path.join(logsDir, "runs", "<run-id>.json")}, ${path.join(logsDir, "events.jsonl")}`,
924
+ "- For questions about Agent-Sin memory, notes, history, promotion, storage, or settings, use this map to decide where to inspect. The user does not need to provide exact paths.",
925
+ ];
926
+ }
927
+ async function buildReadOnlyFileContext(config, userText) {
928
+ const candidates = extractPathCandidates(userText);
929
+ const chunks = [];
930
+ let total = 0;
931
+ for (const candidate of candidates.slice(0, MAX_FILE_CONTEXT_ITEMS)) {
932
+ const chunk = await readPathContext(candidate, config);
933
+ if (!chunk) {
934
+ continue;
935
+ }
936
+ const remaining = MAX_FILE_CONTEXT_CHARS - total;
937
+ if (remaining <= 0) {
938
+ break;
939
+ }
940
+ const clipped = chunk.length > remaining ? `${chunk.slice(0, remaining)}\n... clipped ...` : chunk;
941
+ chunks.push(clipped);
942
+ total += clipped.length;
943
+ }
944
+ if (total < MAX_FILE_CONTEXT_CHARS) {
945
+ for (const chunk of await inferAgentSinContext(config, userText, candidates.length > 0)) {
946
+ const remaining = MAX_FILE_CONTEXT_CHARS - total;
947
+ if (remaining <= 0)
948
+ break;
949
+ const clipped = chunk.length > remaining ? `${chunk.slice(0, remaining)}\n... clipped ...` : chunk;
950
+ chunks.push(clipped);
951
+ total += clipped.length;
952
+ }
953
+ }
954
+ if (chunks.length === 0) {
955
+ return "";
956
+ }
957
+ return [
958
+ "Read-only filesystem context:",
959
+ `Working folder: ${config.workspace}`,
960
+ "Agent-Sin attached this context because the user's message mentioned local filesystem paths or Agent-Sin memory / notes / conversation history. No writes were performed.",
961
+ "Use this context directly. Do not say a separate skill is required to read these files.",
962
+ "",
963
+ ...chunks,
964
+ ].join("\n");
965
+ }
966
+ async function inferAgentSinContext(config, userText, hasExplicitPath) {
967
+ if (!shouldInferAgentSinContext(userText) && !hasExplicitPath) {
968
+ return [];
969
+ }
970
+ const chunks = [agentSinStorageMapContext(config)];
971
+ const text = userText.toLowerCase();
972
+ const asksProfileMemory = /(長期記憶|記憶|プロフィール|profile|memory\.md|user\.md|soul\.md|昇格|promotion|memory)/i.test(userText);
973
+ const asksDailyMemory = /(daily|日別|会話履歴|過去の会話|会話ログ|昨日|今日|一昨日|おととい|直近|最近|昇格|promotion)/i.test(userText);
974
+ const asksNotes = /(memo-list|memo|メモ|notes|ノート|保存先|保存場所)/i.test(userText);
975
+ if (asksProfileMemory) {
976
+ chunks.push(await existingFileContext(profileMemoryPath(config, "memory")));
977
+ chunks.push(await existingFileContext(profileMemoryPath(config, "user")));
978
+ chunks.push(await existingFileContext(profileMemoryPath(config, "soul")));
979
+ chunks.push(await existingFileContext(path.join(config.memory_dir, "daily", ".promotion-state.json")));
980
+ }
981
+ if (asksDailyMemory) {
982
+ chunks.push(await dailyMemoryContext(config, userText));
983
+ }
984
+ if (asksNotes && !text.includes("memory.md")) {
985
+ chunks.push(await notesContext(config));
986
+ }
987
+ return chunks.filter((chunk) => chunk.trim().length > 0);
988
+ }
989
+ function shouldInferAgentSinContext(userText) {
990
+ const domain = /(agent-sin|agentsin|長期記憶|記憶|memory|daily|日別|会話履歴|過去の会話|会話ログ|メモ|memo|notes|ノート|profile|soul|user\.md|昇格|promotion|保存先|保存場所)/i.test(userText);
991
+ const intent = /(見|読|確認|調べ|探|検索|一覧|どこ|どれ|何|内容|履歴|過去|昨日|今日|一昨日|おととい|直近|最近|昇格|保存|教えて|出して|知りたい|みれる|見れる|読める|read|find|search|where|list|show|inspect|check)/i.test(userText);
992
+ return domain && intent;
993
+ }
994
+ function agentSinStorageMapContext(config) {
995
+ return [
996
+ "<agent-sin-storage-map>",
997
+ `workspace: ${config.workspace}`,
998
+ `profile memory: ${path.join(config.memory_dir, "profile")}`,
999
+ `long-term memory: ${profileMemoryPath(config, "memory")}`,
1000
+ `user profile: ${profileMemoryPath(config, "user")}`,
1001
+ `AI profile: ${profileMemoryPath(config, "soul")}`,
1002
+ `daily conversation memory: ${path.join(config.memory_dir, "daily")}`,
1003
+ `daily promotion state: ${path.join(config.memory_dir, "daily", ".promotion-state.json")}`,
1004
+ `notes: ${config.notes_dir}`,
1005
+ `conversation logs: ${path.join(config.logs_dir, "conversations")}`,
1006
+ "</agent-sin-storage-map>",
1007
+ ].join("\n");
1008
+ }
1009
+ async function existingFileContext(file) {
1010
+ if (isSensitivePath(file)) {
1011
+ return "";
1012
+ }
1013
+ try {
1014
+ const info = await stat(file);
1015
+ if (!info.isFile())
1016
+ return "";
1017
+ return readFileContext(file);
1018
+ }
1019
+ catch {
1020
+ return `<path path="${file}">\nnot found or not readable\n</path>`;
1021
+ }
1022
+ }
1023
+ // Always-on context: the nightly-topic-knowledge skill builds a small index of
1024
+ // per-topic knowledge files; we surface the topic ids + one-line summaries on
1025
+ // every turn so the model can decide when to pull the full content via the
1026
+ // topic-knowledge-read skill. This is intentionally tiny (id + summary only)
1027
+ // so it costs almost nothing per turn.
1028
+ export async function buildTopicKnowledgeIndexContext(config) {
1029
+ const file = path.join(config.memory_dir, "topic-knowledge", "index.json");
1030
+ let raw;
1031
+ try {
1032
+ raw = await readFile(file, "utf8");
1033
+ }
1034
+ catch {
1035
+ return "";
1036
+ }
1037
+ let parsed;
1038
+ try {
1039
+ parsed = JSON.parse(raw);
1040
+ }
1041
+ catch {
1042
+ return "";
1043
+ }
1044
+ const topics = Array.isArray(parsed.topics) ? parsed.topics : [];
1045
+ const rows = [];
1046
+ for (const entry of topics) {
1047
+ if (!entry || typeof entry !== "object")
1048
+ continue;
1049
+ const tid = entry.topic_id;
1050
+ if (typeof tid !== "string" || tid.length === 0)
1051
+ continue;
1052
+ const rawSummary = entry.summary;
1053
+ const rawUpdated = entry.last_updated;
1054
+ const summary = typeof rawSummary === "string" ? rawSummary.replace(/\s+/g, " ").trim() : "";
1055
+ const clipped = summary.length > 140 ? `${summary.slice(0, 140)}…` : summary;
1056
+ const updated = typeof rawUpdated === "string" ? rawUpdated.slice(0, 10) : "";
1057
+ const tail = [clipped, updated ? `(${updated})` : ""].filter(Boolean).join(" ");
1058
+ rows.push(`- ${tid}${tail ? `: ${tail}` : ""}`);
1059
+ }
1060
+ if (rows.length === 0)
1061
+ return "";
1062
+ return [
1063
+ "<topic-knowledge-index>",
1064
+ "Distilled per-topic knowledge from past conversations. Each item is a topic_id and a one-line summary.",
1065
+ "Call the topic-knowledge-read skill with the relevant topic_ids when the user's question touches one of them; do not call it for every turn.",
1066
+ ...rows,
1067
+ "</topic-knowledge-index>",
1068
+ ].join("\n");
1069
+ }
1070
+ async function dailyMemoryContext(config, userText) {
1071
+ const targets = dailyMemoryTargetDates(userText);
1072
+ const lines = [
1073
+ "<daily-memory-context>",
1074
+ `root: ${path.join(config.memory_dir, "daily")}`,
1075
+ ];
1076
+ for (const target of targets) {
1077
+ const file = dailyConversationMemoryFile(config, target);
1078
+ lines.push("", `target: ${localDateString(target)}`, await existingFileContext(file));
1079
+ }
1080
+ const latest = await listMarkdownFiles(path.join(config.memory_dir, "daily"), 5, 4);
1081
+ lines.push("", "latest daily markdown files:");
1082
+ lines.push(...(latest.length > 0 ? latest.map((file) => `- ${file}`) : ["(none)"]));
1083
+ if (targets.length === 0 && latest[0]) {
1084
+ lines.push("", `latest file preview: ${latest[0]}`, await filePreview(latest[0]));
1085
+ }
1086
+ lines.push("</daily-memory-context>");
706
1087
  return lines.join("\n");
707
1088
  }
1089
+ function dailyMemoryTargetDates(userText) {
1090
+ const dates = [];
1091
+ const seen = new Set();
1092
+ const add = (date) => {
1093
+ const key = localDateString(date);
1094
+ if (seen.has(key))
1095
+ return;
1096
+ seen.add(key);
1097
+ dates.push(date);
1098
+ };
1099
+ for (const match of userText.matchAll(/\b(\d{4})-(\d{2})-(\d{2})\b/g)) {
1100
+ add(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
1101
+ }
1102
+ const now = new Date();
1103
+ if (/(今日|today)/i.test(userText)) {
1104
+ add(now);
1105
+ }
1106
+ if (/(昨日|前日|yesterday)/i.test(userText)) {
1107
+ const date = new Date(now);
1108
+ date.setDate(date.getDate() - 1);
1109
+ add(date);
1110
+ }
1111
+ if (/(一昨日|おととい|day before yesterday)/i.test(userText)) {
1112
+ const date = new Date(now);
1113
+ date.setDate(date.getDate() - 2);
1114
+ add(date);
1115
+ }
1116
+ return dates;
1117
+ }
1118
+ function localDateString(date) {
1119
+ const yyyy = String(date.getFullYear());
1120
+ const MM = String(date.getMonth() + 1).padStart(2, "0");
1121
+ const dd = String(date.getDate()).padStart(2, "0");
1122
+ return `${yyyy}-${MM}-${dd}`;
1123
+ }
1124
+ async function notesContext(config) {
1125
+ const latest = await listMarkdownFiles(config.notes_dir, 8, 4);
1126
+ return [
1127
+ "<notes-context>",
1128
+ `root: ${config.notes_dir}`,
1129
+ "latest markdown files:",
1130
+ ...(latest.length > 0 ? latest.map((file) => `- ${file}`) : ["(none)"]),
1131
+ "</notes-context>",
1132
+ ].join("\n");
1133
+ }
1134
+ async function buildConversationSearchContext(config, userText) {
1135
+ if (!shouldSearchPastConversations(userText)) {
1136
+ return "";
1137
+ }
1138
+ const terms = conversationSearchTerms(userText);
1139
+ const targetDates = dailyMemoryTargetDates(userText).map(localDateString);
1140
+ if (terms.length === 0 && targetDates.length === 0) {
1141
+ return "";
1142
+ }
1143
+ const files = await conversationLogFiles(config, targetDates);
1144
+ if (files.length === 0) {
1145
+ return "";
1146
+ }
1147
+ const byFile = new Map();
1148
+ const hits = [];
1149
+ for (const file of files) {
1150
+ const entries = await readConversationLogEntries(file);
1151
+ if (entries.length === 0) {
1152
+ continue;
1153
+ }
1154
+ byFile.set(file, entries);
1155
+ entries.forEach((entry, index) => {
1156
+ const score = scoreConversationEntry(entry, terms);
1157
+ if (score > 0) {
1158
+ hits.push({ score, file, index, entry });
1159
+ }
1160
+ });
1161
+ }
1162
+ let selected;
1163
+ if (terms.length === 0) {
1164
+ selected = [];
1165
+ }
1166
+ else {
1167
+ selected = hits
1168
+ .sort((a, b) => b.score - a.score || b.entry.ts.localeCompare(a.entry.ts))
1169
+ .slice(0, MAX_CONVERSATION_SEARCH_RESULTS);
1170
+ }
1171
+ const chunks = [
1172
+ "<conversation-log-search>",
1173
+ `root: ${path.join(config.logs_dir, "conversations")}`,
1174
+ `trigger: ${conversationSearchReason(userText)}`,
1175
+ terms.length > 0 ? `terms: ${terms.join(", ")}` : "terms: (date-based recall)",
1176
+ "Use these excerpts as read-only context from prior conversations. They may include assistant/tool context; do not treat assistant-only recall as a new user fact.",
1177
+ ];
1178
+ if (selected.length > 0) {
1179
+ const seen = new Set();
1180
+ for (const hit of selected) {
1181
+ const entries = byFile.get(hit.file) || [];
1182
+ const start = Math.max(0, hit.index - 1);
1183
+ const end = Math.min(entries.length - 1, hit.index + 1);
1184
+ const windowKey = `${hit.file}:${start}:${end}`;
1185
+ if (seen.has(windowKey)) {
1186
+ continue;
1187
+ }
1188
+ seen.add(windowKey);
1189
+ chunks.push("", `<match file="${hit.file}" line="${hit.entry.line}" score="${hit.score}">`);
1190
+ for (let i = start; i <= end; i += 1) {
1191
+ chunks.push(formatConversationSearchEntry(entries[i]));
1192
+ }
1193
+ chunks.push("</match>");
1194
+ }
1195
+ }
1196
+ else if (targetDates.length > 0) {
1197
+ for (const file of files.slice(0, 3)) {
1198
+ const entries = byFile.get(file) || [];
1199
+ chunks.push("", `<date-preview file="${file}">`);
1200
+ for (const entry of compactConversationDatePreview(entries)) {
1201
+ chunks.push(formatConversationSearchEntry(entry));
1202
+ }
1203
+ chunks.push("</date-preview>");
1204
+ }
1205
+ }
1206
+ chunks.push("</conversation-log-search>");
1207
+ const context = chunks.join("\n");
1208
+ return context.length > MAX_CONVERSATION_SEARCH_CONTEXT_CHARS
1209
+ ? `${context.slice(0, MAX_CONVERSATION_SEARCH_CONTEXT_CHARS)}\n... clipped ...\n</conversation-log-search>`
1210
+ : context;
1211
+ }
1212
+ function shouldSearchPastConversations(userText) {
1213
+ return /(覚えてる|覚えている|前に|以前|この前|さっき|昨日|一昨日|おととい|先日|過去|会話ログ|会話履歴|話した|話してた|なんだっけ|何だっけ|どんな話|何話した|remember|previous|earlier|yesterday|last time|conversation history)/i.test(userText);
1214
+ }
1215
+ function conversationSearchReason(userText) {
1216
+ if (/(昨日|yesterday)/i.test(userText))
1217
+ return "yesterday";
1218
+ if (/(一昨日|おととい|day before yesterday)/i.test(userText))
1219
+ return "day-before-yesterday";
1220
+ if (/(覚えてる|覚えている|remember)/i.test(userText))
1221
+ return "recall";
1222
+ return "past-conversation-reference";
1223
+ }
1224
+ function conversationSearchTerms(userText) {
1225
+ const terms = [];
1226
+ const seen = new Set();
1227
+ const add = (term) => {
1228
+ const normalized = normalizeConversationSearchTerm(term);
1229
+ if (!normalized || seen.has(normalized) || conversationSearchStopwords().has(normalized)) {
1230
+ return;
1231
+ }
1232
+ seen.add(normalized);
1233
+ terms.push(normalized);
1234
+ };
1235
+ for (const match of userText.matchAll(/[A-Za-z0-9][A-Za-z0-9._+-]*/g)) {
1236
+ add(match[0]);
1237
+ }
1238
+ for (const match of userText.matchAll(/[ぁ-んァ-ヶ一-龯ー]{2,}/g)) {
1239
+ add(match[0]);
1240
+ }
1241
+ return terms.slice(0, 8);
1242
+ }
1243
+ function normalizeConversationSearchTerm(term) {
1244
+ return term
1245
+ .toLowerCase()
1246
+ .replace(/^[\s"'`「『((]+|[\s"'`」』))、。.!?!?]+$/g, "")
1247
+ .trim();
1248
+ }
1249
+ function conversationSearchStopwords() {
1250
+ return new Set([
1251
+ "この前",
1252
+ "さっき",
1253
+ "昨日",
1254
+ "一昨日",
1255
+ "おととい",
1256
+ "先日",
1257
+ "以前",
1258
+ "過去",
1259
+ "会話",
1260
+ "会話ログ",
1261
+ "会話履歴",
1262
+ "話",
1263
+ "話した",
1264
+ "話してた",
1265
+ "覚えてる",
1266
+ "覚えている",
1267
+ "なんだっけ",
1268
+ "何だっけ",
1269
+ "どんな話",
1270
+ "何話した",
1271
+ "それ",
1272
+ "あれ",
1273
+ "これ",
1274
+ "件",
1275
+ "について",
1276
+ "して",
1277
+ "した",
1278
+ "です",
1279
+ "ます",
1280
+ "かな",
1281
+ "いい",
1282
+ ]);
1283
+ }
1284
+ async function conversationLogFiles(config, targetDates) {
1285
+ const root = path.join(config.logs_dir, "conversations");
1286
+ if (targetDates.length > 0) {
1287
+ const candidates = targetDates
1288
+ .flatMap((date) => adjacentDateStrings(date).map((key) => path.join(root, `${key}.jsonl`)));
1289
+ const existing = [];
1290
+ for (const file of candidates) {
1291
+ try {
1292
+ const info = await stat(file);
1293
+ if (info.isFile())
1294
+ existing.push(file);
1295
+ }
1296
+ catch {
1297
+ // ignore missing dates
1298
+ }
1299
+ }
1300
+ return [...new Set(existing)];
1301
+ }
1302
+ try {
1303
+ const entries = await readdir(root, { withFileTypes: true });
1304
+ return entries
1305
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1306
+ .map((entry) => path.join(root, entry.name))
1307
+ .sort((a, b) => b.localeCompare(a))
1308
+ .slice(0, MAX_CONVERSATION_SEARCH_FILES);
1309
+ }
1310
+ catch {
1311
+ return [];
1312
+ }
1313
+ }
1314
+ function adjacentDateStrings(date) {
1315
+ const [yyyy, MM, dd] = date.split("-").map((part) => Number.parseInt(part, 10));
1316
+ const center = new Date(yyyy, MM - 1, dd);
1317
+ return [-1, 0, 1].map((offset) => {
1318
+ const next = new Date(center);
1319
+ next.setDate(next.getDate() + offset);
1320
+ return localDateString(next);
1321
+ });
1322
+ }
1323
+ async function readConversationLogEntries(file) {
1324
+ let raw = "";
1325
+ try {
1326
+ raw = await readFile(file, "utf8");
1327
+ }
1328
+ catch {
1329
+ return [];
1330
+ }
1331
+ return raw
1332
+ .split(/\r?\n/)
1333
+ .map((line, index) => parseConversationLogLine(file, index + 1, line))
1334
+ .filter((entry) => Boolean(entry));
1335
+ }
1336
+ function parseConversationLogLine(file, line, raw) {
1337
+ if (!raw.trim()) {
1338
+ return null;
1339
+ }
1340
+ try {
1341
+ const parsed = JSON.parse(raw);
1342
+ const content = typeof parsed.content === "string" ? parsed.content : "";
1343
+ if (!content.trim()) {
1344
+ return null;
1345
+ }
1346
+ return {
1347
+ file,
1348
+ line,
1349
+ ts: typeof parsed.ts === "string" ? parsed.ts : "",
1350
+ source: typeof parsed.source === "string" ? parsed.source : "",
1351
+ role: typeof parsed.role === "string" ? parsed.role : "",
1352
+ content: normalizeConversationLogContent(content, typeof parsed.role === "string" ? parsed.role : ""),
1353
+ };
1354
+ }
1355
+ catch {
1356
+ return null;
1357
+ }
1358
+ }
1359
+ function normalizeConversationLogContent(content, role) {
1360
+ const trimmed = content.trim();
1361
+ if (role === "assistant") {
1362
+ const parsed = parseJsonObject(trimmed);
1363
+ if (parsed && typeof parsed.reply === "string") {
1364
+ const calls = Array.isArray(parsed.skill_calls) ? parsed.skill_calls : [];
1365
+ const callText = calls
1366
+ .map((call) => {
1367
+ if (!call || typeof call !== "object")
1368
+ return "";
1369
+ const record = call;
1370
+ return typeof record.id === "string" ? `skill:${record.id}` : "";
1371
+ })
1372
+ .filter(Boolean)
1373
+ .join(", ");
1374
+ return [parsed.reply.trim(), callText].filter(Boolean).join("\n");
1375
+ }
1376
+ }
1377
+ if (role === "tool") {
1378
+ const fenced = trimmed.match(/```skill-result\s*([\s\S]*?)```/i);
1379
+ const parsed = parseJsonObject(fenced?.[1]?.trim() || trimmed);
1380
+ if (parsed && typeof parsed.summary === "string") {
1381
+ return parsed.summary.trim();
1382
+ }
1383
+ }
1384
+ return scrubConversationSearchText(trimmed);
1385
+ }
1386
+ function scoreConversationEntry(entry, terms) {
1387
+ const content = entry.content.toLowerCase();
1388
+ let score = 0;
1389
+ for (const term of terms) {
1390
+ if (!term)
1391
+ continue;
1392
+ if (content.includes(term)) {
1393
+ score += entry.role === "user" ? 4 : 2;
1394
+ if (/^[a-z0-9._+-]+$/.test(term)) {
1395
+ score += 1;
1396
+ }
1397
+ }
1398
+ }
1399
+ return score;
1400
+ }
1401
+ function compactConversationDatePreview(entries) {
1402
+ const userEntries = entries.filter((entry) => entry.role === "user");
1403
+ if (userEntries.length <= MAX_CONVERSATION_SEARCH_RESULTS) {
1404
+ return userEntries;
1405
+ }
1406
+ const head = userEntries.slice(0, 3);
1407
+ const tail = userEntries.slice(-3);
1408
+ return [...head, ...tail];
1409
+ }
1410
+ function formatConversationSearchEntry(entry) {
1411
+ const timestamp = entry.ts ? entry.ts.replace("T", " ").replace(/\.\d{3}Z$/, "Z") : "";
1412
+ const label = [timestamp, entry.source, entry.role].filter(Boolean).join(" ");
1413
+ return [`- ${label}`, clipConversationSearchText(entry.content)].join("\n ");
1414
+ }
1415
+ function clipConversationSearchText(text) {
1416
+ const scrubbed = scrubConversationSearchText(text).replace(/\n/g, "\n ");
1417
+ if (scrubbed.length <= MAX_CONVERSATION_SEARCH_TURN_CHARS) {
1418
+ return scrubbed;
1419
+ }
1420
+ return `${scrubbed.slice(0, MAX_CONVERSATION_SEARCH_TURN_CHARS)}\n ... clipped ...`;
1421
+ }
1422
+ function scrubConversationSearchText(text) {
1423
+ return text
1424
+ .replace(/\/Users\/[^\s)]+/g, "[local path]")
1425
+ .replace(/\s+\n/g, "\n")
1426
+ .replace(/\n{3,}/g, "\n\n")
1427
+ .trim();
1428
+ }
1429
+ function extractPathCandidates(text) {
1430
+ const seen = new Set();
1431
+ const candidates = [];
1432
+ for (const match of text.matchAll(PATH_CANDIDATE_PATTERN)) {
1433
+ const normalized = normalizeMentionedPath(match[0]);
1434
+ if (!normalized || seen.has(normalized)) {
1435
+ continue;
1436
+ }
1437
+ seen.add(normalized);
1438
+ candidates.push(normalized);
1439
+ }
1440
+ return candidates;
1441
+ }
1442
+ function normalizeMentionedPath(raw) {
1443
+ let value = raw
1444
+ .trim()
1445
+ .replace(/[。、,,!?;:)\]}]+$/g, "");
1446
+ if (!value) {
1447
+ return "";
1448
+ }
1449
+ if (value.startsWith("Users/")) {
1450
+ value = `/${value}`;
1451
+ }
1452
+ if (value.startsWith("~/")) {
1453
+ value = path.join(os.homedir(), value.slice(2));
1454
+ }
1455
+ if (!path.isAbsolute(value)) {
1456
+ return "";
1457
+ }
1458
+ return path.normalize(value);
1459
+ }
1460
+ async function readPathContext(candidate, config) {
1461
+ if (containsDatePlaceholders(candidate)) {
1462
+ return readPlaceholderPathContext(candidate);
1463
+ }
1464
+ if (isSensitivePath(candidate)) {
1465
+ return `<path path="${candidate}">\nskipped: sensitive file path\n</path>`;
1466
+ }
1467
+ try {
1468
+ const info = await stat(candidate);
1469
+ if (info.isDirectory()) {
1470
+ return await readDirectoryContext(candidate);
1471
+ }
1472
+ if (info.isFile()) {
1473
+ return await readFileContext(candidate);
1474
+ }
1475
+ return `<path path="${candidate}">\nskipped: unsupported filesystem entry\n</path>`;
1476
+ }
1477
+ catch {
1478
+ const relative = path.resolve(config.workspace, candidate);
1479
+ if (relative !== candidate) {
1480
+ try {
1481
+ const info = await stat(relative);
1482
+ if (info.isFile() && !isSensitivePath(relative))
1483
+ return await readFileContext(relative);
1484
+ if (info.isDirectory())
1485
+ return await readDirectoryContext(relative);
1486
+ }
1487
+ catch {
1488
+ // fall through to missing path context
1489
+ }
1490
+ }
1491
+ return `<path path="${candidate}">\nnot found or not readable\n</path>`;
1492
+ }
1493
+ }
1494
+ function containsDatePlaceholders(value) {
1495
+ return /\bY{4}\b|\bM{2}\b|\bD{2}\b/.test(value);
1496
+ }
1497
+ async function readPlaceholderPathContext(candidate) {
1498
+ const base = placeholderBasePath(candidate);
1499
+ if (!base || isSensitivePath(base)) {
1500
+ return `<path-pattern path="${candidate}">\nskipped: unsupported placeholder path\n</path-pattern>`;
1501
+ }
1502
+ try {
1503
+ const info = await stat(base);
1504
+ if (!info.isDirectory()) {
1505
+ return `<path-pattern path="${candidate}">\nbase is not a directory: ${base}\n</path-pattern>`;
1506
+ }
1507
+ }
1508
+ catch {
1509
+ return `<path-pattern path="${candidate}">\nbase not found or not readable: ${base}\n</path-pattern>`;
1510
+ }
1511
+ const files = await listMarkdownFiles(base, 8, 4);
1512
+ const latest = files[0];
1513
+ const lines = [
1514
+ `<path-pattern path="${candidate}">`,
1515
+ `base: ${base}`,
1516
+ "matching markdown files, newest path first:",
1517
+ ...(files.length > 0 ? files.map((file) => `- ${file}`) : ["(none)"]),
1518
+ ];
1519
+ if (latest && !isSensitivePath(latest)) {
1520
+ lines.push("", `latest file preview: ${latest}`, await filePreview(latest));
1521
+ }
1522
+ lines.push("</path-pattern>");
1523
+ return lines.join("\n");
1524
+ }
1525
+ function placeholderBasePath(candidate) {
1526
+ const index = candidate.search(/(?:^|\/)(?:Y{4}|M{2}|D{2})(?:\/|\.|-|$)/);
1527
+ if (index < 0) {
1528
+ return "";
1529
+ }
1530
+ const base = candidate.slice(0, index).replace(/\/+$/g, "");
1531
+ return base || path.parse(candidate).root;
1532
+ }
1533
+ async function listMarkdownFiles(root, limit, maxDepth) {
1534
+ const files = [];
1535
+ async function walk(dir, depth) {
1536
+ if (files.length >= limit || depth < 0) {
1537
+ return;
1538
+ }
1539
+ let entries;
1540
+ try {
1541
+ entries = await readdir(dir, { withFileTypes: true });
1542
+ }
1543
+ catch {
1544
+ return;
1545
+ }
1546
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1547
+ for (const entry of entries) {
1548
+ if (files.length >= limit)
1549
+ break;
1550
+ const full = path.join(dir, entry.name);
1551
+ if (entry.isDirectory()) {
1552
+ await walk(full, depth - 1);
1553
+ }
1554
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
1555
+ files.push(full);
1556
+ }
1557
+ }
1558
+ }
1559
+ await walk(root, maxDepth);
1560
+ return files.sort((a, b) => b.localeCompare(a)).slice(0, limit);
1561
+ }
1562
+ async function readDirectoryContext(dir) {
1563
+ const entries = await readdir(dir, { withFileTypes: true });
1564
+ const names = entries
1565
+ .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
1566
+ .slice(0, MAX_DIRECTORY_ENTRIES)
1567
+ .map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
1568
+ const more = entries.length > names.length ? `\n... ${entries.length - names.length} more entries ...` : "";
1569
+ return [`<directory path="${dir}">`, ...names, `${more}</directory>`].join("\n");
1570
+ }
1571
+ async function readFileContext(file) {
1572
+ return [`<file path="${file}">`, await filePreview(file), "</file>"].join("\n");
1573
+ }
1574
+ async function filePreview(file) {
1575
+ const data = await readFile(file);
1576
+ if (data.includes(0)) {
1577
+ return "skipped: binary file";
1578
+ }
1579
+ const text = data.toString("utf8");
1580
+ return text.length > MAX_SINGLE_FILE_CHARS ? `${text.slice(0, MAX_SINGLE_FILE_CHARS)}\n... clipped ...` : text;
1581
+ }
1582
+ function isSensitivePath(file) {
1583
+ const normalized = file.toLowerCase();
1584
+ const base = path.basename(normalized);
1585
+ if (base === ".env" ||
1586
+ base.startsWith(".env.") ||
1587
+ /\.(pem|key|p12|pfx|mobileprovision)$/i.test(base)) {
1588
+ return true;
1589
+ }
1590
+ return /(^|\/)(\.ssh|\.gnupg|keychains)(\/|$)/i.test(normalized) ||
1591
+ /(secret|token|credential|password|cookie|authorization)/i.test(normalized);
1592
+ }
708
1593
  function skillCallKey(call) {
709
1594
  return `${call.id}:${stableStringify(call.args)}`;
710
1595
  }
1596
+ function normalizeSkillCallsForExecution(calls) {
1597
+ const restartCall = calls.find((call) => call.id === SERVICE_RESTART_SKILL_ID);
1598
+ if (!restartCall) {
1599
+ return { calls, changed: false };
1600
+ }
1601
+ const normalized = { id: SERVICE_RESTART_SKILL_ID, args: {} };
1602
+ const changed = calls.length !== 1 || stableStringify(restartCall.args) !== "{}";
1603
+ return { calls: [normalized], changed };
1604
+ }
711
1605
  function emptyChatFallback() {
712
1606
  return l("I could not produce a reply. Please send it once more.", "返答を作れませんでした。もう一度送ってください。");
713
1607
  }
714
1608
  function emptyReplyRetryPrompt() {
715
- return "Your previous response was empty. Use the user's latest message and the conversation context to produce a short, useful reply now in the user's language. If a skill call is required, emit a valid skill-call block. If build mode is required, include a short visible confirmation question and the hidden build-suggestion block. Do not return an empty response.";
1609
+ return [
1610
+ "Your previous response was empty.",
1611
+ "Return exactly one JSON object using the required Agent-Sin response envelope.",
1612
+ "Use the user's latest message and conversation context to produce a short, useful reply now in the user's language.",
1613
+ "If a skill call is required, put it in skill_calls. If build mode is required because the user explicitly asked to create or fix now, put the handoff in build_suggestion.",
1614
+ "Do not return an empty response.",
1615
+ ].join(" ");
716
1616
  }
717
- function shouldRetryEmptyAssistantReply(assistantText, buildSuggestion) {
718
- return !buildSuggestion && parseSkillCalls(assistantText).length === 0 && stripSkillCalls(assistantText).trim().length === 0;
1617
+ function shouldRetryEmptyAssistantReply(output, hasBufferedSkillOutput = false) {
1618
+ // When skills have already produced visible output, an empty reply is a
1619
+ // valid signal that the model wants to passthrough that output as-is.
1620
+ // Don't treat it as a broken response and don't retry.
1621
+ if (hasBufferedSkillOutput)
1622
+ return false;
1623
+ return !output.buildSuggestion && output.skillCalls.length === 0 && output.reply.trim().length === 0;
719
1624
  }
720
1625
  function stableStringify(value) {
721
1626
  if (value === null || typeof value !== "object") {
@@ -727,6 +1632,122 @@ function stableStringify(value) {
727
1632
  const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
728
1633
  return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
729
1634
  }
1635
+ function parseAssistantOutput(text) {
1636
+ const envelope = parseAssistantEnvelope(text);
1637
+ if (envelope) {
1638
+ const reply = typeof envelope.reply === "string" ? envelope.reply : "";
1639
+ const skillCalls = parseEnvelopeSkillCalls(envelope.skill_calls);
1640
+ const buildSuggestion = parseEnvelopeBuildSuggestion(envelope.build_suggestion);
1641
+ return {
1642
+ reply,
1643
+ skillCalls,
1644
+ buildSuggestion,
1645
+ rawText: text,
1646
+ structured: true,
1647
+ controlOnlyText: formatAssistantEnvelope({
1648
+ reply: "",
1649
+ skill_calls: skillCalls,
1650
+ build_suggestion: buildSuggestion,
1651
+ }),
1652
+ };
1653
+ }
1654
+ if (looksLikeStructuredEnvelopeAttempt(text)) {
1655
+ return {
1656
+ reply: "",
1657
+ skillCalls: [],
1658
+ buildSuggestion: null,
1659
+ rawText: text,
1660
+ structured: false,
1661
+ controlOnlyText: "",
1662
+ };
1663
+ }
1664
+ const buildSuggestion = parseBuildSuggestion(text);
1665
+ const skillCalls = parseSkillCalls(text);
1666
+ return {
1667
+ reply: stripInternalControlBlocks(text),
1668
+ skillCalls,
1669
+ buildSuggestion,
1670
+ rawText: text,
1671
+ structured: false,
1672
+ controlOnlyText: [
1673
+ skillCalls.length > 0 ? extractSkillCallBlocks(text) : "",
1674
+ buildSuggestion ? formatBuildSuggestionBlock(buildSuggestion) : "",
1675
+ ].filter(Boolean).join("\n"),
1676
+ };
1677
+ }
1678
+ function looksLikeStructuredEnvelopeAttempt(text) {
1679
+ const trimmed = text.trim();
1680
+ return trimmed.startsWith("{") || /^```json\s*\n/i.test(trimmed) || /^```\s*\n\s*\{/i.test(trimmed);
1681
+ }
1682
+ function parseAssistantEnvelope(text) {
1683
+ const parsed = parseJsonObject(text.trim());
1684
+ if (parsed && isAssistantEnvelopeLike(parsed)) {
1685
+ return parsed;
1686
+ }
1687
+ const fenced = extractSingleJsonFence(text.trim());
1688
+ if (!fenced) {
1689
+ return null;
1690
+ }
1691
+ const fencedParsed = parseJsonObject(fenced);
1692
+ return fencedParsed && isAssistantEnvelopeLike(fencedParsed) ? fencedParsed : null;
1693
+ }
1694
+ function isAssistantEnvelopeLike(value) {
1695
+ return "reply" in value || "skill_calls" in value || "build_suggestion" in value;
1696
+ }
1697
+ function parseJsonObject(text) {
1698
+ try {
1699
+ const parsed = JSON.parse(text);
1700
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1701
+ ? parsed
1702
+ : null;
1703
+ }
1704
+ catch {
1705
+ return null;
1706
+ }
1707
+ }
1708
+ function extractSingleJsonFence(text) {
1709
+ const match = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
1710
+ return match ? match[1].trim() : null;
1711
+ }
1712
+ function parseEnvelopeSkillCalls(value) {
1713
+ if (!Array.isArray(value)) {
1714
+ return [];
1715
+ }
1716
+ const calls = [];
1717
+ for (const item of value) {
1718
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
1719
+ continue;
1720
+ }
1721
+ const record = item;
1722
+ if (typeof record.id !== "string" || !record.id.trim()) {
1723
+ continue;
1724
+ }
1725
+ const rawArgs = record.args;
1726
+ const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs)
1727
+ ? rawArgs
1728
+ : {};
1729
+ calls.push({ id: record.id, args });
1730
+ }
1731
+ return calls;
1732
+ }
1733
+ function parseEnvelopeBuildSuggestion(value) {
1734
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1735
+ return null;
1736
+ }
1737
+ const record = value;
1738
+ if (record.type !== "create" && record.type !== "edit") {
1739
+ return null;
1740
+ }
1741
+ const skillId = sanitizeSuggestedSkillId(String(record.skill_id || ""));
1742
+ if (!skillId) {
1743
+ return null;
1744
+ }
1745
+ return {
1746
+ type: record.type,
1747
+ skill_id: skillId,
1748
+ reason: typeof record.reason === "string" ? record.reason.slice(0, 240) : undefined,
1749
+ };
1750
+ }
730
1751
  export function parseSkillCalls(text) {
731
1752
  const calls = [];
732
1753
  for (const block of findInternalControlBlocks(text, [SKILL_CALL_BLOCK])) {
@@ -785,36 +1806,75 @@ export function stripInternalControlBlocks(text) {
785
1806
  }
786
1807
  function findInternalControlBlocks(text, names) {
787
1808
  const wanted = new Set(names.map(normalizeInternalBlockName));
1809
+ return mergeInternalBlocks([
1810
+ ...findFencedInternalControlBlocks(text, wanted),
1811
+ ...findBareInternalControlBlocks(text, wanted),
1812
+ ]);
1813
+ }
1814
+ function findFencedInternalControlBlocks(text, wanted) {
1815
+ const blocks = [];
1816
+ let cursor = 0;
1817
+ while (cursor < text.length) {
1818
+ const start = nextFenceStart(text, cursor);
1819
+ if (start < 0)
1820
+ break;
1821
+ const block = readFencedInternalControlBlock(text, start, wanted);
1822
+ if (block) {
1823
+ blocks.push(block);
1824
+ cursor = block.end;
1825
+ }
1826
+ else {
1827
+ cursor = start + 1;
1828
+ }
1829
+ }
1830
+ return blocks;
1831
+ }
1832
+ function readFencedInternalControlBlock(text, fenceStart, wanted) {
1833
+ const fenceChar = text[fenceStart];
1834
+ if (fenceChar !== "`" && fenceChar !== "~")
1835
+ return null;
1836
+ const fenceLength = countRepeatedChars(text, fenceStart, fenceChar);
1837
+ if (fenceLength < 3)
1838
+ return null;
1839
+ const openingLineEnd = findLineContentEnd(text, fenceStart);
1840
+ const openingLineFullEnd = findLineFullEnd(text, openingLineEnd);
1841
+ const info = text.slice(fenceStart + fenceLength, openingLineEnd).trim();
1842
+ const inlineName = info ? normalizeInternalBlockName(info) : "";
1843
+ let name = inlineName;
1844
+ let payloadStart = openingLineFullEnd;
1845
+ if (name && !wanted.has(name)) {
1846
+ return null;
1847
+ }
1848
+ if (!name) {
1849
+ const labelLineEnd = findLineContentEnd(text, payloadStart);
1850
+ if (labelLineEnd <= payloadStart && payloadStart >= text.length) {
1851
+ return null;
1852
+ }
1853
+ const splitName = normalizeInternalBlockName(text.slice(payloadStart, labelLineEnd).trim());
1854
+ if (!wanted.has(splitName)) {
1855
+ return null;
1856
+ }
1857
+ name = splitName;
1858
+ payloadStart = findLineFullEnd(text, labelLineEnd);
1859
+ }
1860
+ const close = findClosingFence(text, payloadStart, fenceChar, fenceLength);
1861
+ const payloadEnd = close ? close.start : text.length;
1862
+ const blockEnd = close ? close.end : text.length;
1863
+ const lineStart = findLineStart(text, fenceStart);
1864
+ const leading = text.slice(lineStart, fenceStart);
1865
+ const blockStart = leading.trim() ? fenceStart : lineStart;
1866
+ return {
1867
+ name,
1868
+ payload: text.slice(payloadStart, payloadEnd).trim(),
1869
+ start: blockStart,
1870
+ end: blockEnd,
1871
+ };
1872
+ }
1873
+ function findBareInternalControlBlocks(text, wanted) {
788
1874
  const lines = splitLinesWithOffsets(text);
789
1875
  const blocks = [];
790
1876
  for (let index = 0; index < lines.length; index += 1) {
791
1877
  const line = lines[index];
792
- const fence = line.text.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z][A-Za-z0-9_-]*)?\s*$/);
793
- if (fence) {
794
- let name = normalizeInternalBlockName(fence[2] || "");
795
- let payloadStartLine = index + 1;
796
- if (!name && payloadStartLine < lines.length) {
797
- const splitName = normalizeInternalBlockName(lines[payloadStartLine].text.trim());
798
- if (wanted.has(splitName)) {
799
- name = splitName;
800
- payloadStartLine += 1;
801
- }
802
- }
803
- if (wanted.has(name)) {
804
- const closeLine = findClosingFenceLine(lines, payloadStartLine, fence[1]);
805
- const payloadStart = payloadStartLine < lines.length ? lines[payloadStartLine].start : line.end;
806
- const payloadEnd = closeLine >= 0 ? lines[closeLine].start : text.length;
807
- const end = closeLine >= 0 ? lines[closeLine].end : text.length;
808
- blocks.push({
809
- name,
810
- payload: text.slice(payloadStart, payloadEnd).trim(),
811
- start: line.start,
812
- end,
813
- });
814
- index = closeLine >= 0 ? closeLine : lines.length;
815
- continue;
816
- }
817
- }
818
1878
  const bareName = normalizeInternalBlockName(line.text.trim());
819
1879
  if (!wanted.has(bareName)) {
820
1880
  continue;
@@ -841,6 +1901,59 @@ function findInternalControlBlocks(text, names) {
841
1901
  }
842
1902
  return mergeInternalBlocks(blocks);
843
1903
  }
1904
+ function nextFenceStart(text, start) {
1905
+ const backtick = text.indexOf("```", start);
1906
+ const tilde = text.indexOf("~~~", start);
1907
+ if (backtick < 0)
1908
+ return tilde;
1909
+ if (tilde < 0)
1910
+ return backtick;
1911
+ return Math.min(backtick, tilde);
1912
+ }
1913
+ function countRepeatedChars(text, start, char) {
1914
+ let end = start;
1915
+ while (end < text.length && text[end] === char) {
1916
+ end += 1;
1917
+ }
1918
+ return end - start;
1919
+ }
1920
+ function findLineStart(text, offset) {
1921
+ const newline = text.lastIndexOf("\n", Math.max(0, offset - 1));
1922
+ return newline >= 0 ? newline + 1 : 0;
1923
+ }
1924
+ function findLineContentEnd(text, start) {
1925
+ const newline = text.indexOf("\n", start);
1926
+ const end = newline >= 0 ? newline : text.length;
1927
+ return end > start && text[end - 1] === "\r" ? end - 1 : end;
1928
+ }
1929
+ function findLineFullEnd(text, contentEnd) {
1930
+ if (contentEnd < text.length && text[contentEnd] === "\r" && text[contentEnd + 1] === "\n") {
1931
+ return contentEnd + 2;
1932
+ }
1933
+ if (contentEnd < text.length && text[contentEnd] === "\n") {
1934
+ return contentEnd + 1;
1935
+ }
1936
+ if (contentEnd + 1 < text.length && text[contentEnd + 1] === "\n" && text[contentEnd] === "\r") {
1937
+ return contentEnd + 2;
1938
+ }
1939
+ return contentEnd;
1940
+ }
1941
+ function findClosingFence(text, start, fenceChar, minLength) {
1942
+ let lineStart = start;
1943
+ while (lineStart < text.length) {
1944
+ const lineEnd = findLineContentEnd(text, lineStart);
1945
+ const trimmed = text.slice(lineStart, lineEnd).trim();
1946
+ if (trimmed.length >= minLength &&
1947
+ [...trimmed].every((item) => item === fenceChar)) {
1948
+ return { start: lineStart, end: findLineFullEnd(text, lineEnd) };
1949
+ }
1950
+ const next = findLineFullEnd(text, lineEnd);
1951
+ if (next <= lineStart)
1952
+ break;
1953
+ lineStart = next;
1954
+ }
1955
+ return null;
1956
+ }
844
1957
  function removeInternalControlBlocks(text, names) {
845
1958
  const blocks = findInternalControlBlocks(text, names);
846
1959
  if (blocks.length === 0) {
@@ -874,20 +1987,6 @@ function splitLinesWithOffsets(text) {
874
1987
  }
875
1988
  return lines;
876
1989
  }
877
- function findClosingFenceLine(lines, startLine, openingFence) {
878
- const char = openingFence[0];
879
- const minLength = openingFence.length;
880
- for (let index = startLine; index < lines.length; index += 1) {
881
- const trimmed = lines[index].text.trim();
882
- if (trimmed.length < minLength) {
883
- continue;
884
- }
885
- if ([...trimmed].every((item) => item === char)) {
886
- return index;
887
- }
888
- }
889
- return -1;
890
- }
891
1990
  function nextNonEmptyLine(lines, startLine) {
892
1991
  for (let index = startLine; index < lines.length; index += 1) {
893
1992
  if (lines[index].text.trim()) {
@@ -972,8 +2071,23 @@ function mergeInternalBlocks(blocks) {
972
2071
  function normalizeInternalBlockName(value) {
973
2072
  return value.trim().toLowerCase().replaceAll("_", "-");
974
2073
  }
975
- function formatSkillCallBlock(call) {
976
- return ["```skill-call", safeJson({ id: call.id, args: call.args }), "```"].join("\n");
2074
+ function formatAssistantEnvelope(args) {
2075
+ return safeJson({
2076
+ reply: args.reply,
2077
+ skill_calls: args.skill_calls || [],
2078
+ build_suggestion: args.build_suggestion || null,
2079
+ });
2080
+ }
2081
+ function formatBuildSuggestionBlock(suggestion) {
2082
+ return [
2083
+ "```agent-sin-build-suggestion",
2084
+ safeJson({
2085
+ type: suggestion.type,
2086
+ skill_id: suggestion.skill_id,
2087
+ reason: suggestion.reason,
2088
+ }),
2089
+ "```",
2090
+ ].join("\n");
977
2091
  }
978
2092
  function resolveDirectSkillCall(skills, userText, preferredSkillId) {
979
2093
  const normalizedText = normalizeSkillTrigger(userText);
@@ -1028,11 +2142,14 @@ function sanitizeSuggestedSkillId(raw) {
1028
2142
  .replace(/^-+|-+$/g, "")
1029
2143
  .slice(0, 48);
1030
2144
  }
1031
- export function toolResultJson(id, status, summary, saved = [], data) {
2145
+ export function toolResultJson(id, status, summary, saved = [], data, notes) {
1032
2146
  const payload = { id, status, summary, saved };
1033
2147
  if (data !== undefined) {
1034
2148
  payload.data = data;
1035
2149
  }
2150
+ if (notes && notes.length > 0) {
2151
+ payload.notes = notes;
2152
+ }
1036
2153
  return ["```skill-result", JSON.stringify(payload), "```"].join("\n");
1037
2154
  }
1038
2155
  export function toAiMessages(history, multimodalTurn) {