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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
3
4
|
import { l } from "./i18n.js";
|
|
4
5
|
export async function writeSkillOutputs(config, manifest, result, date = new Date()) {
|
|
5
6
|
const saved = [];
|
|
@@ -11,22 +12,24 @@ export async function writeSkillOutputs(config, manifest, result, date = new Dat
|
|
|
11
12
|
if (output.type !== "markdown") {
|
|
12
13
|
throw new Error(l(`Unsupported output type: ${output.type}`, `未対応の出力形式です: ${output.type}`));
|
|
13
14
|
}
|
|
14
|
-
const
|
|
15
|
+
const overrideFilename = sanitizeOverrideFilename(value.filename);
|
|
16
|
+
const file = resolveOutputFile(config, output, date, overrideFilename);
|
|
15
17
|
assertAllowedOutputPath(config, file);
|
|
16
18
|
await writeMarkdown(file, output, value, date);
|
|
19
|
+
const mode = resolveMergeMode(output);
|
|
17
20
|
saved.push({
|
|
18
21
|
id: output.id,
|
|
19
22
|
type: output.type,
|
|
20
23
|
path: file,
|
|
21
|
-
append:
|
|
24
|
+
append: mode !== "overwrite",
|
|
22
25
|
show_saved: output.show_saved !== false,
|
|
23
26
|
});
|
|
24
27
|
}
|
|
25
28
|
return saved;
|
|
26
29
|
}
|
|
27
|
-
export function resolveOutputFile(config, output, date) {
|
|
30
|
+
export function resolveOutputFile(config, output, date, overrideFilename) {
|
|
28
31
|
const renderedDir = renderTemplate(output.path, date);
|
|
29
|
-
const renderedFilename = renderTemplate(output.filename, date);
|
|
32
|
+
const renderedFilename = overrideFilename || renderTemplate(output.filename, date);
|
|
30
33
|
const normalized = renderedDir.replace(/^\.?\//, "");
|
|
31
34
|
if (normalized === "notes" || normalized.startsWith("notes/")) {
|
|
32
35
|
const rest = normalized.replace(/^notes\/?/, "");
|
|
@@ -37,27 +40,64 @@ export function resolveOutputFile(config, output, date) {
|
|
|
37
40
|
}
|
|
38
41
|
return path.join(config.workspace, normalized, renderedFilename);
|
|
39
42
|
}
|
|
40
|
-
export function renderTemplate(template, date) {
|
|
43
|
+
export function renderTemplate(template, date, vars = {}) {
|
|
41
44
|
const yyyy = String(date.getFullYear());
|
|
42
45
|
const MM = String(date.getMonth() + 1).padStart(2, "0");
|
|
43
46
|
const dd = String(date.getDate()).padStart(2, "0");
|
|
44
47
|
const isoDate = `${yyyy}-${MM}-${dd}`;
|
|
45
|
-
|
|
48
|
+
let out = template
|
|
46
49
|
.replaceAll("{{yyyy}}", yyyy)
|
|
47
50
|
.replaceAll("{{MM}}", MM)
|
|
48
51
|
.replaceAll("{{dd}}", dd)
|
|
49
52
|
.replaceAll("{{date}}", isoDate)
|
|
50
53
|
.replaceAll("{{datetime}}", date.toISOString());
|
|
54
|
+
for (const [key, val] of Object.entries(vars)) {
|
|
55
|
+
out = out.replaceAll(`{{${key}}}`, val);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function sanitizeOverrideFilename(name) {
|
|
60
|
+
if (typeof name !== "string")
|
|
61
|
+
return undefined;
|
|
62
|
+
let cleaned = name.replace(/\0/g, "").replace(/[\\/]/g, "-").trim();
|
|
63
|
+
cleaned = cleaned.replace(/^\.+/, "");
|
|
64
|
+
if (!cleaned || cleaned === "." || cleaned === "..")
|
|
65
|
+
return undefined;
|
|
66
|
+
if (path.isAbsolute(cleaned))
|
|
67
|
+
cleaned = path.basename(cleaned);
|
|
68
|
+
return cleaned;
|
|
69
|
+
}
|
|
70
|
+
function resolveMergeMode(output) {
|
|
71
|
+
if (output.merge_mode)
|
|
72
|
+
return output.merge_mode;
|
|
73
|
+
if (output.append)
|
|
74
|
+
return "append";
|
|
75
|
+
return "overwrite";
|
|
51
76
|
}
|
|
52
77
|
async function writeMarkdown(file, output, value, date) {
|
|
53
78
|
const content = value.content || "";
|
|
54
79
|
await mkdir(path.dirname(file), { recursive: true });
|
|
55
|
-
|
|
80
|
+
const mode = resolveMergeMode(output);
|
|
81
|
+
if (mode === "append") {
|
|
56
82
|
const existing = await readTextIfExists(file);
|
|
57
83
|
const prefix = existing ? "" : initialMarkdown(value.frontmatter, date);
|
|
58
84
|
await writeFile(file, `${prefix}${existing}${ensureTrailingNewline(content)}`, "utf8");
|
|
59
85
|
return;
|
|
60
86
|
}
|
|
87
|
+
if (mode === "update_or_append") {
|
|
88
|
+
const existing = await readTextIfExists(file);
|
|
89
|
+
if (!existing) {
|
|
90
|
+
const fm = withCreatedUpdated(value.frontmatter, date, date);
|
|
91
|
+
await writeFile(file, `${initialFrontmatter(fm)}${ensureTrailingNewline(content)}`, "utf8");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const parsed = parseFrontmatterBlock(existing);
|
|
95
|
+
const mergedFm = mergeFrontmatterForUpdate(parsed.frontmatter, value.frontmatter, date);
|
|
96
|
+
const trimmedBody = parsed.body.replace(/\n+$/, "");
|
|
97
|
+
const newBody = `${trimmedBody}\n\n---\n\n${ensureTrailingNewline(content)}`;
|
|
98
|
+
await writeFile(file, `${initialFrontmatter(mergedFm)}${newBody}`, "utf8");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
61
101
|
await writeFile(file, `${initialFrontmatter(value.frontmatter)}${content}`, "utf8");
|
|
62
102
|
}
|
|
63
103
|
function initialMarkdown(frontmatter, date) {
|
|
@@ -80,6 +120,67 @@ function initialFrontmatter(frontmatter) {
|
|
|
80
120
|
lines.push("---", "");
|
|
81
121
|
return `${lines.join("\n")}\n`;
|
|
82
122
|
}
|
|
123
|
+
function parseFrontmatterBlock(raw) {
|
|
124
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
125
|
+
if (!match) {
|
|
126
|
+
return { frontmatter: {}, body: raw };
|
|
127
|
+
}
|
|
128
|
+
let parsed = {};
|
|
129
|
+
try {
|
|
130
|
+
const obj = YAML.parse(match[1] || "");
|
|
131
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
132
|
+
parsed = obj;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
parsed = {};
|
|
137
|
+
}
|
|
138
|
+
return { frontmatter: parsed, body: raw.slice(match[0].length) };
|
|
139
|
+
}
|
|
140
|
+
function mergeFrontmatterForUpdate(existing, incoming, date) {
|
|
141
|
+
const out = { ...existing };
|
|
142
|
+
if (incoming) {
|
|
143
|
+
for (const [key, val] of Object.entries(incoming)) {
|
|
144
|
+
if (key === "created") {
|
|
145
|
+
if (out.created === undefined)
|
|
146
|
+
out.created = val ?? formatLocalDateTime(date);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (key === "updated")
|
|
150
|
+
continue;
|
|
151
|
+
if (key === "tags" && Array.isArray(val) && Array.isArray(out.tags)) {
|
|
152
|
+
const merged = new Set();
|
|
153
|
+
for (const item of out.tags)
|
|
154
|
+
merged.add(String(item));
|
|
155
|
+
for (const item of val)
|
|
156
|
+
merged.add(String(item));
|
|
157
|
+
out.tags = Array.from(merged);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
out[key] = val;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (out.created === undefined)
|
|
164
|
+
out.created = formatLocalDateTime(date);
|
|
165
|
+
out.updated = formatLocalDateTime(date);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function withCreatedUpdated(frontmatter, createdFallback, updated) {
|
|
169
|
+
const out = { ...(frontmatter || {}) };
|
|
170
|
+
if (out.created === undefined)
|
|
171
|
+
out.created = formatLocalDateTime(createdFallback);
|
|
172
|
+
out.updated = formatLocalDateTime(updated);
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function formatLocalDateTime(date) {
|
|
176
|
+
const yyyy = String(date.getFullYear());
|
|
177
|
+
const MM = String(date.getMonth() + 1).padStart(2, "0");
|
|
178
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
179
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
180
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
181
|
+
const ss = String(date.getSeconds()).padStart(2, "0");
|
|
182
|
+
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`;
|
|
183
|
+
}
|
|
83
184
|
function ensureTrailingNewline(value) {
|
|
84
185
|
return value.endsWith("\n") ? value : `${value}\n`;
|
|
85
186
|
}
|
|
@@ -86,7 +86,12 @@ export async function appendProfileMemory(config, target, text, date = new Date(
|
|
|
86
86
|
}
|
|
87
87
|
await ensureProfileMemoryFiles(config);
|
|
88
88
|
const file = profileMemoryPath(config, target);
|
|
89
|
-
|
|
89
|
+
if (target === "memory") {
|
|
90
|
+
await appendMemoryEntry(file, formatProfileEntry(value, date));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
await appendFile(file, formatProfileEntry(value, date), "utf8");
|
|
94
|
+
}
|
|
90
95
|
return file;
|
|
91
96
|
}
|
|
92
97
|
export function parseProfileMemoryTarget(value) {
|
|
@@ -121,6 +126,22 @@ function profileMemoryPaths(config) {
|
|
|
121
126
|
memory: profileMemoryPath(config, "memory"),
|
|
122
127
|
};
|
|
123
128
|
}
|
|
129
|
+
async function appendMemoryEntry(file, entry) {
|
|
130
|
+
const original = await readFile(file, "utf8");
|
|
131
|
+
const index = recentTopicsSectionStart(original);
|
|
132
|
+
if (index < 0) {
|
|
133
|
+
await appendFile(file, entry, "utf8");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const before = original.slice(0, index).replace(/\s+$/, "");
|
|
137
|
+
const after = original.slice(index).replace(/^\s+/, "");
|
|
138
|
+
const next = `${before}${entry}\n${after}`;
|
|
139
|
+
await writeFile(file, next, "utf8");
|
|
140
|
+
}
|
|
141
|
+
function recentTopicsSectionStart(text) {
|
|
142
|
+
const match = /^##\s+(?:Recent 7-day topics|直近1週間のトピック)\s*$/im.exec(text);
|
|
143
|
+
return match ? match.index : -1;
|
|
144
|
+
}
|
|
124
145
|
async function writeIfMissing(file, content) {
|
|
125
146
|
try {
|
|
126
147
|
await stat(file);
|
package/dist/core/runtime.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface RunSkillResponse {
|
|
|
10
10
|
log_path: string;
|
|
11
11
|
memory_path?: string;
|
|
12
12
|
attempts: number;
|
|
13
|
+
/** ctx.log entries emitted during the run — surfaced so callers can see warn/error. */
|
|
14
|
+
ctx_logs?: SkillLogEntry[];
|
|
13
15
|
}
|
|
14
16
|
export interface RunSkillOptions {
|
|
15
17
|
/** @deprecated Approval gating was removed; field kept for API compatibility. */
|
package/dist/core/runtime.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { appendEventLog, createRunId, writeRunLog } from "./logger.js";
|
|
3
4
|
import { loadSkillMemory, saveSkillMemoryUpdates } from "./memory.js";
|
|
4
5
|
import { writeSkillOutputs } from "./output-writer.js";
|
|
5
|
-
import { findSkillManifest } from "./skill-registry.js";
|
|
6
|
+
import { builtinSkillsDir, findSkillManifest } from "./skill-registry.js";
|
|
6
7
|
import { validateSkillArgs } from "./input-schema.js";
|
|
7
8
|
import { loadDotenv } from "./secrets.js";
|
|
8
9
|
import { ensureProfileMemoryFiles } from "./profile-memory.js";
|
|
@@ -34,6 +35,8 @@ export async function runSkill(config, skillId, args, options = {}) {
|
|
|
34
35
|
const runId = createRunId();
|
|
35
36
|
const started = new Date();
|
|
36
37
|
const memory = await loadSkillMemory(config, manifest);
|
|
38
|
+
const skillOutputDir = path.join(config.skill_outputs_dir, manifest.id);
|
|
39
|
+
await mkdir(skillOutputDir, { recursive: true });
|
|
37
40
|
const input = {
|
|
38
41
|
args,
|
|
39
42
|
trigger: {
|
|
@@ -47,6 +50,10 @@ export async function runSkill(config, skillId, args, options = {}) {
|
|
|
47
50
|
memory_dir: config.memory_dir,
|
|
48
51
|
index_dir: config.index_dir,
|
|
49
52
|
logs_dir: config.logs_dir,
|
|
53
|
+
skills_dir: config.skills_dir,
|
|
54
|
+
builtin_skills_dir: builtinSkillsDir(),
|
|
55
|
+
skill_output_dir: skillOutputDir,
|
|
56
|
+
skillOutputDir,
|
|
50
57
|
locale: detectLocale(),
|
|
51
58
|
},
|
|
52
59
|
memory,
|
|
@@ -103,6 +110,7 @@ export async function runSkill(config, skillId, args, options = {}) {
|
|
|
103
110
|
log_path: logPath,
|
|
104
111
|
memory_path: memoryPath,
|
|
105
112
|
attempts,
|
|
113
|
+
ctx_logs: skillLogs.length > 0 ? skillLogs : undefined,
|
|
106
114
|
};
|
|
107
115
|
}
|
|
108
116
|
catch (error) {
|
package/dist/core/secrets.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export declare function upsertDotenv(workspace: string, entries: Array<{
|
|
|
13
13
|
}>): Promise<{
|
|
14
14
|
path: string;
|
|
15
15
|
}>;
|
|
16
|
+
export declare function removeFromDotenv(workspace: string, keys: string[]): Promise<{
|
|
17
|
+
path: string;
|
|
18
|
+
removed: string[];
|
|
19
|
+
}>;
|
|
16
20
|
export interface ApiKeySource {
|
|
17
21
|
provider: string;
|
|
18
22
|
envVar: string;
|
package/dist/core/secrets.js
CHANGED
|
@@ -132,6 +132,40 @@ export async function upsertDotenv(workspace, entries) {
|
|
|
132
132
|
await writeFile(file, next, { mode: 0o600 });
|
|
133
133
|
return { path: file };
|
|
134
134
|
}
|
|
135
|
+
export async function removeFromDotenv(workspace, keys) {
|
|
136
|
+
const file = path.join(workspace, ".env");
|
|
137
|
+
let existing = "";
|
|
138
|
+
try {
|
|
139
|
+
existing = await readFile(file, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { path: file, removed: [] };
|
|
143
|
+
}
|
|
144
|
+
const lines = existing.split(/\r?\n/);
|
|
145
|
+
const targets = new Set(keys);
|
|
146
|
+
const removed = [];
|
|
147
|
+
const keptLines = [];
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const trimmed = line.trim();
|
|
150
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
151
|
+
keptLines.push(line);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
155
|
+
if (match && targets.has(match[1])) {
|
|
156
|
+
if (!removed.includes(match[1]))
|
|
157
|
+
removed.push(match[1]);
|
|
158
|
+
delete process.env[match[1]];
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
keptLines.push(line);
|
|
162
|
+
}
|
|
163
|
+
let next = keptLines.join("\n");
|
|
164
|
+
if (!next.endsWith("\n"))
|
|
165
|
+
next = `${next}\n`;
|
|
166
|
+
await writeFile(file, next, { mode: 0o600 });
|
|
167
|
+
return { path: file, removed };
|
|
168
|
+
}
|
|
135
169
|
function formatDotenvValue(value) {
|
|
136
170
|
if (/[\s"'#]/.test(value) || value === "") {
|
|
137
171
|
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AppConfig } from "./config.js";
|
|
2
|
+
import type { SkillManifest } from "./skill-registry.js";
|
|
3
|
+
/**
|
|
4
|
+
* Per-skill structured log backed by SQLite. The skill keeps reading/writing
|
|
5
|
+
* via `ctx.history.append/list/read`; this module is the only place that
|
|
6
|
+
* touches the DB and the on-disk layout.
|
|
7
|
+
*
|
|
8
|
+
* ~/.agent-sin/data/<skill-id>.db one file per skill, schema below
|
|
9
|
+
*
|
|
10
|
+
* entries(id PK, time, meta JSON, content TEXT)
|
|
11
|
+
*
|
|
12
|
+
* Bulky structured fields belong in `meta` (JSON). Human-readable summaries
|
|
13
|
+
* belong in `content`. Reports that the user reads back in Obsidian are a
|
|
14
|
+
* separate concern handled by skill manifests' `outputs:` block.
|
|
15
|
+
*/
|
|
16
|
+
export interface SkillHistoryEntry {
|
|
17
|
+
time: string;
|
|
18
|
+
meta: Record<string, unknown>;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AppendHistoryArgs {
|
|
22
|
+
time?: string | Date;
|
|
23
|
+
meta?: Record<string, unknown>;
|
|
24
|
+
content: string;
|
|
25
|
+
/**
|
|
26
|
+
* When true, replace an existing row that shares the same `meta.id`. The
|
|
27
|
+
* caller must supply `meta.id`. When the id is unknown, a fresh row is
|
|
28
|
+
* always inserted.
|
|
29
|
+
*/
|
|
30
|
+
replace?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface HistoryRangeOptions {
|
|
33
|
+
from?: string | Date;
|
|
34
|
+
to?: string | Date;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function skillHistoryDataDir(config: AppConfig): string;
|
|
38
|
+
export declare function skillHistoryDbPath(config: AppConfig, skillId: string): string;
|
|
39
|
+
export declare function appendSkillHistory(config: AppConfig, manifest: SkillManifest, args: AppendHistoryArgs): Promise<{
|
|
40
|
+
path: string;
|
|
41
|
+
id: string;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function listSkillHistory(config: AppConfig, manifest: SkillManifest, options?: HistoryRangeOptions): Promise<SkillHistoryEntry[]>;
|
|
44
|
+
export declare function readSkillHistoryRaw(config: AppConfig, manifest: SkillManifest, options?: HistoryRangeOptions): Promise<string>;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { l } from "./i18n.js";
|
|
6
|
+
const SCHEMA = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
time TEXT NOT NULL,
|
|
10
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
11
|
+
content TEXT NOT NULL DEFAULT ''
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS entries_time_idx ON entries (time);
|
|
14
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
15
|
+
key TEXT PRIMARY KEY,
|
|
16
|
+
value TEXT
|
|
17
|
+
);
|
|
18
|
+
`;
|
|
19
|
+
export function skillHistoryDataDir(config) {
|
|
20
|
+
return path.join(config.workspace, "data");
|
|
21
|
+
}
|
|
22
|
+
export function skillHistoryDbPath(config, skillId) {
|
|
23
|
+
return path.join(skillHistoryDataDir(config), `${safeSkillSegment(skillId)}.db`);
|
|
24
|
+
}
|
|
25
|
+
export async function appendSkillHistory(config, manifest, args) {
|
|
26
|
+
if (manifest.history?.write !== true) {
|
|
27
|
+
throw new Error(l("History write is not allowed for this skill", "このスキルでは履歴の書き込みが許可されていません"));
|
|
28
|
+
}
|
|
29
|
+
const db = await openSkillDb(config, manifest.id);
|
|
30
|
+
try {
|
|
31
|
+
const time = toDate(args.time) ?? new Date();
|
|
32
|
+
const meta = sanitizeMeta(args.meta);
|
|
33
|
+
const explicitId = typeof meta.id === "string" && meta.id ? meta.id : undefined;
|
|
34
|
+
const content = args.content.trim();
|
|
35
|
+
if (args.replace === true) {
|
|
36
|
+
if (!explicitId) {
|
|
37
|
+
throw new Error(l("history.append replace requires meta.id", "history.append replace には meta.id が必要です"));
|
|
38
|
+
}
|
|
39
|
+
const row = {
|
|
40
|
+
id: explicitId,
|
|
41
|
+
time: time.toISOString(),
|
|
42
|
+
meta: JSON.stringify(meta),
|
|
43
|
+
content,
|
|
44
|
+
};
|
|
45
|
+
db.prepare("INSERT INTO entries(id, time, meta, content) VALUES (@id, @time, @meta, @content) " +
|
|
46
|
+
"ON CONFLICT(id) DO UPDATE SET time=excluded.time, meta=excluded.meta, content=excluded.content").run(row);
|
|
47
|
+
return { path: skillHistoryDbPath(config, manifest.id), id: explicitId };
|
|
48
|
+
}
|
|
49
|
+
const id = explicitId ?? generateEntryId(time);
|
|
50
|
+
if (!meta.id)
|
|
51
|
+
meta.id = id;
|
|
52
|
+
const row = {
|
|
53
|
+
id,
|
|
54
|
+
time: time.toISOString(),
|
|
55
|
+
meta: JSON.stringify(meta),
|
|
56
|
+
content,
|
|
57
|
+
};
|
|
58
|
+
db.prepare("INSERT OR REPLACE INTO entries(id, time, meta, content) VALUES (@id, @time, @meta, @content)").run(row);
|
|
59
|
+
return { path: skillHistoryDbPath(config, manifest.id), id };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
db.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function listSkillHistory(config, manifest, options = {}) {
|
|
66
|
+
if (manifest.history?.read !== true) {
|
|
67
|
+
throw new Error(l("History read is not allowed for this skill", "このスキルでは履歴の読み取りが許可されていません"));
|
|
68
|
+
}
|
|
69
|
+
const db = await openSkillDb(config, manifest.id);
|
|
70
|
+
try {
|
|
71
|
+
const { sql, params } = buildSelect(options);
|
|
72
|
+
const rows = db.prepare(sql).all(params);
|
|
73
|
+
const entries = [];
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
let meta = {};
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(row.meta);
|
|
78
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
79
|
+
meta = parsed;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
meta = {};
|
|
84
|
+
}
|
|
85
|
+
entries.push({ time: row.time, meta, content: row.content });
|
|
86
|
+
}
|
|
87
|
+
return entries;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function readSkillHistoryRaw(config, manifest, options = {}) {
|
|
94
|
+
const entries = await listSkillHistory(config, manifest, options);
|
|
95
|
+
return entries
|
|
96
|
+
.map((entry) => {
|
|
97
|
+
const metaLine = JSON.stringify(entry.meta);
|
|
98
|
+
return `---\ntime: ${entry.time}\nmeta: ${metaLine}\n---\n${entry.content}`.trim();
|
|
99
|
+
})
|
|
100
|
+
.join("\n\n");
|
|
101
|
+
}
|
|
102
|
+
function buildSelect(options) {
|
|
103
|
+
const params = {};
|
|
104
|
+
const where = [];
|
|
105
|
+
const from = toDate(options.from);
|
|
106
|
+
const to = toDate(options.to);
|
|
107
|
+
if (from) {
|
|
108
|
+
where.push("time >= @from");
|
|
109
|
+
params.from = startOfDay(from).toISOString();
|
|
110
|
+
}
|
|
111
|
+
if (to) {
|
|
112
|
+
where.push("time <= @to");
|
|
113
|
+
params.to = endOfDay(to).toISOString();
|
|
114
|
+
}
|
|
115
|
+
let sql = "SELECT time, meta, content FROM entries";
|
|
116
|
+
if (where.length > 0)
|
|
117
|
+
sql += " WHERE " + where.join(" AND ");
|
|
118
|
+
sql += " ORDER BY time ASC";
|
|
119
|
+
if (typeof options.limit === "number" && options.limit >= 0) {
|
|
120
|
+
sql += " LIMIT @limit";
|
|
121
|
+
params.limit = options.limit;
|
|
122
|
+
}
|
|
123
|
+
return { sql, params };
|
|
124
|
+
}
|
|
125
|
+
function sanitizeMeta(meta) {
|
|
126
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
127
|
+
return {};
|
|
128
|
+
const out = {};
|
|
129
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
130
|
+
if (value === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
out[key] = value;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function generateEntryId(time) {
|
|
137
|
+
const stamp = time.toISOString().replace(/[^0-9]/g, "").slice(0, 14);
|
|
138
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
139
|
+
return `${stamp}-${rand}`;
|
|
140
|
+
}
|
|
141
|
+
function startOfDay(date) {
|
|
142
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
|
143
|
+
}
|
|
144
|
+
function endOfDay(date) {
|
|
145
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
|
146
|
+
}
|
|
147
|
+
function toDate(value) {
|
|
148
|
+
if (!value)
|
|
149
|
+
return undefined;
|
|
150
|
+
if (value instanceof Date)
|
|
151
|
+
return value;
|
|
152
|
+
const parsed = new Date(value);
|
|
153
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
154
|
+
}
|
|
155
|
+
function safeSkillSegment(value) {
|
|
156
|
+
if (!/^[a-z][a-z0-9-]*$/.test(value)) {
|
|
157
|
+
throw new Error(l(`Invalid skill id for history path: ${value}`, `履歴のパスに不正なスキルIDが含まれています: ${value}`));
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
async function openSkillDb(config, skillId) {
|
|
162
|
+
const dir = skillHistoryDataDir(config);
|
|
163
|
+
await mkdir(dir, { recursive: true });
|
|
164
|
+
const file = skillHistoryDbPath(config, skillId);
|
|
165
|
+
const db = new Database(file);
|
|
166
|
+
db.pragma("journal_mode = WAL");
|
|
167
|
+
db.exec(SCHEMA);
|
|
168
|
+
await migrateLegacyMarkdown(config, skillId, db);
|
|
169
|
+
return db;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* One-time migration from the original Markdown layout
|
|
173
|
+
* (`notes/<skill-id>/{{yyyy}}/{{MM}}/<date>.md`) into the new SQLite store.
|
|
174
|
+
* Parses each entry block, inserts a row keyed by meta.id when present
|
|
175
|
+
* (or a freshly generated id otherwise), then archives the original notes
|
|
176
|
+
* directory next to itself as `<dir>.legacy-md`.
|
|
177
|
+
*/
|
|
178
|
+
async function migrateLegacyMarkdown(config, skillId, db) {
|
|
179
|
+
const flag = db.prepare("SELECT value FROM _meta WHERE key = 'legacy_md_migrated' LIMIT 1").get();
|
|
180
|
+
if (flag)
|
|
181
|
+
return;
|
|
182
|
+
const markDone = db.prepare("INSERT OR REPLACE INTO _meta(key, value) VALUES (?, ?)");
|
|
183
|
+
const legacyRoot = path.join(config.notes_dir, safeSkillSegment(skillId));
|
|
184
|
+
let stats;
|
|
185
|
+
try {
|
|
186
|
+
stats = await stat(legacyRoot);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
markDone.run("legacy_md_migrated", new Date().toISOString());
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!stats.isDirectory()) {
|
|
193
|
+
markDone.run("legacy_md_migrated", new Date().toISOString());
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const files = [];
|
|
197
|
+
await walkMarkdown(legacyRoot, files);
|
|
198
|
+
const insertSql = "INSERT INTO entries(id, time, meta, content) VALUES (@id, @time, @meta, @content) " +
|
|
199
|
+
"ON CONFLICT(id) DO UPDATE SET time=excluded.time, meta=excluded.meta, content=excluded.content";
|
|
200
|
+
const stmt = db.prepare(insertSql);
|
|
201
|
+
const tx = db.transaction((rows) => {
|
|
202
|
+
for (const row of rows)
|
|
203
|
+
stmt.run(row);
|
|
204
|
+
});
|
|
205
|
+
const rows = [];
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const raw = await readFile(file, "utf8");
|
|
208
|
+
for (const entry of parseLegacyEntries(raw, file)) {
|
|
209
|
+
const id = typeof entry.meta.id === "string" && entry.meta.id ? entry.meta.id : generateEntryId(new Date(entry.time));
|
|
210
|
+
if (!entry.meta.id)
|
|
211
|
+
entry.meta.id = id;
|
|
212
|
+
rows.push({
|
|
213
|
+
id,
|
|
214
|
+
time: entry.time,
|
|
215
|
+
meta: JSON.stringify(entry.meta),
|
|
216
|
+
content: entry.content,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (rows.length > 0)
|
|
221
|
+
tx(rows);
|
|
222
|
+
markDone.run("legacy_md_migrated", JSON.stringify({ at: new Date().toISOString(), files: files.length, entries: rows.length }));
|
|
223
|
+
await rm(legacyRoot, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
async function walkMarkdown(dir, out) {
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const entry of entries) {
|
|
234
|
+
if (entry.name.startsWith("."))
|
|
235
|
+
continue;
|
|
236
|
+
const full = path.join(dir, entry.name);
|
|
237
|
+
if (entry.isDirectory()) {
|
|
238
|
+
await walkMarkdown(full, out);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (entry.isFile() && entry.name.endsWith(".md") && /^\d{4}-\d{2}-\d{2}\.md$/.test(entry.name)) {
|
|
242
|
+
out.push(full);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function parseLegacyEntries(raw, file) {
|
|
247
|
+
const body = stripLegacyFrontmatter(raw);
|
|
248
|
+
const entries = [];
|
|
249
|
+
const sectionRegex = /(^|\n)##\s+(\d{1,2}:\d{2})\b[^\n]*\n([\s\S]*?)(?=\n##\s+\d{1,2}:\d{2}\b|$)/g;
|
|
250
|
+
let match;
|
|
251
|
+
const fileDate = inferLegacyFileDate(file);
|
|
252
|
+
while ((match = sectionRegex.exec(body)) !== null) {
|
|
253
|
+
const headerTime = match[2];
|
|
254
|
+
const blockBody = match[3];
|
|
255
|
+
const { meta, content } = splitLegacyMetaAndContent(blockBody);
|
|
256
|
+
const isoTime = extractLegacyMetaTime(meta) ?? combineDateTime(fileDate, headerTime);
|
|
257
|
+
delete meta.time;
|
|
258
|
+
entries.push({ time: isoTime, meta, content });
|
|
259
|
+
}
|
|
260
|
+
return entries;
|
|
261
|
+
}
|
|
262
|
+
function splitLegacyMetaAndContent(blockBody) {
|
|
263
|
+
const lines = blockBody.replace(/^\n/, "").split("\n");
|
|
264
|
+
const yamlMetaLines = [];
|
|
265
|
+
const hiddenMeta = {};
|
|
266
|
+
let i = 0;
|
|
267
|
+
for (; i < lines.length; i += 1) {
|
|
268
|
+
const line = lines[i];
|
|
269
|
+
if (line.trim() === "")
|
|
270
|
+
break;
|
|
271
|
+
const hiddenMatch = line.match(/^<!--\s+([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*:\s*([\s\S]*?)\s*-->\s*$/);
|
|
272
|
+
if (hiddenMatch) {
|
|
273
|
+
const key = hiddenMatch[1];
|
|
274
|
+
const rawValue = hiddenMatch[2];
|
|
275
|
+
try {
|
|
276
|
+
hiddenMeta[key] = YAML.parse(rawValue);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
hiddenMeta[key] = rawValue;
|
|
280
|
+
}
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_\-]*\s*:/.test(line))
|
|
284
|
+
break;
|
|
285
|
+
yamlMetaLines.push(line);
|
|
286
|
+
}
|
|
287
|
+
const contentLines = i < lines.length ? lines.slice(i + 1) : [];
|
|
288
|
+
let meta = {};
|
|
289
|
+
if (yamlMetaLines.length > 0) {
|
|
290
|
+
try {
|
|
291
|
+
const parsed = YAML.parse(yamlMetaLines.join("\n"));
|
|
292
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
293
|
+
meta = parsed;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
meta = {};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { meta: { ...meta, ...hiddenMeta }, content: contentLines.join("\n").trim() };
|
|
301
|
+
}
|
|
302
|
+
function stripLegacyFrontmatter(raw) {
|
|
303
|
+
if (!raw.startsWith("---\n"))
|
|
304
|
+
return raw;
|
|
305
|
+
const end = raw.indexOf("\n---", 4);
|
|
306
|
+
if (end < 0)
|
|
307
|
+
return raw;
|
|
308
|
+
const after = raw.slice(end + 4);
|
|
309
|
+
return after.replace(/^\n/, "");
|
|
310
|
+
}
|
|
311
|
+
function extractLegacyMetaTime(meta) {
|
|
312
|
+
const rawValue = meta.time;
|
|
313
|
+
if (typeof rawValue === "string" && rawValue)
|
|
314
|
+
return rawValue;
|
|
315
|
+
if (rawValue instanceof Date && !Number.isNaN(rawValue.getTime()))
|
|
316
|
+
return rawValue.toISOString();
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
function inferLegacyFileDate(file) {
|
|
320
|
+
const match = path.basename(file).match(/^(\d{4})-(\d{2})-(\d{2})\.md$/);
|
|
321
|
+
if (!match)
|
|
322
|
+
return new Date();
|
|
323
|
+
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
324
|
+
}
|
|
325
|
+
function combineDateTime(date, hhmm) {
|
|
326
|
+
const [hh, mm] = hhmm.split(":").map((part) => Number(part));
|
|
327
|
+
const merged = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hh || 0, mm || 0);
|
|
328
|
+
return merged.toISOString();
|
|
329
|
+
}
|