agent-sin 0.1.11 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +179 -0
- package/builtin-skills/memo-list/skill.yaml +51 -0
- 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 +10 -3
- package/dist/core/chat-engine.js +1563 -197
- 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 +568 -14
- 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 +13 -0
- package/dist/discord/bot.js +542 -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 +122 -31
- 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,18 +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 =
|
|
12
|
-
const
|
|
13
|
-
const
|
|
15
|
+
export const TOOL_CALL_MAX_ITERATIONS = 8;
|
|
16
|
+
const SKILL_CALL_BLOCK = "skill-call";
|
|
17
|
+
const BUILD_SUGGESTION_BLOCK = "agent-sin-build-suggestion";
|
|
18
|
+
const INTERNAL_BLOCK_NAMES = [SKILL_CALL_BLOCK, BUILD_SUGGESTION_BLOCK];
|
|
14
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;
|
|
15
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";
|
|
16
32
|
export async function chatRespond(config, userText, history, options = {}) {
|
|
17
33
|
const formatNarrative = options.formatNarrative ?? ((text) => text);
|
|
18
34
|
const spinner = options.spinner;
|
|
@@ -31,8 +47,14 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
31
47
|
const tools = skills.filter(isToolEligible);
|
|
32
48
|
await maybePromoteDailyMemory(config, { eventSource });
|
|
33
49
|
const profileMemory = await readProfileMemoryForPrompt(config);
|
|
34
|
-
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);
|
|
35
54
|
const modelDisplay = await resolveDisplayModelName(config);
|
|
55
|
+
const directSkillCall = resolveDirectSkillCall(tools, userText, options.preferredSkillId);
|
|
56
|
+
let queuedAssistantText = directSkillCall ? formatAssistantEnvelope({ reply: "", skill_calls: [directSkillCall] }) : null;
|
|
57
|
+
const startedFromDirectTrigger = queuedAssistantText !== null;
|
|
36
58
|
appendHistory(history, { role: "user", content: userText });
|
|
37
59
|
const userTurnIndex = history.length - 1;
|
|
38
60
|
const userImages = options.userImages || [];
|
|
@@ -58,98 +80,132 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
58
80
|
return lines;
|
|
59
81
|
};
|
|
60
82
|
const completedCallKeys = new Set();
|
|
83
|
+
const localAttachmentPathSet = new Set();
|
|
84
|
+
const emitLocalAttachments = (paths) => {
|
|
85
|
+
handleLocalAttachments(paths, lines, options.onLocalAttachments || options.onGeneratedImages, localAttachmentPathSet);
|
|
86
|
+
};
|
|
61
87
|
let lastCompletedSummary = "";
|
|
62
|
-
|
|
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 = [];
|
|
63
94
|
for (let iteration = 0; iteration < TOOL_CALL_MAX_ITERATIONS; iteration += 1) {
|
|
64
95
|
let assistantText;
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
96
|
+
let assistantOutput = null;
|
|
97
|
+
if (queuedAssistantText) {
|
|
98
|
+
assistantText = queuedAssistantText;
|
|
99
|
+
queuedAssistantText = null;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const baseLabel = `${modelDisplay}: ${t("spinner.thinking")}`;
|
|
103
|
+
if (spinner)
|
|
104
|
+
spinner.start(baseLabel);
|
|
105
|
+
emitProgress({ kind: "thinking", iteration });
|
|
106
|
+
const spinnerProgress = spinner ? makeSpinnerProgress(spinner, baseLabel) : null;
|
|
107
|
+
const providerProgress = spinnerProgress || options.onAiProgress
|
|
108
|
+
? (event) => {
|
|
109
|
+
if (spinnerProgress) {
|
|
110
|
+
spinnerProgress(event);
|
|
111
|
+
}
|
|
112
|
+
if (options.onAiProgress) {
|
|
113
|
+
options.onAiProgress(event);
|
|
114
|
+
}
|
|
78
115
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
await appendEventLog(config, {
|
|
96
|
-
level: "warn",
|
|
97
|
-
source: eventSource,
|
|
98
|
-
event: "empty_model_reply_retry",
|
|
99
|
-
message: "model returned an empty chat reply; retrying once",
|
|
100
|
-
details: { model_id: config.chat_model_id, iteration },
|
|
116
|
+
: undefined;
|
|
117
|
+
try {
|
|
118
|
+
const messages = [
|
|
119
|
+
{ role: "system", content: systemPrompt },
|
|
120
|
+
...(topicKnowledgeIndex ? [{ role: "system", content: topicKnowledgeIndex }] : []),
|
|
121
|
+
...(readOnlyFileContext ? [{ role: "system", content: readOnlyFileContext }] : []),
|
|
122
|
+
...(conversationSearchContext ? [{ role: "system", content: conversationSearchContext }] : []),
|
|
123
|
+
...toAiMessages(history, userImages.length > 0 ? { index: userTurnIndex, images: userImages } : undefined),
|
|
124
|
+
];
|
|
125
|
+
const provider = getAiProvider();
|
|
126
|
+
const response = await provider(config, {
|
|
127
|
+
model_id: config.chat_model_id,
|
|
128
|
+
messages,
|
|
129
|
+
role: "chat",
|
|
130
|
+
cwd: config.workspace,
|
|
131
|
+
onProgress: providerProgress,
|
|
101
132
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
...messages,
|
|
107
|
-
{ role: "system", content: emptyReplyRetryPrompt() },
|
|
108
|
-
],
|
|
109
|
-
onProgress: providerProgress,
|
|
110
|
-
});
|
|
111
|
-
buildSuggestion = parseBuildSuggestion(retryResponse.text);
|
|
112
|
-
assistantText = stripBuildSuggestions(retryResponse.text);
|
|
113
|
-
}
|
|
114
|
-
catch (retryError) {
|
|
115
|
-
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
|
|
133
|
+
emitLocalAttachments(response.generated_images);
|
|
134
|
+
assistantText = response.text;
|
|
135
|
+
assistantOutput = parseAssistantOutput(assistantText);
|
|
136
|
+
if (shouldRetryEmptyAssistantReply(assistantOutput, lastSkillBuffer.length > 0)) {
|
|
116
137
|
await appendEventLog(config, {
|
|
117
138
|
level: "warn",
|
|
118
139
|
source: eventSource,
|
|
119
|
-
event: "
|
|
120
|
-
message:
|
|
140
|
+
event: "empty_model_reply_retry",
|
|
141
|
+
message: "model returned an empty chat reply; retrying once",
|
|
121
142
|
details: { model_id: config.chat_model_id, iteration },
|
|
122
143
|
});
|
|
144
|
+
try {
|
|
145
|
+
const retryResponse = await provider(config, {
|
|
146
|
+
model_id: config.chat_model_id,
|
|
147
|
+
messages: [
|
|
148
|
+
...messages,
|
|
149
|
+
{ role: "system", content: emptyReplyRetryPrompt() },
|
|
150
|
+
],
|
|
151
|
+
role: "chat",
|
|
152
|
+
cwd: config.workspace,
|
|
153
|
+
onProgress: providerProgress,
|
|
154
|
+
});
|
|
155
|
+
emitLocalAttachments(retryResponse.generated_images);
|
|
156
|
+
assistantText = retryResponse.text;
|
|
157
|
+
assistantOutput = parseAssistantOutput(assistantText);
|
|
158
|
+
}
|
|
159
|
+
catch (retryError) {
|
|
160
|
+
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
|
|
161
|
+
await appendEventLog(config, {
|
|
162
|
+
level: "warn",
|
|
163
|
+
source: eventSource,
|
|
164
|
+
event: "empty_model_reply_retry_failed",
|
|
165
|
+
message: retryMessage,
|
|
166
|
+
details: { model_id: config.chat_model_id, iteration },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
123
169
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
170
|
+
if (assistantOutput.buildSuggestion && options.onBuildSuggestion) {
|
|
171
|
+
try {
|
|
172
|
+
options.onBuildSuggestion(assistantOutput.buildSuggestion);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Build suggestions are optional metadata; never break chat.
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
}
|
|
133
|
-
|
|
134
|
-
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (spinner)
|
|
181
|
+
spinner.stop();
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
history.pop();
|
|
184
|
+
await appendEventLog(config, {
|
|
185
|
+
level: "error",
|
|
186
|
+
source: eventSource,
|
|
187
|
+
event: "model_failed",
|
|
188
|
+
message,
|
|
189
|
+
details: { model_id: config.chat_model_id },
|
|
190
|
+
});
|
|
191
|
+
emitProgress({ kind: "model_failed", message });
|
|
192
|
+
return [t("chat.model_unreachable", { model: config.chat_model_id, message })];
|
|
193
|
+
}
|
|
135
194
|
if (spinner)
|
|
136
195
|
spinner.stop();
|
|
137
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
-
history.pop();
|
|
139
|
-
await appendEventLog(config, {
|
|
140
|
-
level: "error",
|
|
141
|
-
source: eventSource,
|
|
142
|
-
event: "model_failed",
|
|
143
|
-
message,
|
|
144
|
-
details: { model_id: config.chat_model_id },
|
|
145
|
-
});
|
|
146
|
-
emitProgress({ kind: "model_failed", message });
|
|
147
|
-
return [t("chat.model_unreachable", { model: config.chat_model_id, message })];
|
|
148
196
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const calls =
|
|
152
|
-
const narrative =
|
|
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;
|
|
153
209
|
// When the response invokes a side-effect skill (add/delete/send/save),
|
|
154
210
|
// drop the LLM's narrative entirely. The deterministic skill result is
|
|
155
211
|
// the only record — both for the user and for future-turn history reads.
|
|
@@ -160,35 +216,9 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
160
216
|
return tool?.side_effect === true;
|
|
161
217
|
});
|
|
162
218
|
const recordedAssistantText = hasSideEffectCall && narrative
|
|
163
|
-
?
|
|
164
|
-
:
|
|
219
|
+
? formatAssistantEnvelope({ reply: "", skill_calls: calls, build_suggestion: buildSuggestion })
|
|
220
|
+
: assistantTextForHistory;
|
|
165
221
|
appendHistory(history, { role: "assistant", content: recordedAssistantText });
|
|
166
|
-
if (pendingRawRangeStart !== null) {
|
|
167
|
-
if (calls.length > 0) {
|
|
168
|
-
// Previous turn ran an output_mode: raw skill, but the model wants to
|
|
169
|
-
// call another tool (e.g. todo-list -> todo-done). The intermediate
|
|
170
|
-
// result is plumbing the user does not need to see — drop it.
|
|
171
|
-
lines.length = pendingRawRangeStart;
|
|
172
|
-
pendingRawRangeStart = null;
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
// Previous raw skill result is the final answer; skip the LLM's
|
|
176
|
-
// narrative-only follow-up so the raw output stands alone.
|
|
177
|
-
return ensureVisibleReply();
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
if (narrative && !hasSideEffectCall) {
|
|
181
|
-
lines.push(formatNarrative(narrative));
|
|
182
|
-
}
|
|
183
|
-
else if (!narrative && calls.length === 0 && buildSuggestion) {
|
|
184
|
-
const prompt = buildSuggestion.type === "edit"
|
|
185
|
-
? l("I can fix that in build mode. Should I continue?", "ビルドモードで直せます。進めますか?")
|
|
186
|
-
: l("I can create that in build mode. Should I continue?", "ビルドモードで作れます。進めますか?");
|
|
187
|
-
lines.push(formatNarrative(prompt));
|
|
188
|
-
}
|
|
189
|
-
else if (!narrative && calls.length === 0) {
|
|
190
|
-
lines.push(formatNarrative(emptyChatFallback()));
|
|
191
|
-
}
|
|
192
222
|
await appendEventLog(config, {
|
|
193
223
|
level: "info",
|
|
194
224
|
source: eventSource,
|
|
@@ -199,34 +229,69 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
199
229
|
await appendConversationLog(config, {
|
|
200
230
|
source: "chat",
|
|
201
231
|
role: "assistant",
|
|
202
|
-
content:
|
|
232
|
+
content: assistantTextForHistory,
|
|
203
233
|
model_id: config.chat_model_id,
|
|
204
234
|
details: { iteration, skill_calls: calls.map((call) => call.id) },
|
|
205
235
|
});
|
|
206
|
-
if (calls.length === 0) {
|
|
207
|
-
return ensureVisibleReply();
|
|
208
|
-
}
|
|
209
236
|
const callsToRun = calls.filter((call) => !completedCallKeys.has(skillCallKey(call)));
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
212
271
|
lines.push(formatNarrative(lastCompletedSummary));
|
|
213
272
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|
|
221
282
|
return ensureVisibleReply();
|
|
222
283
|
}
|
|
223
|
-
|
|
224
|
-
|
|
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 = [];
|
|
225
290
|
for (const call of callsToRun) {
|
|
226
291
|
const tool = tools.find((skill) => skill.id === call.id);
|
|
227
292
|
if (!tool) {
|
|
228
293
|
const message = `[skill not allowed: ${call.id}]`;
|
|
229
|
-
|
|
294
|
+
currentBuffer.push(message);
|
|
230
295
|
appendHistory(history, { role: "tool", content: toolResultJson(call.id, "error", message) });
|
|
231
296
|
await appendEventLog(config, {
|
|
232
297
|
level: "warn",
|
|
@@ -235,11 +300,12 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
235
300
|
message,
|
|
236
301
|
details: { skill_id: call.id },
|
|
237
302
|
});
|
|
238
|
-
allRawOk = false;
|
|
239
303
|
continue;
|
|
240
304
|
}
|
|
241
305
|
const isRawMode = tool.output_mode === "raw";
|
|
242
306
|
if (!isRawMode) {
|
|
307
|
+
// Status hint stays in lines so the user sees progress; it is not
|
|
308
|
+
// part of the "answer" buffer.
|
|
243
309
|
lines.push(t("chat.tool_call_announce", { skill: call.id }));
|
|
244
310
|
}
|
|
245
311
|
const execution = await runSkillCallWithSelfRepair(config, tool, call, userText, history, {
|
|
@@ -252,12 +318,13 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
252
318
|
const result = execution.response;
|
|
253
319
|
const summary = [result.result.title, result.result.summary].filter(Boolean).join(" / ");
|
|
254
320
|
const display = result.result.summary || result.result.title;
|
|
321
|
+
emitLocalAttachments(extractLocalAttachmentPathsFromSkillResult(result.result.data));
|
|
255
322
|
const saved = result.saved_outputs.filter((item) => item.show_saved !== false).map((item) => item.path);
|
|
256
323
|
if (display) {
|
|
257
|
-
|
|
324
|
+
currentBuffer.push(display);
|
|
258
325
|
}
|
|
259
326
|
for (const savedPath of saved) {
|
|
260
|
-
|
|
327
|
+
currentBuffer.push(`saved: ${savedPath}`);
|
|
261
328
|
}
|
|
262
329
|
if (result.result.status === "ok") {
|
|
263
330
|
completedCallKeys.add(skillCallKey(call));
|
|
@@ -265,11 +332,14 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
265
332
|
lastCompletedSummary = display;
|
|
266
333
|
}
|
|
267
334
|
}
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|
|
270
341
|
}
|
|
271
|
-
const
|
|
272
|
-
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);
|
|
273
343
|
appendHistory(history, {
|
|
274
344
|
role: "tool",
|
|
275
345
|
content: historyContent,
|
|
@@ -284,7 +354,7 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
284
354
|
}
|
|
285
355
|
else {
|
|
286
356
|
const message = execution.errorMessage;
|
|
287
|
-
|
|
357
|
+
currentBuffer.push(`[skill error: ${message}]`);
|
|
288
358
|
appendHistory(history, { role: "tool", content: toolResultJson(call.id, "error", message) });
|
|
289
359
|
await appendEventLog(config, {
|
|
290
360
|
level: "error",
|
|
@@ -293,15 +363,22 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
293
363
|
message,
|
|
294
364
|
details: { skill_id: call.id, args: call.args },
|
|
295
365
|
});
|
|
296
|
-
allRawOk = false;
|
|
297
366
|
}
|
|
298
367
|
}
|
|
299
|
-
|
|
300
|
-
|
|
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();
|
|
301
378
|
}
|
|
302
379
|
}
|
|
303
|
-
if (
|
|
304
|
-
|
|
380
|
+
if (lastSkillBuffer.length > 0) {
|
|
381
|
+
lines.push(...lastSkillBuffer);
|
|
305
382
|
}
|
|
306
383
|
lines.push("[tool call iterations exhausted]");
|
|
307
384
|
await appendEventLog(config, {
|
|
@@ -312,6 +389,73 @@ export async function chatRespond(config, userText, history, options = {}) {
|
|
|
312
389
|
});
|
|
313
390
|
return lines;
|
|
314
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
|
+
}
|
|
315
459
|
async function runSkillCallWithSelfRepair(config, tool, call, userText, history, options) {
|
|
316
460
|
const firstAttempt = await attemptSkillRun(config, call, options);
|
|
317
461
|
const firstFailure = failureFromAttempt(firstAttempt);
|
|
@@ -502,6 +646,20 @@ function redactSensitiveValues(value) {
|
|
|
502
646
|
}
|
|
503
647
|
return out;
|
|
504
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
|
+
}
|
|
505
663
|
function shouldAttemptSelfRepair(failure) {
|
|
506
664
|
if (!failure.trim())
|
|
507
665
|
return false;
|
|
@@ -595,7 +753,7 @@ export function formatProgressLabel(baseLabel, event) {
|
|
|
595
753
|
case "tool":
|
|
596
754
|
return `${baseLabel} — tool: ${event.name || "?"}${event.text ? ` ${event.text}` : ""}`;
|
|
597
755
|
case "message":
|
|
598
|
-
return
|
|
756
|
+
return `${baseLabel} — ${l("preparing response", "応答を整理中")}`;
|
|
599
757
|
case "info":
|
|
600
758
|
return `${baseLabel} — ${event.text}`;
|
|
601
759
|
case "stderr":
|
|
@@ -607,25 +765,59 @@ export function formatProgressLabel(baseLabel, event) {
|
|
|
607
765
|
export function isToolEligible(skill) {
|
|
608
766
|
return skill.enabled !== false;
|
|
609
767
|
}
|
|
610
|
-
export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
|
|
768
|
+
export function buildSystemPrompt(skills, preferredSkillId, profileMemory, config) {
|
|
769
|
+
const workspacePath = config?.workspace;
|
|
611
770
|
const lines = [
|
|
612
771
|
"You are Agent-Sin conversation mode. Talk naturally with the user and call the registered skills below when useful.",
|
|
613
|
-
"You cannot call unregistered skills or
|
|
772
|
+
"You cannot call unregistered skills or perform arbitrary side-effect CLI operations directly.",
|
|
614
773
|
"",
|
|
615
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.",
|
|
616
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
|
+
"",
|
|
617
785
|
"Important constraint: conversation mode cannot rewrite files.",
|
|
618
786
|
"- Conversation mode runs in a read-only sandbox. Do not try to edit, create, or delete skill.yaml, main.py, or arbitrary files.",
|
|
619
|
-
"-
|
|
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.",
|
|
620
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.",
|
|
621
803
|
"",
|
|
622
|
-
"
|
|
623
|
-
"-
|
|
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>\"}.",
|
|
624
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.",
|
|
625
|
-
"-
|
|
626
|
-
"-
|
|
627
|
-
"- If
|
|
628
|
-
"- 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.",
|
|
629
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.",
|
|
630
822
|
"- Do not explain internal storage details such as builtin packaging, workspace copies, or override flags unless the user explicitly asks.",
|
|
631
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.",
|
|
@@ -645,16 +837,29 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
|
|
|
645
837
|
"- Call read/search skills only when needed to answer the user's question.",
|
|
646
838
|
"",
|
|
647
839
|
"Tool result handling:",
|
|
648
|
-
"-
|
|
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.",
|
|
649
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.",
|
|
650
|
-
"-
|
|
843
|
+
"- skill_calls in assistant history are past call records. Do not copy them. Emit a new call only when truly needed.",
|
|
651
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.",
|
|
652
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.",
|
|
653
854
|
"",
|
|
654
855
|
"How to call side-effect skills:",
|
|
655
|
-
"- For skills marked '(side effect)',
|
|
656
|
-
"- Do not write narration such as 'I will add it now', 'Added', or 'I will register it' together with a skill
|
|
657
|
-
"- 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.",
|
|
658
863
|
"",
|
|
659
864
|
"ToDo handling:",
|
|
660
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.",
|
|
@@ -673,7 +878,11 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
|
|
|
673
878
|
}
|
|
674
879
|
else {
|
|
675
880
|
for (const skill of skills) {
|
|
676
|
-
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(", ")})` : "";
|
|
677
886
|
lines.push(`- ${skill.id}${tag}: ${skill.description || skill.name}`);
|
|
678
887
|
const phrases = skill.invocation?.phrases?.filter((p) => typeof p === "string" && p.trim().length > 0) || [];
|
|
679
888
|
if (phrases.length > 0) {
|
|
@@ -683,30 +892,735 @@ export function buildSystemPrompt(skills, preferredSkillId, profileMemory) {
|
|
|
683
892
|
}
|
|
684
893
|
}
|
|
685
894
|
lines.push("");
|
|
686
|
-
lines.push("
|
|
687
|
-
lines.push("
|
|
688
|
-
lines.push('{"
|
|
689
|
-
lines.push("
|
|
690
|
-
lines.push("
|
|
691
|
-
lines.push("
|
|
692
|
-
lines.push("
|
|
693
|
-
lines.push(
|
|
694
|
-
lines.push("```");
|
|
695
|
-
lines.push("Conversation mode cannot rewrite files directly. When a skill must be fixed or created, do not write it yourself; ask for confirmation with the block above and hand off to build mode after the user's approval.");
|
|
696
|
-
lines.push("If no skill call is needed, reply naturally in the user's language.");
|
|
895
|
+
lines.push("Examples:");
|
|
896
|
+
lines.push('{"reply":"わかりました。確認します。","skill_calls":[{"id":"memo-search","args":{"query":"King Coding Project","limit":10}}],"build_suggestion":null}');
|
|
897
|
+
lines.push('{"reply":"URLを読むスキルなら、本文要約、重要ポイント、気になる点、関連リンクまで返す形がよさそうです。対象は通常のWebページだけでよいですか?PDFやログインが必要なページも含めますか?","skill_calls":[],"build_suggestion":null}');
|
|
898
|
+
lines.push('{"reply":"その内容で通知分類を直せます。この内容で直しますか?","skill_calls":[],"build_suggestion":{"type":"edit","skill_id":"gmail-organizer-ai","reason":"notification classification needs improvement"}}');
|
|
899
|
+
lines.push('{"reply":"了解しました。","skill_calls":[],"build_suggestion":null}');
|
|
900
|
+
lines.push("Multiple skill calls are allowed in skill_calls. Skill results are fed back during the same request with the `tool` role, so dependent calls such as list -> done are allowed.");
|
|
901
|
+
lines.push("Conversation mode cannot rewrite files directly. When a skill must be fixed or created now, do not write it yourself; use build_suggestion only after the user's intent is explicit or the requirements are clear enough for final confirmation.");
|
|
902
|
+
lines.push("If no skill call is needed, put the natural reply in reply, use skill_calls: [], and build_suggestion: null.");
|
|
697
903
|
return lines.join("\n");
|
|
698
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>");
|
|
1087
|
+
return lines.join("\n");
|
|
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
|
+
}
|
|
699
1593
|
function skillCallKey(call) {
|
|
700
1594
|
return `${call.id}:${stableStringify(call.args)}`;
|
|
701
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
|
+
}
|
|
702
1605
|
function emptyChatFallback() {
|
|
703
1606
|
return l("I could not produce a reply. Please send it once more.", "返答を作れませんでした。もう一度送ってください。");
|
|
704
1607
|
}
|
|
705
1608
|
function emptyReplyRetryPrompt() {
|
|
706
|
-
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(" ");
|
|
707
1616
|
}
|
|
708
|
-
function shouldRetryEmptyAssistantReply(
|
|
709
|
-
|
|
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;
|
|
710
1624
|
}
|
|
711
1625
|
function stableStringify(value) {
|
|
712
1626
|
if (value === null || typeof value !== "object") {
|
|
@@ -718,20 +1632,136 @@ function stableStringify(value) {
|
|
|
718
1632
|
const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
719
1633
|
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
720
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
|
+
}
|
|
721
1751
|
export function parseSkillCalls(text) {
|
|
722
1752
|
const calls = [];
|
|
723
|
-
|
|
724
|
-
let match;
|
|
725
|
-
while ((match = SKILL_CALL_PATTERN.exec(text)) !== null) {
|
|
1753
|
+
for (const block of findInternalControlBlocks(text, [SKILL_CALL_BLOCK])) {
|
|
726
1754
|
try {
|
|
727
|
-
const parsed = JSON.parse(
|
|
728
|
-
|
|
1755
|
+
const parsed = JSON.parse(block.payload);
|
|
1756
|
+
const id = parsed.id ?? parsed.skill_id ?? parsed.skillId;
|
|
1757
|
+
if (typeof id !== "string") {
|
|
729
1758
|
continue;
|
|
730
1759
|
}
|
|
731
|
-
const
|
|
732
|
-
|
|
1760
|
+
const rawArgs = parsed.args ?? parsed.arguments ?? parsed.input;
|
|
1761
|
+
const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs)
|
|
1762
|
+
? rawArgs
|
|
733
1763
|
: {};
|
|
734
|
-
calls.push({ id
|
|
1764
|
+
calls.push({ id, args });
|
|
735
1765
|
}
|
|
736
1766
|
catch {
|
|
737
1767
|
continue;
|
|
@@ -740,35 +1770,368 @@ export function parseSkillCalls(text) {
|
|
|
740
1770
|
return calls;
|
|
741
1771
|
}
|
|
742
1772
|
export function stripSkillCalls(text) {
|
|
743
|
-
return text
|
|
1773
|
+
return removeInternalControlBlocks(text, [SKILL_CALL_BLOCK]).trim();
|
|
744
1774
|
}
|
|
745
1775
|
export function extractSkillCallBlocks(text) {
|
|
746
|
-
const
|
|
747
|
-
return
|
|
1776
|
+
const blocks = findInternalControlBlocks(text, [SKILL_CALL_BLOCK]);
|
|
1777
|
+
return blocks.map((block) => text.slice(block.start, block.end).trim()).join("\n");
|
|
748
1778
|
}
|
|
749
1779
|
export function parseBuildSuggestion(text) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1780
|
+
for (const block of findInternalControlBlocks(text, [BUILD_SUGGESTION_BLOCK])) {
|
|
1781
|
+
try {
|
|
1782
|
+
const parsed = JSON.parse(block.payload);
|
|
1783
|
+
const type = parsed.type === "edit" ? "edit" : "create";
|
|
1784
|
+
const skillId = sanitizeSuggestedSkillId(String(parsed.skill_id || ""));
|
|
1785
|
+
if (!skillId)
|
|
1786
|
+
continue;
|
|
1787
|
+
return {
|
|
1788
|
+
type,
|
|
1789
|
+
skill_id: skillId,
|
|
1790
|
+
reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 240) : undefined,
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
export function stripBuildSuggestions(text) {
|
|
1800
|
+
return removeInternalControlBlocks(text, [BUILD_SUGGESTION_BLOCK]).trim();
|
|
1801
|
+
}
|
|
1802
|
+
export function stripInternalControlBlocks(text) {
|
|
1803
|
+
return removeInternalControlBlocks(text, INTERNAL_BLOCK_NAMES)
|
|
1804
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1805
|
+
.trim();
|
|
1806
|
+
}
|
|
1807
|
+
function findInternalControlBlocks(text, names) {
|
|
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 !== "~")
|
|
753
1835
|
return null;
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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) {
|
|
759
1851
|
return null;
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
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);
|
|
765
1859
|
}
|
|
766
|
-
|
|
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) {
|
|
1874
|
+
const lines = splitLinesWithOffsets(text);
|
|
1875
|
+
const blocks = [];
|
|
1876
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1877
|
+
const line = lines[index];
|
|
1878
|
+
const bareName = normalizeInternalBlockName(line.text.trim());
|
|
1879
|
+
if (!wanted.has(bareName)) {
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
const payloadLine = nextNonEmptyLine(lines, index + 1);
|
|
1883
|
+
if (payloadLine < 0) {
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
const jsonStart = firstNonWhitespaceIndex(text, lines[payloadLine].start);
|
|
1887
|
+
if (jsonStart < 0 || text[jsonStart] !== "{") {
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
const jsonEnd = findJsonObjectEnd(text, jsonStart);
|
|
1891
|
+
if (!jsonEnd) {
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
blocks.push({
|
|
1895
|
+
name: bareName,
|
|
1896
|
+
payload: text.slice(jsonStart, jsonEnd).trim(),
|
|
1897
|
+
start: line.start,
|
|
1898
|
+
end: consumeTrailingLineWhitespace(text, jsonEnd),
|
|
1899
|
+
});
|
|
1900
|
+
index = lineIndexForOffset(lines, jsonEnd);
|
|
1901
|
+
}
|
|
1902
|
+
return mergeInternalBlocks(blocks);
|
|
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
|
+
}
|
|
1957
|
+
function removeInternalControlBlocks(text, names) {
|
|
1958
|
+
const blocks = findInternalControlBlocks(text, names);
|
|
1959
|
+
if (blocks.length === 0) {
|
|
1960
|
+
return text;
|
|
1961
|
+
}
|
|
1962
|
+
let out = "";
|
|
1963
|
+
let cursor = 0;
|
|
1964
|
+
for (const block of blocks) {
|
|
1965
|
+
if (block.start < cursor) {
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
out += text.slice(cursor, block.start);
|
|
1969
|
+
cursor = block.end;
|
|
1970
|
+
}
|
|
1971
|
+
out += text.slice(cursor);
|
|
1972
|
+
return out;
|
|
1973
|
+
}
|
|
1974
|
+
function splitLinesWithOffsets(text) {
|
|
1975
|
+
const lines = [];
|
|
1976
|
+
let start = 0;
|
|
1977
|
+
while (start < text.length) {
|
|
1978
|
+
const newline = text.indexOf("\n", start);
|
|
1979
|
+
const end = newline >= 0 ? newline + 1 : text.length;
|
|
1980
|
+
const contentEnd = newline >= 0 ? newline : end;
|
|
1981
|
+
const raw = text.slice(start, contentEnd).replace(/\r$/, "");
|
|
1982
|
+
lines.push({ text: raw, start, end });
|
|
1983
|
+
start = end;
|
|
1984
|
+
}
|
|
1985
|
+
if (text.length === 0) {
|
|
1986
|
+
lines.push({ text: "", start: 0, end: 0 });
|
|
1987
|
+
}
|
|
1988
|
+
return lines;
|
|
1989
|
+
}
|
|
1990
|
+
function nextNonEmptyLine(lines, startLine) {
|
|
1991
|
+
for (let index = startLine; index < lines.length; index += 1) {
|
|
1992
|
+
if (lines[index].text.trim()) {
|
|
1993
|
+
return index;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return -1;
|
|
1997
|
+
}
|
|
1998
|
+
function firstNonWhitespaceIndex(text, start) {
|
|
1999
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2000
|
+
if (!/\s/.test(text[index])) {
|
|
2001
|
+
return index;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return -1;
|
|
2005
|
+
}
|
|
2006
|
+
function findJsonObjectEnd(text, start) {
|
|
2007
|
+
let depth = 0;
|
|
2008
|
+
let inString = false;
|
|
2009
|
+
let escaped = false;
|
|
2010
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2011
|
+
const char = text[index];
|
|
2012
|
+
if (inString) {
|
|
2013
|
+
if (escaped) {
|
|
2014
|
+
escaped = false;
|
|
2015
|
+
}
|
|
2016
|
+
else if (char === "\\") {
|
|
2017
|
+
escaped = true;
|
|
2018
|
+
}
|
|
2019
|
+
else if (char === '"') {
|
|
2020
|
+
inString = false;
|
|
2021
|
+
}
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
if (char === '"') {
|
|
2025
|
+
inString = true;
|
|
2026
|
+
continue;
|
|
2027
|
+
}
|
|
2028
|
+
if (char === "{") {
|
|
2029
|
+
depth += 1;
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
if (char === "}") {
|
|
2033
|
+
depth -= 1;
|
|
2034
|
+
if (depth === 0) {
|
|
2035
|
+
return index + 1;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
function consumeTrailingLineWhitespace(text, start) {
|
|
2042
|
+
let index = start;
|
|
2043
|
+
while (index < text.length && (text[index] === " " || text[index] === "\t" || text[index] === "\r")) {
|
|
2044
|
+
index += 1;
|
|
2045
|
+
}
|
|
2046
|
+
if (text[index] === "\n") {
|
|
2047
|
+
index += 1;
|
|
2048
|
+
}
|
|
2049
|
+
return index;
|
|
2050
|
+
}
|
|
2051
|
+
function lineIndexForOffset(lines, offset) {
|
|
2052
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2053
|
+
if (offset <= lines[index].end) {
|
|
2054
|
+
return index;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return lines.length - 1;
|
|
2058
|
+
}
|
|
2059
|
+
function mergeInternalBlocks(blocks) {
|
|
2060
|
+
const sorted = blocks.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
2061
|
+
const merged = [];
|
|
2062
|
+
for (const block of sorted) {
|
|
2063
|
+
const previous = merged[merged.length - 1];
|
|
2064
|
+
if (previous && block.start < previous.end) {
|
|
2065
|
+
continue;
|
|
2066
|
+
}
|
|
2067
|
+
merged.push(block);
|
|
2068
|
+
}
|
|
2069
|
+
return merged;
|
|
2070
|
+
}
|
|
2071
|
+
function normalizeInternalBlockName(value) {
|
|
2072
|
+
return value.trim().toLowerCase().replaceAll("_", "-");
|
|
2073
|
+
}
|
|
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");
|
|
2091
|
+
}
|
|
2092
|
+
function resolveDirectSkillCall(skills, userText, preferredSkillId) {
|
|
2093
|
+
const normalizedText = normalizeSkillTrigger(userText);
|
|
2094
|
+
if (!normalizedText) {
|
|
2095
|
+
return null;
|
|
2096
|
+
}
|
|
2097
|
+
const candidates = skills
|
|
2098
|
+
.filter((skill) => skill.output_mode === "raw" && !skill.side_effect && hasNoRequiredArgs(skill))
|
|
2099
|
+
.map((skill) => ({
|
|
2100
|
+
skill,
|
|
2101
|
+
phrases: directSkillPhrases(skill),
|
|
2102
|
+
}))
|
|
2103
|
+
.filter((entry) => entry.phrases.some((phrase) => normalizeSkillTrigger(phrase) === normalizedText));
|
|
2104
|
+
if (preferredSkillId) {
|
|
2105
|
+
const preferred = candidates.find((entry) => entry.skill.id === preferredSkillId);
|
|
2106
|
+
if (preferred) {
|
|
2107
|
+
return { id: preferred.skill.id, args: {} };
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
if (candidates.length !== 1) {
|
|
767
2111
|
return null;
|
|
768
2112
|
}
|
|
2113
|
+
return { id: candidates[0].skill.id, args: {} };
|
|
769
2114
|
}
|
|
770
|
-
|
|
771
|
-
return
|
|
2115
|
+
function directSkillPhrases(skill) {
|
|
2116
|
+
return [
|
|
2117
|
+
skill.id,
|
|
2118
|
+
skill.name,
|
|
2119
|
+
skill.invocation?.command || "",
|
|
2120
|
+
...(skill.invocation?.phrases || []),
|
|
2121
|
+
].filter((phrase) => phrase.trim().length > 0);
|
|
2122
|
+
}
|
|
2123
|
+
function hasNoRequiredArgs(skill) {
|
|
2124
|
+
const required = skill.input?.schema?.required;
|
|
2125
|
+
return !Array.isArray(required) || required.length === 0;
|
|
2126
|
+
}
|
|
2127
|
+
function normalizeSkillTrigger(value) {
|
|
2128
|
+
return value
|
|
2129
|
+
.normalize("NFKC")
|
|
2130
|
+
.toLowerCase()
|
|
2131
|
+
.replace(/^\/+/, "")
|
|
2132
|
+
.replace(/(?:を|の)?(?:一覧|リスト)?(?:出して|表示して|見せて|教えて|ください|お願い|して)$/u, "")
|
|
2133
|
+
.replace(/[\s_\-./::・、。!!??"'`]+/g, "")
|
|
2134
|
+
.trim();
|
|
772
2135
|
}
|
|
773
2136
|
function sanitizeSuggestedSkillId(raw) {
|
|
774
2137
|
return raw
|
|
@@ -779,11 +2142,14 @@ function sanitizeSuggestedSkillId(raw) {
|
|
|
779
2142
|
.replace(/^-+|-+$/g, "")
|
|
780
2143
|
.slice(0, 48);
|
|
781
2144
|
}
|
|
782
|
-
export function toolResultJson(id, status, summary, saved = [], data) {
|
|
2145
|
+
export function toolResultJson(id, status, summary, saved = [], data, notes) {
|
|
783
2146
|
const payload = { id, status, summary, saved };
|
|
784
2147
|
if (data !== undefined) {
|
|
785
2148
|
payload.data = data;
|
|
786
2149
|
}
|
|
2150
|
+
if (notes && notes.length > 0) {
|
|
2151
|
+
payload.notes = notes;
|
|
2152
|
+
}
|
|
787
2153
|
return ["```skill-result", JSON.stringify(payload), "```"].join("\n");
|
|
788
2154
|
}
|
|
789
2155
|
export function toAiMessages(history, multimodalTurn) {
|