agent-sin 0.1.12 → 0.1.16
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.
- package/CHANGELOG.md +79 -0
- package/README.md +2 -1
- package/builtin-skills/_shared/_todo_lib.py +290 -0
- package/builtin-skills/even-g2-setup/main.ts +896 -0
- package/builtin-skills/even-g2-setup/skill.yaml +133 -0
- package/builtin-skills/memo-delete/main.py +28 -107
- package/builtin-skills/memo-delete/skill.yaml +10 -21
- package/builtin-skills/memo-index/main.py +96 -64
- package/builtin-skills/memo-index/skill.yaml +4 -10
- package/builtin-skills/memo-list/main.py +126 -72
- package/builtin-skills/memo-list/skill.yaml +8 -14
- package/builtin-skills/memo-save/main.py +191 -25
- package/builtin-skills/memo-save/skill.yaml +29 -5
- package/builtin-skills/memo-search/main.py +38 -18
- package/builtin-skills/memo-vector-search/main.py +11 -6
- package/builtin-skills/nightly-topic-knowledge/_feedback_lib.py +391 -0
- package/builtin-skills/nightly-topic-knowledge/_topics_lib.py +415 -0
- package/builtin-skills/nightly-topic-knowledge/main.py +403 -0
- package/builtin-skills/nightly-topic-knowledge/skill.yaml +88 -0
- package/builtin-skills/schedule-add/main.py +26 -0
- package/builtin-skills/service-restart/main.ts +249 -0
- package/builtin-skills/service-restart/skill.yaml +49 -0
- package/builtin-skills/todo-add/main.py +3 -1
- package/builtin-skills/todo-delete/main.py +3 -1
- package/builtin-skills/todo-done/main.py +3 -1
- package/builtin-skills/todo-list/main.py +4 -1
- package/builtin-skills/todo-tick/main.py +3 -1
- package/builtin-skills/topic-knowledge-read/main.py +118 -0
- package/builtin-skills/topic-knowledge-read/skill.yaml +49 -0
- package/dist/builder/build-action-classifier.d.ts +18 -0
- package/dist/builder/build-action-classifier.js +82 -1
- package/dist/builder/build-flow.d.ts +33 -4
- package/dist/builder/build-flow.js +251 -89
- package/dist/builder/builder-session.d.ts +1 -1
- package/dist/builder/builder-session.js +112 -7
- package/dist/builder/conversation-router.d.ts +4 -2
- package/dist/builder/conversation-router.js +19 -2
- package/dist/cli/index.js +323 -20
- package/dist/core/ai-provider.d.ts +1 -0
- package/dist/core/ai-provider.js +8 -3
- package/dist/core/chat-engine.d.ts +9 -3
- package/dist/core/chat-engine.js +1263 -146
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +82 -0
- package/dist/core/daily-memory-promotion.d.ts +7 -0
- package/dist/core/daily-memory-promotion.js +596 -18
- package/dist/core/image-attachments.d.ts +31 -0
- package/dist/core/image-attachments.js +237 -0
- package/dist/core/logger.d.ts +2 -1
- package/dist/core/logger.js +77 -1
- package/dist/core/memo-migration.d.ts +3 -0
- package/dist/core/memo-migration.js +422 -0
- package/dist/core/native-modules.d.ts +24 -0
- package/dist/core/native-modules.js +99 -0
- package/dist/core/notifier.d.ts +8 -3
- package/dist/core/notifier.js +191 -17
- package/dist/core/obsidian-vault.d.ts +19 -0
- package/dist/core/obsidian-vault.js +477 -0
- package/dist/core/operating-model.d.ts +2 -0
- package/dist/core/operating-model.js +15 -0
- package/dist/core/output-writer.d.ts +3 -2
- package/dist/core/output-writer.js +108 -7
- package/dist/core/profile-memory.js +22 -1
- package/dist/core/runtime.d.ts +2 -0
- package/dist/core/runtime.js +9 -1
- package/dist/core/secrets.d.ts +4 -0
- package/dist/core/secrets.js +34 -0
- package/dist/core/skill-history.d.ts +44 -0
- package/dist/core/skill-history.js +329 -0
- package/dist/core/skill-registry.d.ts +5 -0
- package/dist/core/skill-registry.js +11 -0
- package/dist/discord/bot.d.ts +1 -0
- package/dist/discord/bot.js +181 -10
- package/dist/even-g2/gateway.d.ts +15 -0
- package/dist/even-g2/gateway.js +868 -0
- package/dist/runtimes/codex-app-server.d.ts +5 -1
- package/dist/runtimes/codex-app-server.js +147 -8
- package/dist/runtimes/python-runner.js +82 -0
- package/dist/runtimes/typescript-runner.js +13 -1
- package/dist/skills-sdk/types.d.ts +19 -4
- package/dist/telegram/bot.d.ts +1 -0
- package/dist/telegram/bot.js +115 -7
- package/package.json +3 -1
- package/templates/even-g2-agent/README.md +83 -0
- package/templates/even-g2-agent/app.json +20 -0
- package/templates/even-g2-agent/index.html +31 -0
- package/templates/even-g2-agent/package-lock.json +1836 -0
- package/templates/even-g2-agent/package.json +22 -0
- package/templates/even-g2-agent/scripts/qr-auto.mjs +182 -0
- package/templates/even-g2-agent/src/embedded-config.ts +4 -0
- package/templates/even-g2-agent/src/main.ts +539 -0
- package/templates/even-g2-agent/src/style.css +70 -0
- package/templates/even-g2-agent/tsconfig.json +11 -0
- package/templates/skill-python/main.py +20 -2
- package/templates/skill-python/skill.yaml +9 -0
- package/templates/skill-typescript/main.ts +40 -5
- package/templates/skill-typescript/skill.yaml +9 -0
package/dist/core/chat-engine.js
CHANGED
|
@@ -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 =
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
assistantText =
|
|
102
|
-
|
|
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
|
-
|
|
120
|
-
assistantText =
|
|
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
|
-
|
|
161
|
-
const
|
|
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
|
-
?
|
|
173
|
-
:
|
|
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:
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
currentBuffer.push(display);
|
|
267
325
|
}
|
|
268
326
|
for (const savedPath of saved) {
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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 (
|
|
313
|
-
|
|
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
|
|
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
|
|
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
|
-
"-
|
|
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
|
-
"
|
|
632
|
-
"-
|
|
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
|
-
"-
|
|
635
|
-
"-
|
|
636
|
-
"- If
|
|
637
|
-
"- For
|
|
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
|
-
"-
|
|
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
|
-
"-
|
|
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)',
|
|
665
|
-
"- Do not write narration such as 'I will add it now', 'Added', or 'I will register it' together with a skill
|
|
666
|
-
"- If arguments need confirmation, do not emit a skill
|
|
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
|
|
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("
|
|
696
|
-
lines.push("
|
|
697
|
-
lines.push('{"
|
|
698
|
-
lines.push("
|
|
699
|
-
lines.push("
|
|
700
|
-
lines.push("
|
|
701
|
-
lines.push("
|
|
702
|
-
lines.push(
|
|
703
|
-
lines.
|
|
704
|
-
|
|
705
|
-
|
|
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
|
|
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(
|
|
718
|
-
|
|
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
|
|
976
|
-
return
|
|
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) {
|