clementine-agent 1.0.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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- package/vault/06-Templates/_People-Template.md +22 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — FalkorDBLite graph memory layer.
|
|
3
|
+
*
|
|
4
|
+
* Adds entity graph, typed relationships, and multi-hop traversal on top
|
|
5
|
+
* of the existing SQLite FTS5 memory store. The vault remains the source
|
|
6
|
+
* of truth; the graph is a derived index that can be rebuilt at any time.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - The daemon calls `initialize()` which starts an embedded FalkorDB
|
|
10
|
+
* server and writes its Unix socket path to SOCKET_FILE.
|
|
11
|
+
* - MCP tools, dashboard, and assistant.ts call `connectToRunning()`
|
|
12
|
+
* which reads the socket file and connects as a client (no new server).
|
|
13
|
+
* - If no running instance is found, all graph features degrade gracefully.
|
|
14
|
+
*
|
|
15
|
+
* Graceful degradation: if FalkorDBLite fails to initialize, `isAvailable()`
|
|
16
|
+
* returns false and all graph features are silently skipped.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import matter from 'gray-matter';
|
|
21
|
+
import pino from 'pino';
|
|
22
|
+
const logger = pino({ name: 'clementine.graph' });
|
|
23
|
+
const GRAPH_NAME = 'clementine';
|
|
24
|
+
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
25
|
+
/** Well-known file where the daemon writes the socket path for other processes. */
|
|
26
|
+
const SOCKET_FILE_NAME = '.graph.sock';
|
|
27
|
+
export class GraphStore {
|
|
28
|
+
db = null; // FalkorDBLite instance (only when we own the server)
|
|
29
|
+
client = null; // falkordb client (both modes)
|
|
30
|
+
graph = null;
|
|
31
|
+
available = false;
|
|
32
|
+
persistenceDir;
|
|
33
|
+
ownsServer = false;
|
|
34
|
+
constructor(persistenceDir) {
|
|
35
|
+
this.persistenceDir = persistenceDir;
|
|
36
|
+
}
|
|
37
|
+
/** Get the socket file path for this instance's data dir. */
|
|
38
|
+
get socketFilePath() {
|
|
39
|
+
return path.join(this.persistenceDir, SOCKET_FILE_NAME);
|
|
40
|
+
}
|
|
41
|
+
// ── Initialization (daemon — starts the server) ──────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Start an embedded FalkorDB server. Only the daemon should call this.
|
|
44
|
+
* Writes the socket path to a file so other processes can connect.
|
|
45
|
+
*/
|
|
46
|
+
async initialize() {
|
|
47
|
+
try {
|
|
48
|
+
const { FalkorDB } = await import('falkordblite');
|
|
49
|
+
if (!existsSync(this.persistenceDir)) {
|
|
50
|
+
mkdirSync(this.persistenceDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
this.db = await FalkorDB.open({ path: this.persistenceDir });
|
|
53
|
+
this.graph = this.db.selectGraph(GRAPH_NAME);
|
|
54
|
+
this.available = true;
|
|
55
|
+
this.ownsServer = true;
|
|
56
|
+
// Catch connection-level errors: log once, disable gracefully
|
|
57
|
+
let serverErrorLogged = false;
|
|
58
|
+
this.db.on?.('error', (err) => {
|
|
59
|
+
if (!serverErrorLogged) {
|
|
60
|
+
serverErrorLogged = true;
|
|
61
|
+
logger.warn({ err: err.message }, 'FalkorDB server error — disabling graph features');
|
|
62
|
+
this.available = false;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
// Write socket path so MCP/dashboard/assistant can connect
|
|
66
|
+
writeFileSync(this.socketFilePath, this.db.socketPath, 'utf-8');
|
|
67
|
+
// Create indexes for fast lookups
|
|
68
|
+
const indexes = [
|
|
69
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Person) ON (n.id)',
|
|
70
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Project) ON (n.id)',
|
|
71
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Topic) ON (n.id)',
|
|
72
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Agent) ON (n.slug)',
|
|
73
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Task) ON (n.id)',
|
|
74
|
+
'CREATE INDEX IF NOT EXISTS FOR (n:Note) ON (n.path)',
|
|
75
|
+
];
|
|
76
|
+
for (const idx of indexes) {
|
|
77
|
+
try {
|
|
78
|
+
await this.graph.query(idx);
|
|
79
|
+
}
|
|
80
|
+
catch { /* index may already exist */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
this.available = false;
|
|
85
|
+
logger.warn({ err }, 'FalkorDB unavailable — graph features disabled');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ── Connection (MCP / dashboard / assistant — client only) ───────────
|
|
89
|
+
/**
|
|
90
|
+
* Connect to an already-running FalkorDB instance via its socket file.
|
|
91
|
+
* Does NOT start a new server. Returns false if no running instance.
|
|
92
|
+
*/
|
|
93
|
+
async connectToRunning() {
|
|
94
|
+
try {
|
|
95
|
+
if (!existsSync(this.socketFilePath))
|
|
96
|
+
return false;
|
|
97
|
+
const socketPath = readFileSync(this.socketFilePath, 'utf-8').trim();
|
|
98
|
+
if (!socketPath)
|
|
99
|
+
return false;
|
|
100
|
+
// Use the falkordb client library to connect to the existing socket
|
|
101
|
+
const { FalkorDB: FalkorDBClient } = await import('falkordb');
|
|
102
|
+
this.client = await FalkorDBClient.connect({ socket: { path: socketPath } });
|
|
103
|
+
this.graph = this.client.selectGraph(GRAPH_NAME);
|
|
104
|
+
this.available = true;
|
|
105
|
+
this.ownsServer = false;
|
|
106
|
+
// Catch connection-level errors: disable and start reconnect loop
|
|
107
|
+
let errorHandled = false;
|
|
108
|
+
this.client.on?.('error', (err) => {
|
|
109
|
+
if (errorHandled)
|
|
110
|
+
return;
|
|
111
|
+
errorHandled = true;
|
|
112
|
+
logger.warn({ err: err.message }, 'FalkorDB connection lost — starting reconnect loop');
|
|
113
|
+
this.available = false;
|
|
114
|
+
try {
|
|
115
|
+
this.client?.disconnect?.();
|
|
116
|
+
}
|
|
117
|
+
catch { /* ignore */ }
|
|
118
|
+
// Reconnect loop: try every 30s up to 5 times, then back off to 5 min
|
|
119
|
+
let attempts = 0;
|
|
120
|
+
const reconnectLoop = async () => {
|
|
121
|
+
attempts++;
|
|
122
|
+
try {
|
|
123
|
+
const reconnected = await this.connectToRunning();
|
|
124
|
+
if (reconnected) {
|
|
125
|
+
logger.info({ attempts }, 'FalkorDB reconnected');
|
|
126
|
+
return; // Success — stop the loop
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch { /* retry */ }
|
|
130
|
+
if (attempts < 5) {
|
|
131
|
+
setTimeout(reconnectLoop, 30_000); // Retry in 30s
|
|
132
|
+
}
|
|
133
|
+
else if (attempts < 10) {
|
|
134
|
+
setTimeout(reconnectLoop, 5 * 60_000); // Back off to 5 min
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Keep a slow background probe instead of giving up entirely
|
|
138
|
+
logger.warn({ attempts }, 'FalkorDB reconnect entering slow probe (every 30 min)');
|
|
139
|
+
setTimeout(reconnectLoop, 30 * 60_000);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
setTimeout(reconnectLoop, 30_000);
|
|
143
|
+
});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
this.available = false;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
isAvailable() {
|
|
152
|
+
return this.available;
|
|
153
|
+
}
|
|
154
|
+
async close() {
|
|
155
|
+
if (this.ownsServer && this.db) {
|
|
156
|
+
// Clean up socket file
|
|
157
|
+
try {
|
|
158
|
+
unlinkSync(this.socketFilePath);
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore */ }
|
|
161
|
+
try {
|
|
162
|
+
await this.db.close();
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
// Unregister from FalkorDBLite's cleanup module — its uncaughtException
|
|
166
|
+
// handler re-throws errors, which crashes the daemon on socket drops.
|
|
167
|
+
try {
|
|
168
|
+
const { unregisterServer } = await import('falkordblite/dist/cleanup.js');
|
|
169
|
+
unregisterServer(this.db);
|
|
170
|
+
}
|
|
171
|
+
catch { /* cleanup module may not be accessible */ }
|
|
172
|
+
this.db = null;
|
|
173
|
+
}
|
|
174
|
+
else if (this.client) {
|
|
175
|
+
try {
|
|
176
|
+
await this.client.close();
|
|
177
|
+
}
|
|
178
|
+
catch { /* ignore */ }
|
|
179
|
+
this.client = null;
|
|
180
|
+
}
|
|
181
|
+
this.graph = null;
|
|
182
|
+
this.available = false;
|
|
183
|
+
}
|
|
184
|
+
// ── Entity CRUD ──────────────────────────────────────────────────────
|
|
185
|
+
async upsertEntity(label, id, props) {
|
|
186
|
+
if (!this.available)
|
|
187
|
+
return;
|
|
188
|
+
const safeLabel = label.replace(/[^A-Za-z]/g, '');
|
|
189
|
+
const propsStr = Object.entries(props)
|
|
190
|
+
.map(([k, _v]) => `n.${k} = $${k}`)
|
|
191
|
+
.join(', ');
|
|
192
|
+
const params = { id, ...props };
|
|
193
|
+
const cypher = `MERGE (n:${safeLabel} {id: $id}) SET ${propsStr || 'n.id = $id'}`;
|
|
194
|
+
try {
|
|
195
|
+
await this.graph.query(cypher, { params });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
logger.debug({ err, label, id }, 'upsertEntity failed');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async getEntity(label, id) {
|
|
202
|
+
if (!this.available)
|
|
203
|
+
return null;
|
|
204
|
+
const safeLabel = label.replace(/[^A-Za-z]/g, '');
|
|
205
|
+
try {
|
|
206
|
+
const result = await this.graph.query(`MATCH (n:${safeLabel} {id: $id}) RETURN n`, { params: { id } });
|
|
207
|
+
if (result.data && result.data.length > 0) {
|
|
208
|
+
const row = result.data[0];
|
|
209
|
+
const node = row.n ?? row;
|
|
210
|
+
return { label: safeLabel, id, properties: node?.properties ?? {} };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch { /* not found */ }
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
// ── Relationship CRUD ────────────────────────────────────────────────
|
|
217
|
+
async createRelationship(from, to, type, props, temporal) {
|
|
218
|
+
if (!this.available)
|
|
219
|
+
return;
|
|
220
|
+
const fromLabel = from.label.replace(/[^A-Za-z]/g, '');
|
|
221
|
+
const toLabel = to.label.replace(/[^A-Za-z]/g, '');
|
|
222
|
+
const relType = type.replace(/[^A-Za-z_]/g, '');
|
|
223
|
+
const propsStr = props
|
|
224
|
+
? ', ' + Object.entries(props).map(([k, _v]) => `r.${k} = $r_${k}`).join(', ')
|
|
225
|
+
: '';
|
|
226
|
+
const params = {
|
|
227
|
+
fromId: from.id,
|
|
228
|
+
toId: to.id,
|
|
229
|
+
valid_from: temporal?.validFrom ?? new Date().toISOString(),
|
|
230
|
+
valid_to: temporal?.validTo ?? null,
|
|
231
|
+
};
|
|
232
|
+
if (props) {
|
|
233
|
+
for (const [k, v] of Object.entries(props)) {
|
|
234
|
+
params[`r_${k}`] = v;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const cypher = `MERGE (a:${fromLabel} {id: $fromId}) ` +
|
|
238
|
+
`MERGE (b:${toLabel} {id: $toId}) ` +
|
|
239
|
+
`MERGE (a)-[r:${relType}]->(b) ` +
|
|
240
|
+
`SET r.created_at = timestamp(), r.valid_from = $valid_from, r.valid_to = $valid_to${propsStr}`;
|
|
241
|
+
try {
|
|
242
|
+
await this.graph.query(cypher, { params });
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
logger.debug({ err, from, to, type }, 'createRelationship failed');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Mark a relationship as no longer active by setting its valid_to timestamp.
|
|
250
|
+
*/
|
|
251
|
+
async invalidateRelationship(fromId, toId, relType, asOf) {
|
|
252
|
+
if (!this.available)
|
|
253
|
+
return false;
|
|
254
|
+
const safeRelType = relType.replace(/[^A-Za-z_]/g, '');
|
|
255
|
+
const cypher = `MATCH (a {id: $fromId})-[r:${safeRelType}]->(b {id: $toId}) ` +
|
|
256
|
+
`WHERE r.valid_to IS NULL ` +
|
|
257
|
+
`SET r.valid_to = $validTo ` +
|
|
258
|
+
`RETURN count(r) AS updated`;
|
|
259
|
+
try {
|
|
260
|
+
const res = await this.graph.query(cypher, {
|
|
261
|
+
params: { fromId, toId, validTo: asOf ?? new Date().toISOString() },
|
|
262
|
+
});
|
|
263
|
+
return (res.data?.[0]?.updated ?? 0) > 0;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async getRelationships(entityId, direction = 'both', relType, asOf) {
|
|
270
|
+
if (!this.available)
|
|
271
|
+
return [];
|
|
272
|
+
const relFilter = relType ? `:${relType.replace(/[^A-Za-z_]/g, '')}` : '';
|
|
273
|
+
// Build temporal WHERE clause
|
|
274
|
+
let temporalWhere = '';
|
|
275
|
+
const params = { id: entityId };
|
|
276
|
+
if (asOf) {
|
|
277
|
+
temporalWhere = ' WHERE (r.valid_from IS NULL OR r.valid_from <= $asOf) AND (r.valid_to IS NULL OR r.valid_to > $asOf)';
|
|
278
|
+
params.asOf = asOf;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// By default show only active relationships (valid_to is null or not set)
|
|
282
|
+
temporalWhere = ' WHERE r.valid_to IS NULL';
|
|
283
|
+
}
|
|
284
|
+
const queries = [];
|
|
285
|
+
if (direction === 'out' || direction === 'both') {
|
|
286
|
+
queries.push(`MATCH (a {id: $id})-[r${relFilter}]->(b)${temporalWhere} RETURN a.id AS from, b.id AS to, type(r) AS rel, properties(r) AS props`);
|
|
287
|
+
}
|
|
288
|
+
if (direction === 'in' || direction === 'both') {
|
|
289
|
+
queries.push(`MATCH (a {id: $id})<-[r${relFilter}]-(b)${temporalWhere} RETURN b.id AS from, a.id AS to, type(r) AS rel, properties(r) AS props`);
|
|
290
|
+
}
|
|
291
|
+
const results = [];
|
|
292
|
+
for (const q of queries) {
|
|
293
|
+
try {
|
|
294
|
+
const res = await this.graph.query(q, { params });
|
|
295
|
+
if (res.data) {
|
|
296
|
+
for (const row of res.data) {
|
|
297
|
+
results.push({
|
|
298
|
+
from: row.from,
|
|
299
|
+
to: row.to,
|
|
300
|
+
type: row.rel,
|
|
301
|
+
properties: row.props ?? {},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch { /* ignore query errors */ }
|
|
307
|
+
}
|
|
308
|
+
return results;
|
|
309
|
+
}
|
|
310
|
+
// ── Graph Queries ────────────────────────────────────────────────────
|
|
311
|
+
async traverse(startId, maxDepth = 3, relTypes, asOf) {
|
|
312
|
+
if (!this.available)
|
|
313
|
+
return [];
|
|
314
|
+
const relFilter = relTypes?.length
|
|
315
|
+
? relTypes.map(t => t.replace(/[^A-Za-z_]/g, '')).join('|')
|
|
316
|
+
: '';
|
|
317
|
+
const relPattern = relFilter ? `:${relFilter}` : '';
|
|
318
|
+
// When asOf is specified, include relationship properties for post-filtering
|
|
319
|
+
const cypher = `MATCH path = (start {id: $id})-[${relPattern}*1..${maxDepth}]->(end) ` +
|
|
320
|
+
`RETURN end.id AS id, labels(end)[0] AS label, properties(end) AS props, ` +
|
|
321
|
+
`length(path) AS depth, [r IN relationships(path) | type(r)] AS rels` +
|
|
322
|
+
(asOf ? `, [r IN relationships(path) | properties(r)] AS relProps` : '');
|
|
323
|
+
try {
|
|
324
|
+
const res = await this.graph.query(cypher, { params: { id: startId } });
|
|
325
|
+
if (!res.data)
|
|
326
|
+
return [];
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
const results = [];
|
|
329
|
+
for (const row of res.data) {
|
|
330
|
+
// If asOf specified, filter out paths with temporally invalid relationships
|
|
331
|
+
if (asOf && row.relProps) {
|
|
332
|
+
const allValid = row.relProps.every((rp) => {
|
|
333
|
+
const from = rp.valid_from;
|
|
334
|
+
const to = rp.valid_to;
|
|
335
|
+
return (!from || from <= asOf) && (!to || to > asOf);
|
|
336
|
+
});
|
|
337
|
+
if (!allValid)
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const eid = row.id;
|
|
341
|
+
if (seen.has(eid))
|
|
342
|
+
continue;
|
|
343
|
+
seen.add(eid);
|
|
344
|
+
results.push({
|
|
345
|
+
entity: { label: row.label ?? 'Unknown', id: eid, properties: row.props ?? {} },
|
|
346
|
+
depth: row.depth,
|
|
347
|
+
path: row.rels ?? [],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return results;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async shortestPath(fromId, toId) {
|
|
357
|
+
if (!this.available)
|
|
358
|
+
return null;
|
|
359
|
+
const cypher = `MATCH path = shortestPath((a {id: $from})-[*..10]->(b {id: $to})) ` +
|
|
360
|
+
`RETURN [n IN nodes(path) | {id: n.id, label: labels(n)[0], props: properties(n)}] AS nodes, ` +
|
|
361
|
+
`[r IN relationships(path) | type(r)] AS rels`;
|
|
362
|
+
try {
|
|
363
|
+
const res = await this.graph.query(cypher, { params: { from: fromId, to: toId } });
|
|
364
|
+
if (!res.data || res.data.length === 0)
|
|
365
|
+
return null;
|
|
366
|
+
const row = res.data[0];
|
|
367
|
+
const nodes = (row.nodes ?? []).map((n) => ({
|
|
368
|
+
label: n.label ?? 'Unknown',
|
|
369
|
+
id: n.id,
|
|
370
|
+
properties: n.props ?? {},
|
|
371
|
+
}));
|
|
372
|
+
const relationships = row.rels ?? [];
|
|
373
|
+
return { nodes, relationships, length: relationships.length };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
async findConnected(entityId, targetLabel, maxHops = 3) {
|
|
380
|
+
if (!this.available)
|
|
381
|
+
return [];
|
|
382
|
+
const safeLabel = targetLabel.replace(/[^A-Za-z]/g, '');
|
|
383
|
+
const cypher = `MATCH (start {id: $id})-[*1..${maxHops}]->(end:${safeLabel}) ` +
|
|
384
|
+
`RETURN DISTINCT end.id AS id, properties(end) AS props`;
|
|
385
|
+
try {
|
|
386
|
+
const res = await this.graph.query(cypher, { params: { id: entityId } });
|
|
387
|
+
if (!res.data)
|
|
388
|
+
return [];
|
|
389
|
+
return res.data.map((row) => ({
|
|
390
|
+
label: safeLabel,
|
|
391
|
+
id: row.id,
|
|
392
|
+
properties: row.props ?? {},
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return [];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async query(cypher, params) {
|
|
400
|
+
if (!this.available)
|
|
401
|
+
return [];
|
|
402
|
+
try {
|
|
403
|
+
const res = await this.graph.query(cypher, params ? { params } : undefined);
|
|
404
|
+
return res.data ?? [];
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// ── Bulk Sync from Vault ─────────────────────────────────────────────
|
|
411
|
+
async syncFromVault(vaultDir, agentsDir) {
|
|
412
|
+
const start = Date.now();
|
|
413
|
+
let nodesCreated = 0;
|
|
414
|
+
let relationshipsCreated = 0;
|
|
415
|
+
if (!this.available)
|
|
416
|
+
return { nodesCreated: 0, relationshipsCreated: 0, duration: 0 };
|
|
417
|
+
// Check if graph already has data (skip full sync if so)
|
|
418
|
+
try {
|
|
419
|
+
const countRes = await this.graph.query('MATCH (n) RETURN count(n) AS c');
|
|
420
|
+
const count = countRes.data?.[0]?.c ?? 0;
|
|
421
|
+
if (count > 0) {
|
|
422
|
+
logger.info({ existingNodes: count }, 'Graph already populated — skipping full sync');
|
|
423
|
+
return { nodesCreated: 0, relationshipsCreated: 0, duration: Date.now() - start };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch { /* empty graph — proceed */ }
|
|
427
|
+
// 1. People notes
|
|
428
|
+
const peopleDir = path.join(vaultDir, '02-People');
|
|
429
|
+
if (existsSync(peopleDir)) {
|
|
430
|
+
for (const file of readdirSync(peopleDir).filter(f => f.endsWith('.md'))) {
|
|
431
|
+
try {
|
|
432
|
+
const content = readFileSync(path.join(peopleDir, file), 'utf-8');
|
|
433
|
+
const { data: fm } = matter(content);
|
|
434
|
+
const slug = path.basename(file, '.md').toLowerCase().replace(/\s+/g, '-');
|
|
435
|
+
await this.upsertEntity('Person', slug, {
|
|
436
|
+
name: fm.name || path.basename(file, '.md'),
|
|
437
|
+
role: fm.role || '',
|
|
438
|
+
company: fm.company || '',
|
|
439
|
+
email: fm.email || '',
|
|
440
|
+
});
|
|
441
|
+
nodesCreated++;
|
|
442
|
+
// Extract wikilinks as relationships
|
|
443
|
+
let match;
|
|
444
|
+
while ((match = WIKILINK_RE.exec(content)) !== null) {
|
|
445
|
+
const target = match[1].toLowerCase().replace(/\s+/g, '-');
|
|
446
|
+
await this.createRelationship({ label: 'Person', id: slug }, { label: 'Note', id: target }, 'MENTIONS');
|
|
447
|
+
relationshipsCreated++;
|
|
448
|
+
}
|
|
449
|
+
// Extract relationships from frontmatter
|
|
450
|
+
if (fm.company) {
|
|
451
|
+
const companySlug = fm.company.toLowerCase().replace(/\s+/g, '-');
|
|
452
|
+
await this.upsertEntity('Project', companySlug, { name: fm.company, type: 'company' });
|
|
453
|
+
await this.createRelationship({ label: 'Person', id: slug }, { label: 'Project', id: companySlug }, 'WORKS_AT');
|
|
454
|
+
nodesCreated++;
|
|
455
|
+
relationshipsCreated++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch { /* skip broken files */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// 2. Project notes
|
|
462
|
+
const projectsDir = path.join(vaultDir, '03-Projects');
|
|
463
|
+
if (existsSync(projectsDir)) {
|
|
464
|
+
for (const file of readdirSync(projectsDir).filter(f => f.endsWith('.md'))) {
|
|
465
|
+
try {
|
|
466
|
+
const content = readFileSync(path.join(projectsDir, file), 'utf-8');
|
|
467
|
+
const { data: fm } = matter(content);
|
|
468
|
+
const slug = path.basename(file, '.md').toLowerCase().replace(/\s+/g, '-');
|
|
469
|
+
await this.upsertEntity('Project', slug, {
|
|
470
|
+
name: fm.name || path.basename(file, '.md'),
|
|
471
|
+
type: fm.type || 'project',
|
|
472
|
+
description: (fm.description || '').slice(0, 200),
|
|
473
|
+
});
|
|
474
|
+
nodesCreated++;
|
|
475
|
+
}
|
|
476
|
+
catch { /* skip */ }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// 3. Topic notes
|
|
480
|
+
const topicsDir = path.join(vaultDir, '04-Topics');
|
|
481
|
+
if (existsSync(topicsDir)) {
|
|
482
|
+
for (const file of readdirSync(topicsDir).filter(f => f.endsWith('.md'))) {
|
|
483
|
+
try {
|
|
484
|
+
const content = readFileSync(path.join(topicsDir, file), 'utf-8');
|
|
485
|
+
const { data: fm } = matter(content);
|
|
486
|
+
const slug = path.basename(file, '.md').toLowerCase().replace(/\s+/g, '-');
|
|
487
|
+
await this.upsertEntity('Topic', slug, {
|
|
488
|
+
name: fm.name || path.basename(file, '.md'),
|
|
489
|
+
description: (fm.description || '').slice(0, 200),
|
|
490
|
+
});
|
|
491
|
+
nodesCreated++;
|
|
492
|
+
}
|
|
493
|
+
catch { /* skip */ }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// 4. Agent configs
|
|
497
|
+
if (existsSync(agentsDir)) {
|
|
498
|
+
for (const dir of readdirSync(agentsDir, { withFileTypes: true })) {
|
|
499
|
+
if (!dir.isDirectory())
|
|
500
|
+
continue;
|
|
501
|
+
const agentFile = path.join(agentsDir, dir.name, 'agent.md');
|
|
502
|
+
if (!existsSync(agentFile))
|
|
503
|
+
continue;
|
|
504
|
+
try {
|
|
505
|
+
const content = readFileSync(agentFile, 'utf-8');
|
|
506
|
+
const { data: fm } = matter(content);
|
|
507
|
+
const slug = dir.name;
|
|
508
|
+
await this.upsertEntity('Agent', slug, {
|
|
509
|
+
slug,
|
|
510
|
+
name: fm.name || slug,
|
|
511
|
+
role: fm.role || '',
|
|
512
|
+
model: fm.model || '',
|
|
513
|
+
});
|
|
514
|
+
nodesCreated++;
|
|
515
|
+
// canMessage edges
|
|
516
|
+
if (Array.isArray(fm.canMessage)) {
|
|
517
|
+
for (const target of fm.canMessage) {
|
|
518
|
+
await this.createRelationship({ label: 'Agent', id: slug }, { label: 'Agent', id: target }, 'CAN_MESSAGE');
|
|
519
|
+
relationshipsCreated++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// project binding
|
|
523
|
+
if (fm.project) {
|
|
524
|
+
const projSlug = String(fm.project).toLowerCase().replace(/\s+/g, '-');
|
|
525
|
+
await this.createRelationship({ label: 'Agent', id: slug }, { label: 'Project', id: projSlug }, 'MANAGES');
|
|
526
|
+
relationshipsCreated++;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch { /* skip */ }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// 5. Tasks from TASKS.md
|
|
533
|
+
const tasksFile = path.join(vaultDir, '05-Tasks', 'TASKS.md');
|
|
534
|
+
if (existsSync(tasksFile)) {
|
|
535
|
+
try {
|
|
536
|
+
const content = readFileSync(tasksFile, 'utf-8');
|
|
537
|
+
const taskRe = /^[-*]\s+\[([x ])\]\s+\*?\*?(T-\d+)\*?\*?\s*[—–-]\s*(.*)/gm;
|
|
538
|
+
let m;
|
|
539
|
+
while ((m = taskRe.exec(content)) !== null) {
|
|
540
|
+
const status = m[1] === 'x' ? 'done' : 'open';
|
|
541
|
+
const taskId = m[2];
|
|
542
|
+
const title = m[3].trim();
|
|
543
|
+
await this.upsertEntity('Task', taskId, { title, status });
|
|
544
|
+
nodesCreated++;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch { /* skip */ }
|
|
548
|
+
}
|
|
549
|
+
const duration = Date.now() - start;
|
|
550
|
+
logger.info({ nodesCreated, relationshipsCreated, duration }, 'Graph sync complete');
|
|
551
|
+
return { nodesCreated, relationshipsCreated, duration };
|
|
552
|
+
}
|
|
553
|
+
// ── Extract & Store Relationships ────────────────────────────────────
|
|
554
|
+
async extractAndStoreRelationships(triplets) {
|
|
555
|
+
if (!this.available)
|
|
556
|
+
return;
|
|
557
|
+
for (const t of triplets) {
|
|
558
|
+
await this.upsertEntity(t.from.label, t.from.id, {});
|
|
559
|
+
await this.upsertEntity(t.to.label, t.to.id, {});
|
|
560
|
+
await this.createRelationship(t.from, t.to, t.rel, t.context ? { context: t.context } : undefined);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// ── Graph-enhanced Context Enrichment ────────────────────────────────
|
|
564
|
+
async enrichWithGraphContext(entityIds, _maxHops = 1) {
|
|
565
|
+
if (!this.available || entityIds.length === 0)
|
|
566
|
+
return '';
|
|
567
|
+
const lines = [];
|
|
568
|
+
const seen = new Set();
|
|
569
|
+
for (const id of entityIds.slice(0, 5)) {
|
|
570
|
+
const rels = await this.getRelationships(id, 'both');
|
|
571
|
+
for (const r of rels.slice(0, 8)) {
|
|
572
|
+
const key = `${r.from}-${r.type}-${r.to}`;
|
|
573
|
+
if (seen.has(key))
|
|
574
|
+
continue;
|
|
575
|
+
seen.add(key);
|
|
576
|
+
lines.push(`- ${r.from} ${r.type} ${r.to}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (lines.length === 0)
|
|
580
|
+
return '';
|
|
581
|
+
return '\n## Relationship Context\n' + lines.join('\n');
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ── Shared Client Helper ───────────────────────────────────────────────
|
|
585
|
+
/**
|
|
586
|
+
* Get a client-mode GraphStore connected to the daemon's running instance.
|
|
587
|
+
* Returns null if the daemon isn't running or graph isn't available.
|
|
588
|
+
* Callers should cache the result and reuse it.
|
|
589
|
+
*/
|
|
590
|
+
let _sharedInstance = null;
|
|
591
|
+
let _sharedConnecting = false;
|
|
592
|
+
export async function getSharedGraphStore(persistenceDir) {
|
|
593
|
+
// Return existing instance if available
|
|
594
|
+
if (_sharedInstance?.isAvailable())
|
|
595
|
+
return _sharedInstance;
|
|
596
|
+
// Prevent multiple callers from racing to connect
|
|
597
|
+
if (_sharedConnecting)
|
|
598
|
+
return _sharedInstance;
|
|
599
|
+
_sharedConnecting = true;
|
|
600
|
+
try {
|
|
601
|
+
const gs = _sharedInstance ?? new GraphStore(persistenceDir);
|
|
602
|
+
const connected = await gs.connectToRunning();
|
|
603
|
+
_sharedInstance = connected ? gs : null;
|
|
604
|
+
return _sharedInstance;
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
_sharedConnecting = false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
//# sourceMappingURL=graph-store.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Maximal Marginal Relevance re-ranking.
|
|
3
|
+
*
|
|
4
|
+
* Ensures search results are both relevant AND diverse by penalizing
|
|
5
|
+
* results too similar to already-selected ones.
|
|
6
|
+
* Uses Jaccard similarity on tokenized content (no embedding provider needed).
|
|
7
|
+
*/
|
|
8
|
+
import type { SearchResult } from '../types.js';
|
|
9
|
+
/** Tokenize text into lowercase word set for Jaccard similarity. */
|
|
10
|
+
export declare function tokenize(text: string): Set<string>;
|
|
11
|
+
/** Jaccard similarity between two token sets: |A∩B| / |A∪B| */
|
|
12
|
+
export declare function jaccard(a: Set<string>, b: Set<string>): number;
|
|
13
|
+
/**
|
|
14
|
+
* Re-rank results using Maximal Marginal Relevance.
|
|
15
|
+
*
|
|
16
|
+
* @param results - Scored search results (higher score = more relevant)
|
|
17
|
+
* @param lambda - Balance: 0 = max diversity, 1 = max relevance (default 0.7)
|
|
18
|
+
* @param limit - Max results to return
|
|
19
|
+
*/
|
|
20
|
+
export declare function mmrRerank(results: SearchResult[], lambda?: number, limit?: number): SearchResult[];
|
|
21
|
+
//# sourceMappingURL=mmr.d.ts.map
|