@stackmemoryai/stackmemory 1.9.0 → 1.10.3
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/dist/src/cli/commands/orchestrator.js +27 -6
- package/dist/src/cli/commands/ralph.js +43 -0
- package/dist/src/cli/commands/rules.js +250 -0
- package/dist/src/cli/commands/skill.js +406 -0
- package/dist/src/cli/index.js +64 -0
- package/dist/src/core/config/config-manager.js +2 -1
- package/dist/src/core/rules/built-in-rules.js +289 -0
- package/dist/src/core/rules/pr-review-rule.js +87 -0
- package/dist/src/core/rules/rule-engine.js +85 -0
- package/dist/src/core/rules/rule-store.js +99 -0
- package/dist/src/core/rules/types.js +4 -0
- package/dist/src/core/skills/index.js +21 -0
- package/dist/src/core/skills/skill-matcher.js +178 -0
- package/dist/src/core/skills/skill-registry.js +646 -0
- package/dist/src/core/skills/types.js +1 -47
- package/dist/src/core/storage/obsidian-vault-adapter.js +392 -0
- package/dist/src/integrations/mcp/handlers/skill-handlers.js +67 -167
- package/dist/src/integrations/mcp/server.js +2 -6
- package/dist/src/integrations/ralph/loopmax.js +488 -0
- package/package.json +2 -2
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
watch
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { logger } from "../monitoring/logger.js";
|
|
15
|
+
import { frameLifecycleHooks } from "../context/frame-lifecycle-hooks.js";
|
|
16
|
+
class ObsidianVaultAdapter {
|
|
17
|
+
config;
|
|
18
|
+
dirs;
|
|
19
|
+
watcher = null;
|
|
20
|
+
ingestCallback = null;
|
|
21
|
+
seenRawFiles = /* @__PURE__ */ new Set();
|
|
22
|
+
unregisterCreate = null;
|
|
23
|
+
unregisterClose = null;
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.config = {
|
|
26
|
+
vaultPath: config.vaultPath,
|
|
27
|
+
subdir: config.subdir ?? "stackmemory",
|
|
28
|
+
watchRaw: config.watchRaw ?? true,
|
|
29
|
+
autoIndex: config.autoIndex ?? true,
|
|
30
|
+
includeEvents: config.includeEvents ?? false
|
|
31
|
+
};
|
|
32
|
+
const root = join(this.config.vaultPath, this.config.subdir);
|
|
33
|
+
this.dirs = {
|
|
34
|
+
root,
|
|
35
|
+
frames: join(root, "frames"),
|
|
36
|
+
raw: join(root, "raw"),
|
|
37
|
+
sessions: join(root, "sessions")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Initialize vault directory structure and register lifecycle hooks */
|
|
41
|
+
async initialize() {
|
|
42
|
+
for (const dir of Object.values(this.dirs)) {
|
|
43
|
+
if (!existsSync(dir)) {
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rawReadme = join(this.dirs.raw, "README.md");
|
|
48
|
+
if (!existsSync(rawReadme)) {
|
|
49
|
+
writeFileSync(
|
|
50
|
+
rawReadme,
|
|
51
|
+
[
|
|
52
|
+
"# Raw Ingest",
|
|
53
|
+
"",
|
|
54
|
+
"Drop .md files here (e.g., from Obsidian Web Clipper).",
|
|
55
|
+
"StackMemory will auto-ingest them as frames.",
|
|
56
|
+
"",
|
|
57
|
+
"## Setup",
|
|
58
|
+
"",
|
|
59
|
+
"1. Install [Obsidian Web Clipper](https://obsidian.md/clipper)",
|
|
60
|
+
"2. Set the clip destination to this folder",
|
|
61
|
+
"3. StackMemory watches for new files and ingests them"
|
|
62
|
+
].join("\n")
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
this.unregisterCreate = frameLifecycleHooks.onFrameCreated(
|
|
66
|
+
"obsidian-vault",
|
|
67
|
+
async (frame) => {
|
|
68
|
+
await this.writeFrame(frame);
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
this.unregisterClose = frameLifecycleHooks.onFrameClosed(
|
|
72
|
+
"obsidian-vault",
|
|
73
|
+
async (data) => {
|
|
74
|
+
await this.writeFrame(data.frame, data.events, data.anchors);
|
|
75
|
+
if (this.config.autoIndex) {
|
|
76
|
+
await this.updateIndex();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
if (this.config.watchRaw) {
|
|
81
|
+
this.startWatching();
|
|
82
|
+
}
|
|
83
|
+
if (this.config.autoIndex) {
|
|
84
|
+
await this.updateIndex();
|
|
85
|
+
}
|
|
86
|
+
logger.info("Obsidian vault adapter initialized", {
|
|
87
|
+
vault: this.config.vaultPath,
|
|
88
|
+
subdir: this.config.subdir
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Set callback for when raw files are ingested */
|
|
92
|
+
onIngest(callback) {
|
|
93
|
+
this.ingestCallback = callback;
|
|
94
|
+
}
|
|
95
|
+
/** Write a frame as a .md file */
|
|
96
|
+
async writeFrame(frame, events, anchors) {
|
|
97
|
+
const filename = this.frameFilename(frame);
|
|
98
|
+
const filepath = join(this.dirs.frames, filename);
|
|
99
|
+
const content = this.serializeFrame(frame, events, anchors);
|
|
100
|
+
writeFileSync(filepath, content);
|
|
101
|
+
logger.debug("Wrote frame to vault", {
|
|
102
|
+
frameId: frame.frame_id,
|
|
103
|
+
file: filename
|
|
104
|
+
});
|
|
105
|
+
return filepath;
|
|
106
|
+
}
|
|
107
|
+
/** Write a session summary */
|
|
108
|
+
async writeSessionSummary(sessionId, summary, frames) {
|
|
109
|
+
const filename = `session-${sessionId.slice(0, 12)}.md`;
|
|
110
|
+
const filepath = join(this.dirs.sessions, filename);
|
|
111
|
+
const frameLinks = frames.map(
|
|
112
|
+
(f) => `- [[${this.frameFilename(f).replace(".md", "")}|${f.name}]] (${f.type})`
|
|
113
|
+
).join("\n");
|
|
114
|
+
const content = [
|
|
115
|
+
"---",
|
|
116
|
+
`session_id: "${sessionId}"`,
|
|
117
|
+
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
118
|
+
`frame_count: ${frames.length}`,
|
|
119
|
+
"---",
|
|
120
|
+
"",
|
|
121
|
+
`# Session ${sessionId.slice(0, 12)}`,
|
|
122
|
+
"",
|
|
123
|
+
summary,
|
|
124
|
+
"",
|
|
125
|
+
"## Frames",
|
|
126
|
+
"",
|
|
127
|
+
frameLinks
|
|
128
|
+
].join("\n");
|
|
129
|
+
writeFileSync(filepath, content);
|
|
130
|
+
return filepath;
|
|
131
|
+
}
|
|
132
|
+
/** Update the auto-maintained index.md */
|
|
133
|
+
async updateIndex() {
|
|
134
|
+
const framesDir = this.dirs.frames;
|
|
135
|
+
if (!existsSync(framesDir)) return;
|
|
136
|
+
const files = readdirSync(framesDir).filter((f) => f.endsWith(".md")).sort().reverse();
|
|
137
|
+
const frameLinks = files.slice(0, 100).map((f) => {
|
|
138
|
+
const name = f.replace(".md", "");
|
|
139
|
+
return `- [[frames/${name}]]`;
|
|
140
|
+
});
|
|
141
|
+
const typeCounts = {};
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
const match = f.match(/^(\w+)-/);
|
|
144
|
+
if (match) {
|
|
145
|
+
typeCounts[match[1]] = (typeCounts[match[1]] || 0) + 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const typeStats = Object.entries(typeCounts).map(([type, count]) => `| ${type} | ${count} |`).join("\n");
|
|
149
|
+
const sessionsDir = this.dirs.sessions;
|
|
150
|
+
const sessionFiles = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter(
|
|
151
|
+
(f) => f.endsWith(".md") && f !== "README.md"
|
|
152
|
+
) : [];
|
|
153
|
+
const sessionLinks = sessionFiles.slice(0, 20).map((f) => `- [[sessions/${f.replace(".md", "")}]]`);
|
|
154
|
+
const content = [
|
|
155
|
+
"---",
|
|
156
|
+
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
157
|
+
`total_frames: ${files.length}`,
|
|
158
|
+
"---",
|
|
159
|
+
"",
|
|
160
|
+
"# StackMemory Index",
|
|
161
|
+
"",
|
|
162
|
+
`> Auto-maintained by StackMemory. ${files.length} frames indexed.`,
|
|
163
|
+
"",
|
|
164
|
+
"## Frame Types",
|
|
165
|
+
"",
|
|
166
|
+
"| Type | Count |",
|
|
167
|
+
"|------|-------|",
|
|
168
|
+
typeStats,
|
|
169
|
+
"",
|
|
170
|
+
"## Recent Frames",
|
|
171
|
+
"",
|
|
172
|
+
...frameLinks.slice(0, 30),
|
|
173
|
+
files.length > 30 ? `
|
|
174
|
+
_...and ${files.length - 30} more_` : "",
|
|
175
|
+
"",
|
|
176
|
+
sessionLinks.length > 0 ? "## Sessions\n" : "",
|
|
177
|
+
...sessionLinks,
|
|
178
|
+
"",
|
|
179
|
+
"## Raw Ingest",
|
|
180
|
+
"",
|
|
181
|
+
"[[raw/README|Drop web clipper files in raw/]]"
|
|
182
|
+
].join("\n");
|
|
183
|
+
writeFileSync(join(this.dirs.root, "index.md"), content);
|
|
184
|
+
}
|
|
185
|
+
/** Serialize a Frame to Obsidian-flavored markdown */
|
|
186
|
+
serializeFrame(frame, events, anchors) {
|
|
187
|
+
const lines = [];
|
|
188
|
+
lines.push("---");
|
|
189
|
+
lines.push(`frame_id: "${frame.frame_id}"`);
|
|
190
|
+
lines.push(`type: "${frame.type}"`);
|
|
191
|
+
lines.push(`name: "${this.escapeYaml(frame.name)}"`);
|
|
192
|
+
lines.push(`state: "${frame.state}"`);
|
|
193
|
+
lines.push(`depth: ${frame.depth}`);
|
|
194
|
+
lines.push(`project_id: "${frame.project_id}"`);
|
|
195
|
+
lines.push(`run_id: "${frame.run_id}"`);
|
|
196
|
+
lines.push(`created_at: ${frame.created_at}`);
|
|
197
|
+
if (frame.closed_at) lines.push(`closed_at: ${frame.closed_at}`);
|
|
198
|
+
if (frame.parent_frame_id)
|
|
199
|
+
lines.push(`parent_frame_id: "${frame.parent_frame_id}"`);
|
|
200
|
+
lines.push(`tags: [stackmemory, ${frame.type}]`);
|
|
201
|
+
lines.push("---");
|
|
202
|
+
lines.push("");
|
|
203
|
+
lines.push(`# ${frame.name}`);
|
|
204
|
+
lines.push("");
|
|
205
|
+
if (frame.parent_frame_id) {
|
|
206
|
+
lines.push(
|
|
207
|
+
`Parent: [[${frame.type}-${frame.parent_frame_id.slice(0, 8)}]]`
|
|
208
|
+
);
|
|
209
|
+
lines.push("");
|
|
210
|
+
}
|
|
211
|
+
lines.push(
|
|
212
|
+
`**Type:** \`${frame.type}\` | **State:** \`${frame.state}\` | **Depth:** ${frame.depth}`
|
|
213
|
+
);
|
|
214
|
+
lines.push(`**Created:** ${new Date(frame.created_at).toISOString()}`);
|
|
215
|
+
if (frame.closed_at) {
|
|
216
|
+
const duration = Math.round((frame.closed_at - frame.created_at) / 1e3);
|
|
217
|
+
lines.push(
|
|
218
|
+
`**Closed:** ${new Date(frame.closed_at).toISOString()} (${duration}s)`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
lines.push("");
|
|
222
|
+
if (frame.digest_text) {
|
|
223
|
+
lines.push("## Digest");
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push(frame.digest_text);
|
|
226
|
+
lines.push("");
|
|
227
|
+
}
|
|
228
|
+
if (frame.inputs && Object.keys(frame.inputs).length > 0) {
|
|
229
|
+
lines.push("## Inputs");
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push("```json");
|
|
232
|
+
lines.push(JSON.stringify(frame.inputs, null, 2));
|
|
233
|
+
lines.push("```");
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
if (frame.outputs && Object.keys(frame.outputs).length > 0) {
|
|
237
|
+
lines.push("## Outputs");
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push("```json");
|
|
240
|
+
lines.push(JSON.stringify(frame.outputs, null, 2));
|
|
241
|
+
lines.push("```");
|
|
242
|
+
lines.push("");
|
|
243
|
+
}
|
|
244
|
+
if (anchors && anchors.length > 0) {
|
|
245
|
+
lines.push("## Anchors");
|
|
246
|
+
lines.push("");
|
|
247
|
+
for (const anchor of anchors) {
|
|
248
|
+
lines.push(`### ${anchor.type}: ${this.escapeYaml(anchor.content)}`);
|
|
249
|
+
lines.push("");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (this.config.includeEvents && events && events.length > 0) {
|
|
253
|
+
lines.push("## Events");
|
|
254
|
+
lines.push("");
|
|
255
|
+
for (const event of events.slice(-20)) {
|
|
256
|
+
const ts = new Date(event.ts).toISOString().substring(11, 19);
|
|
257
|
+
lines.push(
|
|
258
|
+
`- \`${ts}\` **${event.event_type}** ${this.summarizePayload(event.payload)}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
lines.push("");
|
|
262
|
+
}
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
}
|
|
265
|
+
/** Generate a filename for a frame */
|
|
266
|
+
frameFilename(frame) {
|
|
267
|
+
const id = frame.frame_id.slice(0, 8);
|
|
268
|
+
const name = frame.name.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/-+/g, "-").slice(0, 40);
|
|
269
|
+
return `${frame.type}-${id}-${name}.md`;
|
|
270
|
+
}
|
|
271
|
+
/** Escape special YAML characters */
|
|
272
|
+
escapeYaml(s) {
|
|
273
|
+
return s.replace(/"/g, '\\"').replace(/\n/g, " ");
|
|
274
|
+
}
|
|
275
|
+
/** Summarize event payload for compact display */
|
|
276
|
+
summarizePayload(payload) {
|
|
277
|
+
const str = JSON.stringify(payload);
|
|
278
|
+
return str.length > 120 ? str.slice(0, 117) + "..." : str;
|
|
279
|
+
}
|
|
280
|
+
// ── Raw File Watcher ──
|
|
281
|
+
/** Watch raw/ directory for new .md files from web clipper */
|
|
282
|
+
startWatching() {
|
|
283
|
+
if (!existsSync(this.dirs.raw)) return;
|
|
284
|
+
for (const f of readdirSync(this.dirs.raw)) {
|
|
285
|
+
this.seenRawFiles.add(f);
|
|
286
|
+
}
|
|
287
|
+
this.watcher = watch(this.dirs.raw, async (eventType, filename) => {
|
|
288
|
+
if (!filename || !filename.endsWith(".md")) return;
|
|
289
|
+
if (filename === "README.md") return;
|
|
290
|
+
if (this.seenRawFiles.has(filename)) return;
|
|
291
|
+
const filepath = join(this.dirs.raw, filename);
|
|
292
|
+
if (!existsSync(filepath)) return;
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
294
|
+
if (!existsSync(filepath)) return;
|
|
295
|
+
this.seenRawFiles.add(filename);
|
|
296
|
+
logger.info("Raw file detected for ingest", { filename });
|
|
297
|
+
try {
|
|
298
|
+
const content = readFileSync(filepath, "utf-8");
|
|
299
|
+
const metadata = this.parseClipperMetadata(content);
|
|
300
|
+
if (this.ingestCallback) {
|
|
301
|
+
await this.ingestCallback(filename, content, metadata);
|
|
302
|
+
}
|
|
303
|
+
logger.info("Ingested raw file", {
|
|
304
|
+
filename,
|
|
305
|
+
source: metadata.source || "unknown"
|
|
306
|
+
});
|
|
307
|
+
} catch (err) {
|
|
308
|
+
logger.error("Failed to ingest raw file", {
|
|
309
|
+
filename,
|
|
310
|
+
error: err.message
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
logger.info("Watching raw/ for web clipper files", { dir: this.dirs.raw });
|
|
315
|
+
}
|
|
316
|
+
/** Parse Obsidian Web Clipper YAML frontmatter */
|
|
317
|
+
parseClipperMetadata(content) {
|
|
318
|
+
const metadata = {};
|
|
319
|
+
if (!content.startsWith("---")) return metadata;
|
|
320
|
+
const end = content.indexOf("---", 3);
|
|
321
|
+
if (end === -1) return metadata;
|
|
322
|
+
const yaml = content.slice(3, end).trim();
|
|
323
|
+
for (const line of yaml.split("\n")) {
|
|
324
|
+
const match = line.match(/^(\w+):\s*(.+)/);
|
|
325
|
+
if (match) {
|
|
326
|
+
metadata[match[1]] = match[2].replace(/^["']|["']$/g, "");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return metadata;
|
|
330
|
+
}
|
|
331
|
+
/** Stop watching and clean up */
|
|
332
|
+
async cleanup() {
|
|
333
|
+
if (this.watcher) {
|
|
334
|
+
this.watcher.close();
|
|
335
|
+
this.watcher = null;
|
|
336
|
+
}
|
|
337
|
+
if (this.unregisterCreate) this.unregisterCreate();
|
|
338
|
+
if (this.unregisterClose) this.unregisterClose();
|
|
339
|
+
logger.info("Obsidian vault adapter cleaned up");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let _instance = null;
|
|
343
|
+
async function initObsidianVault() {
|
|
344
|
+
if (_instance) return _instance;
|
|
345
|
+
try {
|
|
346
|
+
const configPath = join(process.cwd(), ".stackmemory", "config.yaml");
|
|
347
|
+
if (!existsSync(configPath)) return null;
|
|
348
|
+
const content = readFileSync(configPath, "utf-8");
|
|
349
|
+
const vaultMatch = content.match(
|
|
350
|
+
/obsidian:\s*\n\s+vaultPath:\s*["']?([^\n"']+)/
|
|
351
|
+
);
|
|
352
|
+
if (!vaultMatch) return null;
|
|
353
|
+
const vaultPath = vaultMatch[1].trim();
|
|
354
|
+
if (!vaultPath || !existsSync(vaultPath)) {
|
|
355
|
+
logger.warn("Obsidian vaultPath configured but directory not found", {
|
|
356
|
+
vaultPath
|
|
357
|
+
});
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const subdirMatch = content.match(
|
|
361
|
+
/obsidian:\s*\n(?:\s+\w+:.*\n)*\s+subdir:\s*["']?([^\n"']+)/
|
|
362
|
+
);
|
|
363
|
+
const watchRawMatch = content.match(
|
|
364
|
+
/obsidian:\s*\n(?:\s+\w+:.*\n)*\s+watchRaw:\s*(true|false)/
|
|
365
|
+
);
|
|
366
|
+
const autoIndexMatch = content.match(
|
|
367
|
+
/obsidian:\s*\n(?:\s+\w+:.*\n)*\s+autoIndex:\s*(true|false)/
|
|
368
|
+
);
|
|
369
|
+
_instance = new ObsidianVaultAdapter({
|
|
370
|
+
vaultPath,
|
|
371
|
+
subdir: subdirMatch?.[1]?.trim(),
|
|
372
|
+
watchRaw: watchRawMatch ? watchRawMatch[1] === "true" : void 0,
|
|
373
|
+
autoIndex: autoIndexMatch ? autoIndexMatch[1] === "true" : void 0
|
|
374
|
+
});
|
|
375
|
+
await _instance.initialize();
|
|
376
|
+
logger.info("Obsidian vault adapter auto-initialized", { vaultPath });
|
|
377
|
+
return _instance;
|
|
378
|
+
} catch (err) {
|
|
379
|
+
logger.debug("Obsidian vault adapter not initialized", {
|
|
380
|
+
error: err.message
|
|
381
|
+
});
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function getObsidianVault() {
|
|
386
|
+
return _instance;
|
|
387
|
+
}
|
|
388
|
+
export {
|
|
389
|
+
ObsidianVaultAdapter,
|
|
390
|
+
getObsidianVault,
|
|
391
|
+
initObsidianVault
|
|
392
|
+
};
|