@hoverlover/cc-discord 0.3.3 → 0.4.0

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.
@@ -71,43 +71,88 @@ async function syncRuntimeContext({ hookEvent, hookInput }: { hookEvent: string;
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * Build memory context using QMD semantic search.
76
+ *
77
+ * Queries the `agent-memory` QMD collection and formats results into the
78
+ * same MEMORY CONTEXT format the downstream prompts expect.
79
+ */
74
80
  async function buildMemoryContext({ queryText, runtimeState }: { queryText: string; runtimeState: any }) {
75
- const memoryDbPath = join(DATA_DIR, "memory.db");
76
- const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
81
+ if (!queryText?.trim()) return "";
77
82
 
78
- let store: SqliteMemoryStore | undefined;
79
83
  try {
80
- store = new SqliteMemoryStore({ dbPath: memoryDbPath, logger: noopLogger });
81
- const coordinator = new MemoryCoordinator({ store, logger: noopLogger });
82
- await coordinator.init();
84
+ const { execSync } = await import("node:child_process");
85
+
86
+ // Use `qmd search` (BM25) for fast keyword recall (~0.3s).
87
+ // `qmd query` would give LLM-reranked results but is too slow for a hook.
88
+ // -n 8 returns up to 8 relevant chunks.
89
+ // --min-score 0.3 filters low-relevance noise.
90
+ const raw = execSync(
91
+ `qmd search ${JSON.stringify(queryText)} -n 8 --min-score 0.3 --json 2>/dev/null`,
92
+ { encoding: "utf-8", timeout: 8_000 },
93
+ );
83
94
 
84
- const packet = await coordinator.assembleContext({
85
- sessionKey: memorySessionKey,
86
- queryText,
87
- runtimeContextId: runtimeState?.runtimeContextId || null,
88
- runtimeEpoch: runtimeState?.runtimeEpoch || null,
89
- includeSnapshot: true,
90
- avoidCurrentRuntime: true,
91
- activeWindowSize: 12,
92
- maxCards: 6,
93
- maxRecallTurns: 8,
94
- maxTurnScan: 300,
95
- });
95
+ const results: Array<{ docid: string; score: number; file: string; title: string; snippet: string }> =
96
+ JSON.parse(raw || "[]");
97
+
98
+ if (results.length === 0) return "";
99
+
100
+ // Expand the top results into readable turn snippets using `qmd get`.
101
+ // Each snippet in the JSON output has a line reference — we use `qmd get`
102
+ // to retrieve a fuller window of surrounding content.
103
+ const lines: string[] = [];
104
+ for (const result of results.slice(0, 8)) {
105
+ // Extract the path + line from the qmd:// URI
106
+ const qmdPath = result.file.replace(/^qmd:\/\//, "");
107
+ const lineMatch = result.snippet.match(/@@ -(\d+)/);
108
+ const lineNum = lineMatch ? parseInt(lineMatch[1], 10) : 1;
96
109
 
97
- return coordinator.formatContextPacket(packet);
98
- } catch {
99
- return "";
100
- } finally {
101
- if (store) {
102
110
  try {
103
- await store.close();
111
+ const content = execSync(
112
+ `qmd get "${qmdPath}:${lineNum}" -l 12 2>/dev/null`,
113
+ { encoding: "utf-8", timeout: 5_000 },
114
+ ).trim();
115
+
116
+ if (content) {
117
+ // Clean up markdown headers/frontmatter noise, keep the conversation content
118
+ const cleaned = content
119
+ .split("\n")
120
+ .filter((l: string) => !l.startsWith("---") && !l.startsWith("session_key:") && !l.startsWith("agent_id:"))
121
+ .join("\n")
122
+ .trim();
123
+
124
+ if (cleaned) {
125
+ const scoreLabel = `${Math.round(result.score * 100)}%`;
126
+ lines.push(`- [${scoreLabel}] ${truncateForMemory(cleaned, 320)}`);
127
+ }
128
+ }
104
129
  } catch {
105
- /* ignore */
130
+ // Fall back to the snippet from the JSON
131
+ if (result.snippet) {
132
+ const snippetText = result.snippet
133
+ .replace(/@@ -\d+,?\d* @@.*\n?/, "")
134
+ .replace(/\(.*?before.*?after\)\n?/, "")
135
+ .trim();
136
+ if (snippetText) {
137
+ lines.push(`- [${Math.round(result.score * 100)}%] ${truncateForMemory(snippetText, 320)}`);
138
+ }
139
+ }
106
140
  }
107
141
  }
142
+
143
+ if (lines.length === 0) return "";
144
+ return `MEMORY CONTEXT:\nRelevant prior turns (semantic recall via QMD):\n${lines.join("\n")}`;
145
+ } catch {
146
+ return "";
108
147
  }
109
148
  }
110
149
 
150
+ function truncateForMemory(text: string, maxLen: number): string {
151
+ const oneLine = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
152
+ if (oneLine.length <= maxLen) return oneLine;
153
+ return `${oneLine.slice(0, maxLen - 1)}…`;
154
+ }
155
+
111
156
  let hookInput: any;
112
157
  try {
113
158
  const chunks: Buffer[] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoverlover/cc-discord",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Discord <-> Claude Code relay: use your Claude subscription to power per-channel AI bots",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Phase 1: Export memory.db turns to markdown files for QMD indexing.
4
+ *
5
+ * Reads all sessions and turns from the SQLite memory store and writes them
6
+ * as markdown files grouped by session into the Obsidian vault's
7
+ * agent-memory/conversations/ folder.
8
+ *
9
+ * Output directory: ~/Library/Mobile Documents/iCloud~md~obsidian/Documents/iCloud/agent-memory/
10
+ *
11
+ * File layout:
12
+ * agent-memory/
13
+ * conversations/
14
+ * <date>-<channel-id>.md (one file per session, named by date)
15
+ *
16
+ * Each file contains YAML-style frontmatter and all turns in order.
17
+ */
18
+
19
+ import Database from "bun:sqlite";
20
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ // ── Paths ──────────────────────────────────────────────────────────
25
+ const DATA_DIR = join(homedir(), ".cc-discord", "data");
26
+ const DB_PATH = join(DATA_DIR, "memory.db");
27
+ const OBSIDIAN_VAULT = join(
28
+ homedir(),
29
+ "Library",
30
+ "Mobile Documents",
31
+ "iCloud~md~obsidian",
32
+ "Documents",
33
+ "iCloud"
34
+ );
35
+ const AGENT_MEMORY_DIR = join(OBSIDIAN_VAULT, "agent-memory");
36
+ const CONVERSATIONS_DIR = join(AGENT_MEMORY_DIR, "conversations");
37
+
38
+ // ── Types ──────────────────────────────────────────────────────────
39
+ interface SessionRow {
40
+ session_key: string;
41
+ agent_id: string | null;
42
+ created_at: string;
43
+ updated_at: string;
44
+ }
45
+
46
+ interface TurnRow {
47
+ id: string;
48
+ session_key: string;
49
+ turn_index: number;
50
+ role: string;
51
+ content: string;
52
+ metadata_json: string | null;
53
+ created_at: string;
54
+ }
55
+
56
+ // ── Helpers ────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Build a human-friendly filename from session metadata.
60
+ * Format: YYYY-MM-DD-<channel-id>.md
61
+ */
62
+ function buildFilename(session: SessionRow): string {
63
+ // Extract date from created_at (ISO string → YYYY-MM-DD)
64
+ const date = session.created_at.slice(0, 10);
65
+ // Extract channel ID from session key (last segment after last colon)
66
+ const parts = session.session_key.split(":");
67
+ const channelId = parts[parts.length - 1] || "unknown";
68
+ return `${date}-${channelId}.md`;
69
+ }
70
+
71
+ /** Format a single turn as a markdown section. */
72
+ function formatTurn(turn: TurnRow): string {
73
+ const roleLabel = turn.role === "user" ? "🧑 User" : "🤖 Assistant";
74
+ const ts = turn.created_at ? ` _(${turn.created_at})_` : "";
75
+
76
+ let meta = "";
77
+ if (turn.metadata_json) {
78
+ try {
79
+ const parsed = JSON.parse(turn.metadata_json);
80
+ if (parsed.runtimeContextId) {
81
+ meta = `\n> Runtime context: \`${parsed.runtimeContextId}\` epoch ${parsed.runtimeEpoch ?? "?"}`;
82
+ }
83
+ } catch {
84
+ // ignore malformed JSON
85
+ }
86
+ }
87
+
88
+ return `### Turn ${turn.turn_index} — ${roleLabel}${ts}${meta}\n\n${turn.content}\n`;
89
+ }
90
+
91
+ // ── Main ───────────────────────────────────────────────────────────
92
+ function main() {
93
+ if (!existsSync(DB_PATH)) {
94
+ console.error(`❌ memory.db not found at ${DB_PATH}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ const db = new Database(DB_PATH, { readonly: true });
99
+
100
+ // Fetch sessions
101
+ const sessions = db
102
+ .query<SessionRow, []>(
103
+ `SELECT session_key, agent_id, created_at, updated_at
104
+ FROM memory_sessions ORDER BY created_at`
105
+ )
106
+ .all();
107
+
108
+ console.log(`📦 Found ${sessions.length} session(s) in memory.db`);
109
+
110
+ // Verify Obsidian vault exists
111
+ if (!existsSync(OBSIDIAN_VAULT)) {
112
+ console.error(`❌ Obsidian vault not found at ${OBSIDIAN_VAULT}`);
113
+ process.exit(1);
114
+ }
115
+
116
+ // Prepare output directories
117
+ mkdirSync(CONVERSATIONS_DIR, { recursive: true });
118
+ console.log(`📂 Output directory: ${CONVERSATIONS_DIR}`);
119
+
120
+ let totalTurns = 0;
121
+ let filesWritten = 0;
122
+
123
+ for (const session of sessions) {
124
+ const turns = db
125
+ .query<TurnRow, [string]>(
126
+ `SELECT id, session_key, turn_index, role, content, metadata_json, created_at
127
+ FROM memory_turns
128
+ WHERE session_key = ?
129
+ ORDER BY turn_index`
130
+ )
131
+ .all(session.session_key);
132
+
133
+ if (turns.length === 0) {
134
+ console.log(` ⏭ Skipping empty session: ${session.session_key}`);
135
+ continue;
136
+ }
137
+
138
+ totalTurns += turns.length;
139
+
140
+ // Build markdown with Obsidian-friendly filename
141
+ const filename = buildFilename(session);
142
+ const filePath = join(CONVERSATIONS_DIR, filename);
143
+
144
+ const frontmatter = [
145
+ "---",
146
+ `session_key: "${session.session_key}"`,
147
+ `agent_id: "${session.agent_id ?? ""}"`,
148
+ `source: cc-discord`,
149
+ `type: conversation`,
150
+ `created: "${session.created_at}"`,
151
+ `updated: "${session.updated_at}"`,
152
+ `turn_count: ${turns.length}`,
153
+ `first_turn: "${turns[0].created_at}"`,
154
+ `last_turn: "${turns[turns.length - 1].created_at}"`,
155
+ `tags:`,
156
+ ` - agent-memory`,
157
+ ` - conversation`,
158
+ ` - cc-discord`,
159
+ "---",
160
+ ].join("\n");
161
+
162
+ const header = `# Session: ${session.session_key}\n\n`;
163
+ const summary = `> **${turns.length} turns** · Agent \`${session.agent_id ?? "unknown"}\` · ${session.created_at} → ${session.updated_at}\n\n`;
164
+ const body = turns.map(formatTurn).join("\n---\n\n");
165
+
166
+ const markdown = `${frontmatter}\n\n${header}${summary}${body}`;
167
+
168
+ writeFileSync(filePath, markdown, "utf-8");
169
+ filesWritten++;
170
+ console.log(
171
+ ` ✅ ${filename} — ${turns.length} turn(s)`
172
+ );
173
+ }
174
+
175
+ db.close();
176
+
177
+ console.log(`\n🎉 Export complete!`);
178
+ console.log(` Sessions exported : ${filesWritten}`);
179
+ console.log(` Total turns : ${totalTurns}`);
180
+ console.log(` Output directory : ${CONVERSATIONS_DIR}`);
181
+ console.log(`\nNext steps:`);
182
+ console.log(` 1. qmd collection remove cc-discord-memory (remove old collection)`);
183
+ console.log(` 2. qmd collection add "${AGENT_MEMORY_DIR}" --name agent-memory`);
184
+ console.log(` 3. qmd embed`);
185
+ console.log(` 4. qmd query "test query" — verify semantic search`);
186
+ }
187
+
188
+ main();
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Quick test: verify QMD-based memory recall works end-to-end.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+
8
+ const queryText = "mattermost installation deployment";
9
+
10
+ console.log(`Query: "${queryText}"\n`);
11
+
12
+ const raw = execSync(
13
+ `qmd search ${JSON.stringify(queryText)} -n 8 --min-score 0.3 --json 2>/dev/null`,
14
+ { encoding: "utf-8", timeout: 8_000 },
15
+ );
16
+
17
+ const results: Array<{ docid: string; score: number; file: string; title: string; snippet: string }> =
18
+ JSON.parse(raw || "[]");
19
+
20
+ console.log(`QMD returned ${results.length} result(s)\n`);
21
+
22
+ const lines: string[] = [];
23
+ for (const result of results.slice(0, 8)) {
24
+ const qmdPath = result.file.replace(/^qmd:\/\//, "");
25
+ const lineMatch = result.snippet.match(/@@ -(\d+)/);
26
+ const lineNum = lineMatch ? parseInt(lineMatch[1], 10) : 1;
27
+
28
+ try {
29
+ const content = execSync(
30
+ `qmd get "${qmdPath}:${lineNum}" -l 12 2>/dev/null`,
31
+ { encoding: "utf-8", timeout: 5_000 },
32
+ ).trim();
33
+
34
+ if (content) {
35
+ const cleaned = content
36
+ .split("\n")
37
+ .filter((l: string) => !l.startsWith("---") && !l.startsWith("session_key:") && !l.startsWith("agent_id:"))
38
+ .join("\n")
39
+ .trim();
40
+
41
+ if (cleaned) {
42
+ const scoreLabel = `${Math.round(result.score * 100)}%`;
43
+ const oneLine = cleaned.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
44
+ lines.push(`- [${scoreLabel}] ${oneLine.slice(0, 320)}`);
45
+ }
46
+ }
47
+ } catch {
48
+ if (result.snippet) {
49
+ const snippetText = result.snippet
50
+ .replace(/@@ -\d+,?\d* @@.*\n?/, "")
51
+ .replace(/\(.*?before.*?after\)\n?/, "")
52
+ .trim();
53
+ if (snippetText) {
54
+ lines.push(`- [${Math.round(result.score * 100)}%] ${snippetText.replace(/\n/g, " ").slice(0, 320)}`);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ console.log("MEMORY CONTEXT:");
61
+ console.log("Relevant prior turns (semantic recall via QMD):");
62
+ for (const line of lines) {
63
+ console.log(line);
64
+ }
package/server/memory.ts CHANGED
@@ -1,51 +1,55 @@
1
1
  /**
2
2
  * Memory integration for the relay server.
3
- * Persists inbound/outbound turns and assembles memory context for context injection.
4
3
  *
5
- * Session key strategy:
6
- * When a channelId is provided, turns are written to a per-channel key
7
- * (discord:{sessionId}:{channelId}) so that per-channel subagents can
8
- * retrieve them. This matches the key that wait-for-discord-messages
9
- * uses on the read side (agentId = channelId).
4
+ * Writes new turns as markdown to the Obsidian vault's agent-memory folder
5
+ * and triggers QMD re-indexing. Reads are handled by the hook via `qmd query`.
6
+ *
7
+ * SQLite is retained solely for runtime-state tracking (context epochs).
10
8
  *
11
- * When no channelId is available, falls back to the legacy shared key
12
- * (discord:{sessionId}:{CLAUDE_AGENT_ID}).
9
+ * Session key strategy:
10
+ * When a channelId is provided, turns are appended to a per-channel
11
+ * conversation file. When no channelId is available, falls back to the
12
+ * legacy shared key.
13
13
  */
14
14
 
15
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
16
  import { join } from "node:path";
16
- import { MemoryCoordinator } from "../memory/core/MemoryCoordinator.ts";
17
17
  import { buildMemorySessionKey } from "../memory/core/session-key.ts";
18
18
  import { SqliteMemoryStore } from "../memory/providers/sqlite/SqliteMemoryStore.ts";
19
19
  import { CLAUDE_AGENT_ID, DATA_DIR, DISCORD_SESSION_ID } from "./config.ts";
20
20
 
21
+ // ── Paths ──────────────────────────────────────────────────────────
22
+ const OBSIDIAN_VAULT = join(
23
+ process.env.HOME || "",
24
+ "Library",
25
+ "Mobile Documents",
26
+ "iCloud~md~obsidian",
27
+ "Documents",
28
+ "iCloud",
29
+ );
30
+ const AGENT_MEMORY_DIR = join(OBSIDIAN_VAULT, "agent-memory");
31
+ const CONVERSATIONS_DIR = join(AGENT_MEMORY_DIR, "conversations");
32
+
21
33
  /** Legacy fallback key for turns without a channel association. */
22
34
  const fallbackSessionKey = buildMemorySessionKey({
23
35
  sessionId: DISCORD_SESSION_ID,
24
36
  agentId: CLAUDE_AGENT_ID,
25
37
  });
26
38
 
39
+ /**
40
+ * SQLite store is retained only for runtime-state tracking.
41
+ * It is NOT used for reading/writing memory turns.
42
+ */
27
43
  export const memoryStore = new SqliteMemoryStore({
28
44
  dbPath: join(DATA_DIR, "memory.db"),
29
45
  logger: console,
30
46
  });
47
+ await memoryStore.init();
31
48
 
32
- export const memory = new MemoryCoordinator({
33
- store: memoryStore,
34
- logger: console,
35
- });
36
-
37
- await memory.init();
49
+ // ── Helpers ────────────────────────────────────────────────────────
38
50
 
39
- /**
40
- * Resolve the memory session key for a turn.
41
- * If channelId is available, produces a per-channel key matching what
42
- * the subagent's wait-for-discord-messages will query.
43
- */
44
51
  function resolveSessionKey(channelId?: string): string {
45
52
  if (channelId) {
46
- // Subagents set AGENT_ID=channelId and build their key as:
47
- // buildMemorySessionKey({ sessionId, agentId: channelId })
48
- // => discord:{sessionId}:{channelId}
49
53
  return buildMemorySessionKey({
50
54
  sessionId: DISCORD_SESSION_ID,
51
55
  agentId: channelId,
@@ -54,6 +58,83 @@ function resolveSessionKey(channelId?: string): string {
54
58
  return fallbackSessionKey;
55
59
  }
56
60
 
61
+ /**
62
+ * Build the filename for a conversation file.
63
+ * Format: <date>-<channelId>.md (matches export script naming).
64
+ */
65
+ function resolveConversationFile(sessionKey: string): string {
66
+ const parts = sessionKey.split(":");
67
+ const channelId = parts[parts.length - 1] || "unknown";
68
+
69
+ // Look for an existing file matching this channel
70
+ const { readdirSync } = require("node:fs");
71
+ mkdirSync(CONVERSATIONS_DIR, { recursive: true });
72
+ const files: string[] = readdirSync(CONVERSATIONS_DIR);
73
+ const existing = files.find((f: string) => f.endsWith(`-${channelId}.md`));
74
+ if (existing) return join(CONVERSATIONS_DIR, existing);
75
+
76
+ // No existing file — create a new one with today's date
77
+ const today = new Date().toISOString().slice(0, 10);
78
+ return join(CONVERSATIONS_DIR, `${today}-${channelId}.md`);
79
+ }
80
+
81
+ /**
82
+ * Format a turn as markdown, matching the export format.
83
+ */
84
+ function formatTurnMarkdown(role: string, content: string, metadata: any): string {
85
+ const roleLabel = role === "user" ? "🧑 User" : "🤖 Assistant";
86
+ const ts = new Date().toISOString();
87
+
88
+ let meta = "";
89
+ if (metadata?.runtimeContextId) {
90
+ meta = `\n> Runtime context: \`${metadata.runtimeContextId}\` epoch ${metadata.runtimeEpoch ?? "?"}`;
91
+ }
92
+
93
+ // We use "Turn ?" because exact index doesn't matter for QMD search
94
+ return `\n---\n\n### Turn — ${roleLabel} _(${ts})_${meta}\n\n${content}\n`;
95
+ }
96
+
97
+ /**
98
+ * Create a new conversation file with frontmatter.
99
+ */
100
+ function createConversationFile(filePath: string, sessionKey: string, agentId: string): void {
101
+ const now = new Date().toISOString();
102
+ const frontmatter = [
103
+ "---",
104
+ `session_key: "${sessionKey}"`,
105
+ `agent_id: "${agentId}"`,
106
+ `source: cc-discord`,
107
+ `type: conversation`,
108
+ `created: "${now}"`,
109
+ `updated: "${now}"`,
110
+ `tags:`,
111
+ ` - agent-memory`,
112
+ ` - conversation`,
113
+ ` - cc-discord`,
114
+ "---",
115
+ "",
116
+ `# Session: ${sessionKey}`,
117
+ "",
118
+ ].join("\n");
119
+
120
+ writeFileSync(filePath, frontmatter, "utf-8");
121
+ }
122
+
123
+ /**
124
+ * Trigger a background QMD re-index so new turns become searchable.
125
+ * Non-blocking — we don't wait for it to finish.
126
+ */
127
+ function triggerQmdReindex(): void {
128
+ try {
129
+ const { exec } = require("node:child_process");
130
+ exec("qmd update 2>/dev/null", { timeout: 30_000 });
131
+ } catch {
132
+ // Best-effort; QMD will catch up on next explicit update or query
133
+ }
134
+ }
135
+
136
+ // ── Main API ───────────────────────────────────────────────────────
137
+
57
138
  export async function appendMemoryTurn({
58
139
  role,
59
140
  content,
@@ -68,21 +149,40 @@ export async function appendMemoryTurn({
68
149
  const sessionKey = resolveSessionKey(channelId);
69
150
  const runtimeState = await memoryStore.readRuntimeState(sessionKey);
70
151
 
71
- const result = await memory.appendTurn({
72
- sessionKey,
73
- agentId: channelId || CLAUDE_AGENT_ID,
74
- role,
75
- content,
76
- metadata: {
77
- ...metadata,
78
- runtimeContextId: runtimeState?.runtimeContextId || null,
79
- runtimeEpoch: runtimeState?.runtimeEpoch || null,
80
- },
81
- });
82
- console.log(
83
- `[Memory] persisted ${role} turn to ${sessionKey} (batch=${result?.batchId}, turns=${result?.counts?.turns})`,
84
- );
152
+ const enrichedMetadata = {
153
+ ...metadata,
154
+ runtimeContextId: runtimeState?.runtimeContextId || null,
155
+ runtimeEpoch: runtimeState?.runtimeEpoch || null,
156
+ };
157
+
158
+ // Resolve (or create) the conversation markdown file
159
+ const filePath = resolveConversationFile(sessionKey);
160
+
161
+ if (!existsSync(filePath)) {
162
+ createConversationFile(filePath, sessionKey, channelId || CLAUDE_AGENT_ID);
163
+ }
164
+
165
+ // Append the new turn
166
+ const turnMd = formatTurnMarkdown(role, content, enrichedMetadata);
167
+ appendFileSync(filePath, turnMd, "utf-8");
168
+
169
+ // Update the frontmatter "updated" timestamp
170
+ try {
171
+ const raw = readFileSync(filePath, "utf-8");
172
+ const updated = raw.replace(
173
+ /^updated: ".*"$/m,
174
+ `updated: "${new Date().toISOString()}"`,
175
+ );
176
+ if (updated !== raw) writeFileSync(filePath, updated, "utf-8");
177
+ } catch {
178
+ /* best-effort */
179
+ }
180
+
181
+ // Trigger non-blocking QMD re-index
182
+ triggerQmdReindex();
183
+
184
+ console.log(`[Memory/QMD] persisted ${role} turn to ${filePath}`);
85
185
  } catch (err: unknown) {
86
- console.error("[Memory] failed to persist turn:", (err as Error).message);
186
+ console.error("[Memory/QMD] failed to persist turn:", (err as Error).message);
87
187
  }
88
188
  }