@hoverlover/cc-discord 0.3.3 → 0.5.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/.claude/skills/release/SKILL.md +197 -0
- package/hooks/check-discord-messages.ts +70 -25
- package/package.json +1 -1
- package/scripts/migrate-memory-to-qmd.ts +188 -0
- package/scripts/test-qmd-recall.ts +64 -0
- package/server/catchup.ts +36 -2
- package/server/config.ts +18 -0
- package/server/db.ts +9 -0
- package/server/index.ts +38 -3
- package/server/memory.ts +138 -38
- package/server/messages.ts +8 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Release
|
|
3
|
+
description: Analyze commits since last release and publish with intelligent semantic version bumping.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Release
|
|
7
|
+
|
|
8
|
+
Analyze all commits since the last version tag, determine the appropriate semantic version bump (major/minor/patch), and publish to npm.
|
|
9
|
+
|
|
10
|
+
## Version Bump Rules
|
|
11
|
+
|
|
12
|
+
Semantic versioning: `MAJOR.MINOR.PATCH`
|
|
13
|
+
|
|
14
|
+
| Change Type | Bump | Indicators |
|
|
15
|
+
|-------------|------|------------|
|
|
16
|
+
| **Breaking** | MAJOR | `BREAKING CHANGE:`, `!:` suffix, API removals, incompatible changes |
|
|
17
|
+
| **Feature** | MINOR | `feat:`, `feature:`, `add:`, new functionality, enhancements |
|
|
18
|
+
| **Bugfix** | PATCH | `fix:`, `bugfix:`, `patch:`, corrections, typos, small improvements |
|
|
19
|
+
|
|
20
|
+
The highest-impact change determines the bump (MAJOR > MINOR > PATCH).
|
|
21
|
+
|
|
22
|
+
## Instructions
|
|
23
|
+
|
|
24
|
+
### Step 1: Gather Release Context
|
|
25
|
+
|
|
26
|
+
Run these commands in parallel:
|
|
27
|
+
|
|
28
|
+
1. Get the last version tag:
|
|
29
|
+
```bash
|
|
30
|
+
git describe --tags --abbrev=0
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
2. Get commits since last tag:
|
|
34
|
+
```bash
|
|
35
|
+
git log $(git describe --tags --abbrev=0)..HEAD --oneline
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
3. Check npm login status:
|
|
39
|
+
```bash
|
|
40
|
+
npm whoami 2>/dev/null || echo "NOT_LOGGED_IN"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Step 2: Verify npm Authentication
|
|
44
|
+
|
|
45
|
+
Check the result of `npm whoami`:
|
|
46
|
+
|
|
47
|
+
**If logged in** (returns a username): Proceed to Step 3.
|
|
48
|
+
|
|
49
|
+
**If not logged in** (returns "NOT_LOGGED_IN" or error): Use browser-based authentication:
|
|
50
|
+
|
|
51
|
+
1. Connect to Chrome (with retry logic):
|
|
52
|
+
- Call `mcp__claude-in-chrome__tabs_context_mcp` with `createIfEmpty: true`
|
|
53
|
+
- If connection fails, wait 2 seconds and retry (up to 3 times)
|
|
54
|
+
- If connected, create a new tab using `mcp__claude-in-chrome__tabs_create_mcp`
|
|
55
|
+
- Note whether Chrome is available for later steps
|
|
56
|
+
|
|
57
|
+
2. Start `npm login` in the terminal with a pseudo-terminal to get the login URL:
|
|
58
|
+
```bash
|
|
59
|
+
script -q /dev/null npm login --auth-type=web 2>&1
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
3. The command will output a URL like `https://www.npmjs.com/login?next=/login/cli/...`
|
|
63
|
+
|
|
64
|
+
4. Open the URL:
|
|
65
|
+
- **If Chrome connected:** Navigate the browser tab to that URL using `mcp__claude-in-chrome__navigate`
|
|
66
|
+
- **If Chrome unavailable:** Display the URL and ask user to open it manually
|
|
67
|
+
|
|
68
|
+
5. Inform the user: "Please complete the npm login in the browser window."
|
|
69
|
+
|
|
70
|
+
6. Wait for the login command to complete (it polls for authentication)
|
|
71
|
+
|
|
72
|
+
7. Verify login succeeded:
|
|
73
|
+
```bash
|
|
74
|
+
npm whoami
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
8. If still not logged in, ask user if they want to retry or abort
|
|
78
|
+
|
|
79
|
+
### Step 3: Analyze Commits
|
|
80
|
+
|
|
81
|
+
For each commit since the last tag, classify it:
|
|
82
|
+
|
|
83
|
+
**Conventional Commit Patterns (primary):**
|
|
84
|
+
- `fix:`, `fix(scope):` → PATCH
|
|
85
|
+
- `feat:`, `feat(scope):` → MINOR
|
|
86
|
+
- `BREAKING CHANGE:` in body or `!:` → MAJOR
|
|
87
|
+
- `docs:`, `chore:`, `style:`, `refactor:`, `test:` → PATCH (maintenance)
|
|
88
|
+
|
|
89
|
+
**Content Analysis (for non-conventional commits):**
|
|
90
|
+
- Look at the commit message semantics
|
|
91
|
+
- "Add", "Implement", "Introduce" → likely MINOR
|
|
92
|
+
- "Fix", "Correct", "Repair", "Resolve" → likely PATCH
|
|
93
|
+
- "Remove", "Delete API", "Breaking" → likely MAJOR
|
|
94
|
+
- "Update", "Bump", "Improve" → likely PATCH
|
|
95
|
+
- Release commits ("Bump installer to...") → skip (don't count)
|
|
96
|
+
|
|
97
|
+
**When uncertain:** Default to PATCH unless the change clearly adds new functionality.
|
|
98
|
+
|
|
99
|
+
### Step 4: Present Analysis
|
|
100
|
+
|
|
101
|
+
Show the user:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Analyzing commits since vX.Y.Z...
|
|
105
|
+
|
|
106
|
+
Commit Analysis:
|
|
107
|
+
[hash] [message] → [classification] ([bump type])
|
|
108
|
+
[hash] [message] → [classification] ([bump type])
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
Summary:
|
|
112
|
+
Breaking changes: N
|
|
113
|
+
New features: N
|
|
114
|
+
Bugfixes/maintenance: N
|
|
115
|
+
|
|
116
|
+
Highest impact: [MAJOR|MINOR|PATCH]
|
|
117
|
+
Proposed version: vX.Y.Z → vA.B.C
|
|
118
|
+
|
|
119
|
+
Proceed with release?
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Wait for user confirmation before proceeding.
|
|
123
|
+
|
|
124
|
+
### Step 5: Execute Release
|
|
125
|
+
|
|
126
|
+
#### 5a. Connect to Chrome Browser
|
|
127
|
+
|
|
128
|
+
Before starting the release, establish a Chrome connection for browser-based npm authentication:
|
|
129
|
+
|
|
130
|
+
1. Call `mcp__claude-in-chrome__tabs_context_mcp` with `createIfEmpty: true`
|
|
131
|
+
2. If the connection fails (extension not connected error):
|
|
132
|
+
- Wait 2 seconds and retry
|
|
133
|
+
- Retry up to 3 times total
|
|
134
|
+
- If all retries fail, inform the user: "Chrome browser extension is not available. You'll need to open the npm auth URL manually when prompted."
|
|
135
|
+
3. If connected, create a new tab using `mcp__claude-in-chrome__tabs_create_mcp`
|
|
136
|
+
4. Store the tab ID for later use
|
|
137
|
+
|
|
138
|
+
#### 5b. Run Release Script
|
|
139
|
+
|
|
140
|
+
Run the release script in background mode to capture the auth URL:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
script -q /dev/null ./scripts/release.sh [patch|minor|major] 2>&1
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The script will:
|
|
147
|
+
1. Validate agent configurations
|
|
148
|
+
2. Bump version in package.json files
|
|
149
|
+
3. Commit and tag the release
|
|
150
|
+
4. Push to git
|
|
151
|
+
5. Publish to npm with browser-based 2FA
|
|
152
|
+
|
|
153
|
+
#### 5c. Handle npm Authentication
|
|
154
|
+
|
|
155
|
+
Monitor the script output for the auth URL. When you see:
|
|
156
|
+
```
|
|
157
|
+
Authenticate your account at:
|
|
158
|
+
https://www.npmjs.com/auth/cli/[unique-id]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**If Chrome is connected:**
|
|
162
|
+
1. Navigate the browser tab to that URL using `mcp__claude-in-chrome__navigate`
|
|
163
|
+
2. Inform the user: "Please complete the npm authentication in the browser."
|
|
164
|
+
|
|
165
|
+
**If Chrome is not available:**
|
|
166
|
+
1. Display the full URL to the user
|
|
167
|
+
2. Inform them: "Please open this URL in your browser to authenticate."
|
|
168
|
+
|
|
169
|
+
Wait for the script to complete (npm polls for authentication).
|
|
170
|
+
|
|
171
|
+
### Step 6: Report Results
|
|
172
|
+
|
|
173
|
+
After successful release, show:
|
|
174
|
+
- New version number
|
|
175
|
+
- npm package URL
|
|
176
|
+
- Git tag created
|
|
177
|
+
|
|
178
|
+
## Edge Cases
|
|
179
|
+
|
|
180
|
+
**No commits since last tag:**
|
|
181
|
+
- Inform user there's nothing to release
|
|
182
|
+
- Ask if they want to force a patch bump anyway
|
|
183
|
+
|
|
184
|
+
**All commits are release/chore commits:**
|
|
185
|
+
- Default to PATCH bump
|
|
186
|
+
- Note that only maintenance commits were found
|
|
187
|
+
|
|
188
|
+
**Mixed signals in a commit:**
|
|
189
|
+
- If a commit adds a feature but also fixes a bug, classify by primary intent
|
|
190
|
+
- When truly ambiguous, ask the user
|
|
191
|
+
|
|
192
|
+
## Safety Rules
|
|
193
|
+
|
|
194
|
+
- Always show analysis and get confirmation before releasing
|
|
195
|
+
- Verify npm login before attempting to publish (use browser auth if needed)
|
|
196
|
+
- If any step fails, stop and report the error
|
|
197
|
+
- Don't push or publish without explicit user approval
|
|
@@ -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/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/catchup.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Client } from "discord.js";
|
|
7
|
-
import { CATCHUP_MESSAGE_LIMIT, IGNORED_CHANNEL_IDS, ALLOWED_CHANNEL_IDS, isAllowedUser } from "./config.ts";
|
|
7
|
+
import { CATCHUP_MESSAGE_LIMIT, IGNORED_CHANNEL_IDS, ALLOWED_CHANNEL_IDS, TRACE_THREAD_NAME, isAllowedUser } from "./config.ts";
|
|
8
|
+
import { isTraceThread } from "./db.ts";
|
|
8
9
|
import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
|
|
9
10
|
import { startTypingIndicator } from "./typing.ts";
|
|
10
11
|
|
|
@@ -15,6 +16,7 @@ export async function catchUpMissedMessages(client: Client) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
let channelsScanned = 0;
|
|
19
|
+
let threadsCaughtUp = 0;
|
|
18
20
|
let messagesCaughtUp = 0;
|
|
19
21
|
|
|
20
22
|
for (const [, guild] of client.guilds.cache) {
|
|
@@ -47,8 +49,40 @@ export async function catchUpMissedMessages(client: Client) {
|
|
|
47
49
|
} catch (err: unknown) {
|
|
48
50
|
console.error(`[Catchup] Failed to fetch messages for #${channel.name} (${channel.id}):`, (err as Error).message);
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
// Catch up active threads in this channel
|
|
54
|
+
if ("threads" in channel && channel.threads) {
|
|
55
|
+
try {
|
|
56
|
+
const activeThreads = await (channel as any).threads.fetchActive();
|
|
57
|
+
for (const [, thread] of activeThreads.threads) {
|
|
58
|
+
if (thread.name === TRACE_THREAD_NAME || isTraceThread(thread.id)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const threadMessages = await thread.messages.fetch({ limit: CATCHUP_MESSAGE_LIMIT });
|
|
61
|
+
threadsCaughtUp++;
|
|
62
|
+
const sorted = [...threadMessages.values()]
|
|
63
|
+
.filter((m: any) => !m.author.bot && isAllowedUser(m.author.id))
|
|
64
|
+
.sort((a: any, b: any) => a.createdTimestamp - b.createdTimestamp);
|
|
65
|
+
let threadHasNew = false;
|
|
66
|
+
for (const msg of sorted) {
|
|
67
|
+
const isNew = await persistInboundDiscordMessage(msg);
|
|
68
|
+
if (isNew) {
|
|
69
|
+
messagesCaughtUp++;
|
|
70
|
+
threadHasNew = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (threadHasNew) {
|
|
74
|
+
startTypingIndicator(client, thread.id, persistOutboundDiscordMessage);
|
|
75
|
+
}
|
|
76
|
+
} catch (err: unknown) {
|
|
77
|
+
console.error(`[Catchup] Failed for thread "${thread.name}" (${thread.id}):`, (err as Error).message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
console.error(`[Catchup] Failed to fetch threads for #${(channel as any).name}:`, (err as Error).message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
console.log(`[Catchup] Done — scanned ${channelsScanned} channel(s), caught up ${messagesCaughtUp} message(s)`);
|
|
87
|
+
console.log(`[Catchup] Done — scanned ${channelsScanned} channel(s), ${threadsCaughtUp} thread(s), caught up ${messagesCaughtUp} message(s)`);
|
|
54
88
|
}
|
package/server/config.ts
CHANGED
|
@@ -94,6 +94,24 @@ export function isAllowedChannel(channelId: string): boolean {
|
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Thread-aware channel check for incoming messages.
|
|
99
|
+
* Allows threads whose parent channel is in the allowlist.
|
|
100
|
+
*/
|
|
101
|
+
export function isAllowedChannelForMessage(message: { channelId: string; channel?: any }): boolean {
|
|
102
|
+
if (!message.channelId) return false;
|
|
103
|
+
if (IGNORED_CHANNEL_IDS.has(message.channelId)) return false;
|
|
104
|
+
if (ALLOWED_CHANNEL_IDS.length === 0) return true;
|
|
105
|
+
if (ALLOWED_CHANNEL_IDS.includes(message.channelId)) return true;
|
|
106
|
+
// Thread: check parent channel
|
|
107
|
+
const ch = message.channel;
|
|
108
|
+
if (ch?.isThread?.() && ch.parentId) {
|
|
109
|
+
if (IGNORED_CHANNEL_IDS.has(ch.parentId)) return false;
|
|
110
|
+
return ALLOWED_CHANNEL_IDS.includes(ch.parentId);
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
export function isAllowedUser(userId: string | undefined): boolean {
|
|
98
116
|
if (!userId) return false;
|
|
99
117
|
if (ALLOWED_DISCORD_USER_IDS.length === 0) return true;
|
package/server/db.ts
CHANGED
|
@@ -133,6 +133,15 @@ export function getCurrentAgentActivity(sessionId: string, defaultAgentId: strin
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
export function isTraceThread(threadId: string): boolean {
|
|
137
|
+
try {
|
|
138
|
+
const row = db.prepare("SELECT 1 FROM trace_threads WHERE thread_id = ?").get(threadId);
|
|
139
|
+
return !!row;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
// ── Trace thread helpers ────────────────────────────────────────────
|
|
137
146
|
|
|
138
147
|
export function getTraceThreadId(channelId: string): string | null {
|
package/server/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DISCORD_SESSION_ID,
|
|
16
16
|
IGNORED_CHANNEL_IDS,
|
|
17
17
|
isAllowedChannel,
|
|
18
|
+
isAllowedChannelForMessage,
|
|
18
19
|
isAllowedUser,
|
|
19
20
|
MESSAGE_ROUTING_MODE,
|
|
20
21
|
RELAY_ALLOW_NO_AUTH,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
TYPING_MAX_MS,
|
|
27
28
|
validateConfig,
|
|
28
29
|
} from "./config.ts";
|
|
29
|
-
import { clearChannelModel, db, getAgentHealthAll, getChannelModel, setChannelModel } from "./db.ts";
|
|
30
|
+
import { clearChannelModel, db, getAgentHealthAll, getChannelModel, isTraceThread, setChannelModel } from "./db.ts";
|
|
30
31
|
import { memoryStore } from "./memory.ts";
|
|
31
32
|
import { persistInboundDiscordMessage, persistOutboundDiscordMessage } from "./messages.ts";
|
|
32
33
|
import { startTraceFlushLoop, stopTraceFlushLoop } from "./trace-thread.ts";
|
|
@@ -39,7 +40,12 @@ setInterval(cleanupOldAttachments, 10 * 60 * 1000);
|
|
|
39
40
|
cleanupOldAttachments();
|
|
40
41
|
|
|
41
42
|
const client = new Client({
|
|
42
|
-
intents: [
|
|
43
|
+
intents: [
|
|
44
|
+
GatewayIntentBits.Guilds,
|
|
45
|
+
GatewayIntentBits.GuildMessages,
|
|
46
|
+
GatewayIntentBits.MessageContent,
|
|
47
|
+
GatewayIntentBits.GuildMessageThreads,
|
|
48
|
+
],
|
|
43
49
|
});
|
|
44
50
|
|
|
45
51
|
function requireAuth(req: Request, res: Response): boolean {
|
|
@@ -104,7 +110,8 @@ client.once("clientReady", async () => {
|
|
|
104
110
|
client.on("messageCreate", async (message) => {
|
|
105
111
|
if (!message) return;
|
|
106
112
|
if (message.author?.bot) return;
|
|
107
|
-
if (!
|
|
113
|
+
if (!isAllowedChannelForMessage(message)) return;
|
|
114
|
+
if (message.channel?.isThread?.() && isTraceThread(message.channelId)) return;
|
|
108
115
|
if (!isAllowedUser(message.author?.id)) {
|
|
109
116
|
console.log(`[Relay] Ignoring message from unauthorized user ${message.author?.id}`);
|
|
110
117
|
return;
|
|
@@ -196,6 +203,34 @@ app.get("/api/channels", async (req: Request, res: Response) => {
|
|
|
196
203
|
}
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
// Optionally include active threads in allowed channels
|
|
207
|
+
if (req.query.include_threads === "true") {
|
|
208
|
+
for (const ch of [...channels]) {
|
|
209
|
+
try {
|
|
210
|
+
const parentChannel = await client.channels.fetch(ch.id);
|
|
211
|
+
if (parentChannel && "threads" in parentChannel) {
|
|
212
|
+
const activeThreads = await (parentChannel as any).threads.fetchActive();
|
|
213
|
+
for (const [, thread] of activeThreads.threads) {
|
|
214
|
+
if (isTraceThread(thread.id)) continue;
|
|
215
|
+
channels.push({
|
|
216
|
+
id: thread.id,
|
|
217
|
+
name: thread.name,
|
|
218
|
+
guildId: ch.guildId,
|
|
219
|
+
guildName: ch.guildName,
|
|
220
|
+
type: thread.type,
|
|
221
|
+
isThread: true,
|
|
222
|
+
parentChannelId: ch.id,
|
|
223
|
+
parentChannelName: ch.name,
|
|
224
|
+
model: getChannelModel(thread.id),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
/* skip */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
199
234
|
res.json({ success: true, channels });
|
|
200
235
|
} catch (err: unknown) {
|
|
201
236
|
console.error("[Relay] /api/channels failed:", err);
|
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/messages.ts
CHANGED
|
@@ -23,7 +23,14 @@ export async function formatInboundMessage(message: any) {
|
|
|
23
23
|
fullText = "[No text content]";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// Add thread context if message is from a thread
|
|
27
|
+
let prefix = author;
|
|
28
|
+
if (message.channel?.isThread?.()) {
|
|
29
|
+
const threadName = message.channel.name || "thread";
|
|
30
|
+
prefix = `${author} [thread: ${threadName}]`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `${prefix}: ${fullText}`;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export async function persistInboundDiscordMessage(message: any): Promise<boolean> {
|