@hoverlover/cc-discord 0.3.2 → 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.
- package/hooks/check-discord-messages.ts +70 -25
- package/hooks/track-activity.ts +16 -5
- package/package.json +1 -1
- package/scripts/migrate-memory-to-qmd.ts +188 -0
- package/scripts/test-qmd-recall.ts +64 -0
- package/server/db.ts +27 -7
- package/server/memory.ts +138 -38
- package/server/trace-thread.ts +33 -18
- package/tools/send-discord.ts +20 -0
|
@@ -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
|
-
|
|
76
|
-
const memorySessionKey = buildMemorySessionKey({ sessionId, agentId });
|
|
81
|
+
if (!queryText?.trim()) return "";
|
|
77
82
|
|
|
78
|
-
let store: SqliteMemoryStore | undefined;
|
|
79
83
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/hooks/track-activity.ts
CHANGED
|
@@ -43,6 +43,9 @@ const toolInput = input.tool_input || input.toolInput || null;
|
|
|
43
43
|
let db: InstanceType<typeof DatabaseSync>;
|
|
44
44
|
try {
|
|
45
45
|
db = new DatabaseSync(dbPath);
|
|
46
|
+
// WAL mode for better concurrency with the relay server's flush loop
|
|
47
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
48
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
46
49
|
} catch {
|
|
47
50
|
process.exit(0);
|
|
48
51
|
}
|
|
@@ -107,7 +110,9 @@ try {
|
|
|
107
110
|
INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
|
|
108
111
|
VALUES (?, ?, ?, 'tool_start', ?, ?)
|
|
109
112
|
`).run(sessionId, agentId, traceChannelId, toolName || "tool", summary);
|
|
110
|
-
} catch {
|
|
113
|
+
} catch {
|
|
114
|
+
/* fail-open */
|
|
115
|
+
}
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
process.exit(0);
|
|
@@ -118,17 +123,21 @@ try {
|
|
|
118
123
|
let elapsedTag = "";
|
|
119
124
|
if (traceEnabled && hookEvent === "PostToolUse") {
|
|
120
125
|
try {
|
|
121
|
-
const row = db
|
|
126
|
+
const row = db
|
|
127
|
+
.prepare(`
|
|
122
128
|
SELECT started_at FROM agent_activity
|
|
123
129
|
WHERE session_id = ? AND agent_id = ?
|
|
124
|
-
`)
|
|
130
|
+
`)
|
|
131
|
+
.get(sessionId, agentId) as { started_at?: string } | undefined;
|
|
125
132
|
if (row?.started_at) {
|
|
126
133
|
const elapsedMs = Date.now() - new Date(row.started_at).getTime();
|
|
127
134
|
if (elapsedMs >= 0 && elapsedMs < 600_000) {
|
|
128
135
|
elapsedTag = `elapsed:${elapsedMs}|`;
|
|
129
136
|
}
|
|
130
137
|
}
|
|
131
|
-
} catch {
|
|
138
|
+
} catch {
|
|
139
|
+
/* best-effort */
|
|
140
|
+
}
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
db.prepare(`
|
|
@@ -157,7 +166,9 @@ try {
|
|
|
157
166
|
INSERT INTO trace_events (session_id, agent_id, channel_id, event_type, tool_name, summary)
|
|
158
167
|
VALUES (?, ?, ?, 'tool_end', ?, ?)
|
|
159
168
|
`).run(sessionId, agentId, traceChannelId, toolName || "tool", `${elapsedTag}${summary}`);
|
|
160
|
-
} catch {
|
|
169
|
+
} catch {
|
|
170
|
+
/* fail-open */
|
|
171
|
+
}
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
} catch {
|
package/package.json
CHANGED
|
@@ -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/db.ts
CHANGED
|
@@ -11,6 +11,11 @@ mkdirSync(DATA_DIR, { recursive: true });
|
|
|
11
11
|
|
|
12
12
|
export const db = new DatabaseSync(join(DATA_DIR, "messages.db"));
|
|
13
13
|
|
|
14
|
+
// Enable WAL mode for better concurrent access between relay server and hook processes.
|
|
15
|
+
// WAL allows readers and writers to operate concurrently without SQLITE_BUSY errors.
|
|
16
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
17
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
18
|
+
|
|
14
19
|
db.exec(`
|
|
15
20
|
CREATE TABLE IF NOT EXISTS messages (
|
|
16
21
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -170,14 +175,29 @@ export function getPendingTraceEvents(limit: number = 50): TraceEvent[] {
|
|
|
170
175
|
}
|
|
171
176
|
}
|
|
172
177
|
|
|
173
|
-
export function markTraceEventsPosted(ids: number[]) {
|
|
174
|
-
if (!ids.length) return;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
export function markTraceEventsPosted(ids: number[]): boolean {
|
|
179
|
+
if (!ids.length) return true;
|
|
180
|
+
// Retry up to 3 times to handle SQLITE_BUSY from concurrent hook writes
|
|
181
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
182
|
+
try {
|
|
183
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
184
|
+
db.prepare(`UPDATE trace_events SET posted = 1 WHERE id IN (${placeholders})`).run(...ids);
|
|
185
|
+
return true;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const code = (err as any)?.code;
|
|
188
|
+
if (code === "SQLITE_BUSY" && attempt < 2) {
|
|
189
|
+
// Brief sync delay before retry (Bun doesn't have Atomics.wait, use a spin)
|
|
190
|
+
const end = Date.now() + 50 * (attempt + 1);
|
|
191
|
+
while (Date.now() < end) {
|
|
192
|
+
/* spin wait */
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
console.error(`[Trace] markTraceEventsPosted failed (attempt ${attempt + 1}/3, ${ids.length} ids):`, err);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
180
199
|
}
|
|
200
|
+
return false;
|
|
181
201
|
}
|
|
182
202
|
|
|
183
203
|
export function insertTraceEvent(
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
}
|
package/server/trace-thread.ts
CHANGED
|
@@ -6,17 +6,8 @@
|
|
|
6
6
|
* hooks write → trace_events table → flush loop reads → batches → posts to thread
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
type Client,
|
|
12
|
-
type TextChannel,
|
|
13
|
-
type ThreadChannel,
|
|
14
|
-
} from "discord.js";
|
|
15
|
-
import {
|
|
16
|
-
TRACE_FLUSH_INTERVAL_MS,
|
|
17
|
-
TRACE_THREAD_ENABLED,
|
|
18
|
-
TRACE_THREAD_NAME,
|
|
19
|
-
} from "./config.ts";
|
|
9
|
+
import { ChannelType, type Client, type TextChannel, type ThreadChannel } from "discord.js";
|
|
10
|
+
import { TRACE_FLUSH_INTERVAL_MS, TRACE_THREAD_ENABLED, TRACE_THREAD_NAME } from "./config.ts";
|
|
20
11
|
import {
|
|
21
12
|
getPendingTraceEvents,
|
|
22
13
|
getTraceThreadId,
|
|
@@ -241,7 +232,7 @@ function formatTimestamp(iso: string): string {
|
|
|
241
232
|
/** Clean up text for display — convert literal \n to real newlines, preserve full content. */
|
|
242
233
|
function cleanWhitespace(text: string): string {
|
|
243
234
|
return String(text || "")
|
|
244
|
-
.replace(/\\n/g, "\n")
|
|
235
|
+
.replace(/\\n/g, "\n") // convert literal \n to real newlines
|
|
245
236
|
.replace(/[ \t]+/g, " ") // collapse horizontal whitespace (but keep newlines)
|
|
246
237
|
.trim();
|
|
247
238
|
}
|
|
@@ -284,14 +275,25 @@ async function flushTraceEvents(client: Client) {
|
|
|
284
275
|
return true;
|
|
285
276
|
});
|
|
286
277
|
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
278
|
+
// If all events were filtered out, mark them as posted immediately
|
|
279
|
+
if (!meaningful.length) {
|
|
280
|
+
postedIds.push(...channelEvents.map((e) => e.id));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
291
283
|
|
|
292
284
|
try {
|
|
293
285
|
const thread = await ensureTraceThread(client, channelId);
|
|
294
|
-
if (!thread)
|
|
286
|
+
if (!thread) {
|
|
287
|
+
// Can't get thread (cooldown or missing channel) — DON'T mark as posted
|
|
288
|
+
// so they'll be retried after cooldown expires. To prevent infinite
|
|
289
|
+
// growth, mark them posted if they're older than the cooldown period.
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
for (const e of channelEvents) {
|
|
292
|
+
const age = now - new Date(e.created_at).getTime();
|
|
293
|
+
if (age > FAILURE_COOLDOWN_MS) postedIds.push(e.id);
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
295
297
|
|
|
296
298
|
// Batch into a single message (Discord max 2000 chars)
|
|
297
299
|
const lines = meaningful.map(formatTraceEvent);
|
|
@@ -300,6 +302,9 @@ async function flushTraceEvents(client: Client) {
|
|
|
300
302
|
for (const batch of batches) {
|
|
301
303
|
await thread.send(batch);
|
|
302
304
|
}
|
|
305
|
+
|
|
306
|
+
// Only mark as posted AFTER successful Discord send
|
|
307
|
+
postedIds.push(...channelEvents.map((e) => e.id));
|
|
303
308
|
} catch (err) {
|
|
304
309
|
const code = (err as any)?.code;
|
|
305
310
|
if (code === 50001 || code === 50013) {
|
|
@@ -307,13 +312,23 @@ async function flushTraceEvents(client: Client) {
|
|
|
307
312
|
console.warn(`[Trace] No access to trace thread for channel ${channelId} (${code}) — backing off 5m`);
|
|
308
313
|
threadCache.delete(channelId);
|
|
309
314
|
failedChannels.set(channelId, Date.now());
|
|
315
|
+
// Mark as posted to avoid infinite retry on permanent access errors
|
|
316
|
+
postedIds.push(...channelEvents.map((e) => e.id));
|
|
310
317
|
} else {
|
|
311
318
|
console.error(`[Trace] Failed to post trace events for channel ${channelId}:`, err);
|
|
319
|
+
// Don't mark as posted — they'll be retried on next flush
|
|
312
320
|
}
|
|
313
321
|
}
|
|
314
322
|
}
|
|
315
323
|
|
|
316
|
-
|
|
324
|
+
if (postedIds.length > 0) {
|
|
325
|
+
const success = markTraceEventsPosted(postedIds);
|
|
326
|
+
if (!success) {
|
|
327
|
+
console.error(
|
|
328
|
+
`[Trace] CRITICAL: Failed to mark ${postedIds.length} events as posted — duplicates likely on next flush`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
317
332
|
}
|
|
318
333
|
|
|
319
334
|
/** Split lines into batches that fit within maxLen characters.
|
package/tools/send-discord.ts
CHANGED
|
@@ -16,6 +16,21 @@ const textParts: string[] = [];
|
|
|
16
16
|
|
|
17
17
|
for (let i = 0; i < args.length; i++) {
|
|
18
18
|
const arg = args[i];
|
|
19
|
+
if (arg === "--help" || arg === "-h") {
|
|
20
|
+
console.log(`
|
|
21
|
+
Usage: send-discord [--channel <id>] [--reply <messageId>] "message"
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--channel Target channel ID (defaults to AGENT_ID env var)
|
|
25
|
+
--reply Message ID to reply to
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
send-discord "Build started"
|
|
29
|
+
send-discord --channel 123456789012345678 "Hello from Claude"
|
|
30
|
+
send-discord --reply 123456789012345678 "Thanks, on it"
|
|
31
|
+
`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
19
34
|
if (arg === "--channel" && args[i + 1]) {
|
|
20
35
|
channelId = args[++i];
|
|
21
36
|
continue;
|
|
@@ -24,6 +39,11 @@ for (let i = 0; i < args.length; i++) {
|
|
|
24
39
|
replyTo = args[++i];
|
|
25
40
|
continue;
|
|
26
41
|
}
|
|
42
|
+
// Reject unrecognized flags — don't let them become message content
|
|
43
|
+
if (arg.startsWith("--")) {
|
|
44
|
+
console.error(`Unknown flag: ${arg}\nUse --help for usage.`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
27
47
|
textParts.push(arg);
|
|
28
48
|
}
|
|
29
49
|
|