@pcircle/memesh 2.8.11 → 2.9.1
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/LICENSE +21 -661
- package/README.de.md +171 -0
- package/README.es.md +171 -0
- package/README.fr.md +171 -0
- package/README.id.md +171 -0
- package/README.ja.md +171 -0
- package/README.ko.md +171 -0
- package/README.md +73 -100
- package/README.th.md +171 -0
- package/README.vi.md +171 -0
- package/README.zh-CN.md +171 -0
- package/README.zh-TW.md +71 -98
- package/dist/knowledge-graph/index.d.ts +22 -1
- package/dist/knowledge-graph/index.d.ts.map +1 -1
- package/dist/knowledge-graph/index.js +144 -3
- package/dist/knowledge-graph/index.js.map +1 -1
- package/dist/mcp/ServerInitializer.d.ts.map +1 -1
- package/dist/mcp/ServerInitializer.js +1 -1
- package/dist/mcp/ServerInitializer.js.map +1 -1
- package/dist/mcp/ToolDefinitions.d.ts.map +1 -1
- package/dist/mcp/ToolDefinitions.js +47 -55
- package/dist/mcp/ToolDefinitions.js.map +1 -1
- package/dist/mcp/ToolRouter.d.ts.map +1 -1
- package/dist/mcp/ToolRouter.js +4 -4
- package/dist/mcp/ToolRouter.js.map +1 -1
- package/dist/mcp/daemon/StdioProxyClient.d.ts.map +1 -1
- package/dist/mcp/daemon/StdioProxyClient.js +9 -1
- package/dist/mcp/daemon/StdioProxyClient.js.map +1 -1
- package/dist/mcp/handlers/BuddyHandlers.d.ts +3 -1
- package/dist/mcp/handlers/BuddyHandlers.d.ts.map +1 -1
- package/dist/mcp/handlers/BuddyHandlers.js +6 -5
- package/dist/mcp/handlers/BuddyHandlers.js.map +1 -1
- package/dist/mcp/handlers/ToolHandlers.d.ts.map +1 -1
- package/dist/mcp/handlers/ToolHandlers.js +1 -2
- package/dist/mcp/handlers/ToolHandlers.js.map +1 -1
- package/dist/mcp/resources/quick-reference.md +1 -1
- package/dist/mcp/schemas/OutputSchemas.d.ts +116 -53
- package/dist/mcp/schemas/OutputSchemas.d.ts.map +1 -1
- package/dist/mcp/schemas/OutputSchemas.js +64 -26
- package/dist/mcp/schemas/OutputSchemas.js.map +1 -1
- package/dist/mcp/server-bootstrap.js +89 -9
- package/dist/mcp/server-bootstrap.js.map +1 -1
- package/dist/mcp/tools/buddy-do.d.ts +2 -1
- package/dist/mcp/tools/buddy-do.d.ts.map +1 -1
- package/dist/mcp/tools/buddy-do.js +91 -4
- package/dist/mcp/tools/buddy-do.js.map +1 -1
- package/dist/mcp/tools/buddy-remember.d.ts +0 -5
- package/dist/mcp/tools/buddy-remember.d.ts.map +1 -1
- package/dist/mcp/tools/buddy-remember.js.map +1 -1
- package/dist/mcp/tools/memesh-agent-register.d.ts +20 -0
- package/dist/mcp/tools/memesh-agent-register.d.ts.map +1 -0
- package/dist/mcp/tools/memesh-agent-register.js +80 -0
- package/dist/mcp/tools/memesh-agent-register.js.map +1 -0
- package/dist/mcp/tools/memesh-cloud-sync.js +27 -8
- package/dist/mcp/tools/memesh-cloud-sync.js.map +1 -1
- package/dist/mcp/tools/memesh-metrics.d.ts +13 -0
- package/dist/mcp/tools/memesh-metrics.d.ts.map +1 -0
- package/dist/mcp/tools/memesh-metrics.js +193 -0
- package/dist/mcp/tools/memesh-metrics.js.map +1 -0
- package/dist/memory/UnifiedMemoryStore.d.ts +1 -1
- package/dist/memory/UnifiedMemoryStore.d.ts.map +1 -1
- package/dist/memory/UnifiedMemoryStore.js +4 -3
- package/dist/memory/UnifiedMemoryStore.js.map +1 -1
- package/package.json +9 -12
- package/plugin.json +2 -2
- package/scripts/hooks/README.md +230 -0
- package/scripts/hooks/__tests__/hook-test-harness.js +218 -0
- package/scripts/hooks/__tests__/hooks.test.js +267 -0
- package/scripts/hooks/hook-utils.js +899 -0
- package/scripts/hooks/post-commit.js +307 -0
- package/scripts/hooks/post-tool-use.js +812 -0
- package/scripts/hooks/pre-tool-use.js +462 -0
- package/scripts/hooks/session-start.js +544 -0
- package/scripts/hooks/stop.js +673 -0
- package/scripts/hooks/subagent-stop.js +184 -0
- package/scripts/hooks/templates/planning-template.md +46 -0
- package/scripts/postinstall-lib.js +8 -4
- package/scripts/postinstall-new.js +110 -7
- package/scripts/skills/comprehensive-code-review/SKILL.md +276 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook Utilities - Shared functions for Claude Code hooks
|
|
5
|
+
*
|
|
6
|
+
* This module provides common utilities used across all hooks:
|
|
7
|
+
* - File I/O (JSON read/write)
|
|
8
|
+
* - SQLite queries with SQL injection protection
|
|
9
|
+
* - Path constants
|
|
10
|
+
* - Time utilities
|
|
11
|
+
*
|
|
12
|
+
* All hooks should import from this module to avoid code duplication.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import { execFileSync } from 'child_process';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Home directory with fallback */
|
|
25
|
+
export const HOME_DIR = process.env.HOME || os.homedir();
|
|
26
|
+
|
|
27
|
+
/** State directory for hook data */
|
|
28
|
+
export const STATE_DIR = path.join(HOME_DIR, '.claude', 'state');
|
|
29
|
+
|
|
30
|
+
/** MeMesh knowledge graph database path (mirrors PathResolver logic from src/utils/PathResolver.ts) */
|
|
31
|
+
function resolveMemeshDbPath() {
|
|
32
|
+
const primaryDir = path.join(HOME_DIR, '.memesh');
|
|
33
|
+
const legacyDir = path.join(HOME_DIR, '.claude-code-buddy');
|
|
34
|
+
|
|
35
|
+
if (fs.existsSync(path.join(primaryDir, 'knowledge-graph.db'))) {
|
|
36
|
+
return path.join(primaryDir, 'knowledge-graph.db');
|
|
37
|
+
}
|
|
38
|
+
if (fs.existsSync(path.join(legacyDir, 'knowledge-graph.db'))) {
|
|
39
|
+
return path.join(legacyDir, 'knowledge-graph.db');
|
|
40
|
+
}
|
|
41
|
+
return path.join(primaryDir, 'knowledge-graph.db');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const MEMESH_DB_PATH = resolveMemeshDbPath();
|
|
45
|
+
|
|
46
|
+
/** Hook error log file */
|
|
47
|
+
export const ERROR_LOG_PATH = path.join(STATE_DIR, 'hook-errors.log');
|
|
48
|
+
|
|
49
|
+
/** Memory saves log file */
|
|
50
|
+
export const MEMORY_LOG_PATH = path.join(STATE_DIR, 'memory-saves.log');
|
|
51
|
+
|
|
52
|
+
// Time constants (in milliseconds)
|
|
53
|
+
export const TIME = {
|
|
54
|
+
SECOND: 1000,
|
|
55
|
+
MINUTE: 60 * 1000,
|
|
56
|
+
HOUR: 60 * 60 * 1000,
|
|
57
|
+
DAY: 24 * 60 * 60 * 1000,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Threshold constants
|
|
61
|
+
export const THRESHOLDS = {
|
|
62
|
+
/** Token threshold for auto-saving key points */
|
|
63
|
+
TOKEN_SAVE: 250_000,
|
|
64
|
+
/** Days to retain session key points */
|
|
65
|
+
RETENTION_DAYS: 30,
|
|
66
|
+
/** Days to recall key points on session start */
|
|
67
|
+
RECALL_DAYS: 30,
|
|
68
|
+
/** Slow execution threshold (ms) */
|
|
69
|
+
SLOW_EXECUTION: 5000,
|
|
70
|
+
/** High token usage threshold */
|
|
71
|
+
HIGH_TOKENS: 10_000,
|
|
72
|
+
/** Quota warning percentage */
|
|
73
|
+
QUOTA_WARNING: 0.8,
|
|
74
|
+
/** Heartbeat validity duration (ms) */
|
|
75
|
+
HEARTBEAT_VALIDITY: 5 * 60 * 1000,
|
|
76
|
+
/** Maximum number of archived sessions to keep */
|
|
77
|
+
MAX_ARCHIVED_SESSIONS: 30,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// File I/O Utilities
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ensure a directory exists, creating it if necessary
|
|
86
|
+
* @param {string} dirPath - Directory path to ensure exists
|
|
87
|
+
*/
|
|
88
|
+
export function ensureDir(dirPath) {
|
|
89
|
+
if (!fs.existsSync(dirPath)) {
|
|
90
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read JSON file with error handling
|
|
96
|
+
* @param {string} filePath - Path to JSON file
|
|
97
|
+
* @param {*} defaultValue - Default value if file doesn't exist or is invalid
|
|
98
|
+
* @returns {*} Parsed JSON or default value
|
|
99
|
+
*/
|
|
100
|
+
export function readJSONFile(filePath, defaultValue = {}) {
|
|
101
|
+
try {
|
|
102
|
+
if (fs.existsSync(filePath)) {
|
|
103
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
104
|
+
return JSON.parse(content);
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logError(`Read error ${path.basename(filePath)}`, error);
|
|
108
|
+
}
|
|
109
|
+
return defaultValue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Write JSON file with error handling
|
|
114
|
+
* @param {string} filePath - Path to JSON file
|
|
115
|
+
* @param {*} data - Data to write
|
|
116
|
+
* @returns {boolean} True if successful
|
|
117
|
+
*/
|
|
118
|
+
export function writeJSONFile(filePath, data) {
|
|
119
|
+
try {
|
|
120
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
121
|
+
return true;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logError(`Write error ${path.basename(filePath)}`, error);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Logging Utilities
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Log error to error log file (silent - no console output)
|
|
134
|
+
* @param {string} context - Error context description
|
|
135
|
+
* @param {Error|string} error - Error object or message
|
|
136
|
+
*/
|
|
137
|
+
export function logError(context, error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
const timestamp = new Date().toISOString();
|
|
140
|
+
const logLine = `[${timestamp}] ${context}: ${message}\n`;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
ensureDir(STATE_DIR);
|
|
144
|
+
fs.appendFileSync(ERROR_LOG_PATH, logLine);
|
|
145
|
+
} catch {
|
|
146
|
+
// Silent fail - can't log the logging error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Log memory save event
|
|
152
|
+
* @param {string} message - Log message
|
|
153
|
+
*/
|
|
154
|
+
export function logMemorySave(message) {
|
|
155
|
+
const timestamp = new Date().toISOString();
|
|
156
|
+
const logLine = `[${timestamp}] ${message}\n`;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
ensureDir(STATE_DIR);
|
|
160
|
+
fs.appendFileSync(MEMORY_LOG_PATH, logLine);
|
|
161
|
+
} catch {
|
|
162
|
+
// Silent fail
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// SQLite Utilities (SQL Injection Safe)
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Escape a value for safe SQL string interpolation.
|
|
172
|
+
* Numbers are returned unquoted; strings are quoted with single-quote escaping.
|
|
173
|
+
* @param {*} value - Value to escape
|
|
174
|
+
* @returns {string} Escaped SQL literal
|
|
175
|
+
*/
|
|
176
|
+
export function escapeSQL(value) {
|
|
177
|
+
if (value === null || value === undefined) {
|
|
178
|
+
return 'NULL';
|
|
179
|
+
}
|
|
180
|
+
// Numbers don't need quoting in SQL
|
|
181
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
182
|
+
return String(value);
|
|
183
|
+
}
|
|
184
|
+
// Booleans as integers
|
|
185
|
+
if (typeof value === 'boolean') {
|
|
186
|
+
return value ? '1' : '0';
|
|
187
|
+
}
|
|
188
|
+
// Everything else: coerce to string and escape single quotes
|
|
189
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Execute SQLite query with parameterized values (SQL injection safe)
|
|
194
|
+
*
|
|
195
|
+
* Uses placeholder replacement for safe parameter binding.
|
|
196
|
+
* Parameters are escaped by doubling single quotes.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} dbPath - Path to SQLite database
|
|
199
|
+
* @param {string} query - SQL query with ? placeholders
|
|
200
|
+
* @param {Array} params - Parameter values to substitute
|
|
201
|
+
* @param {Object} options - Query options
|
|
202
|
+
* @param {number} options.timeout - Timeout in ms (default: 5000)
|
|
203
|
+
* @param {boolean} options.json - Use JSON output mode (default: false)
|
|
204
|
+
* @returns {string|null} Query result as string, or null on error
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Basic query
|
|
208
|
+
* sqliteQuery(dbPath, 'SELECT * FROM users WHERE id = ?', [userId]);
|
|
209
|
+
*
|
|
210
|
+
* // JSON output mode
|
|
211
|
+
* sqliteQuery(dbPath, 'SELECT * FROM users', [], { json: true });
|
|
212
|
+
*/
|
|
213
|
+
export function sqliteQuery(dbPath, query, params = [], options = {}) {
|
|
214
|
+
const { timeout = 5000, json = false } = options;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
let finalQuery = query;
|
|
218
|
+
|
|
219
|
+
// Replace ? placeholders with escaped values
|
|
220
|
+
if (params.length > 0) {
|
|
221
|
+
let paramIndex = 0;
|
|
222
|
+
finalQuery = query.replace(/\?/g, () => {
|
|
223
|
+
if (paramIndex < params.length) {
|
|
224
|
+
return escapeSQL(params[paramIndex++]);
|
|
225
|
+
}
|
|
226
|
+
return '?';
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const args = json ? ['-json', dbPath, finalQuery] : [dbPath, finalQuery];
|
|
231
|
+
|
|
232
|
+
const result = execFileSync('sqlite3', args, {
|
|
233
|
+
encoding: 'utf-8',
|
|
234
|
+
timeout,
|
|
235
|
+
});
|
|
236
|
+
return result.trim();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
logError('sqliteQuery', error);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Execute SQLite query and parse JSON result
|
|
245
|
+
*
|
|
246
|
+
* @param {string} dbPath - Path to SQLite database
|
|
247
|
+
* @param {string} query - SQL query with ? placeholders
|
|
248
|
+
* @param {Array} params - Parameter values to substitute
|
|
249
|
+
* @param {Object} options - Query options
|
|
250
|
+
* @returns {Array|null} Parsed JSON array, empty array for no rows, or null on error
|
|
251
|
+
*/
|
|
252
|
+
export function sqliteQueryJSON(dbPath, query, params = [], options = {}) {
|
|
253
|
+
const result = sqliteQuery(dbPath, query, params, { ...options, json: true });
|
|
254
|
+
|
|
255
|
+
// sqliteQuery returns null on error — propagate to caller
|
|
256
|
+
if (result === null) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Empty string means no matching rows
|
|
261
|
+
if (result === '') {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(result);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logError('sqliteQueryJSON parse', error);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Time Utilities
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get human-readable time ago string
|
|
279
|
+
* @param {Date} date - Date to compare
|
|
280
|
+
* @returns {string} Human-readable time difference
|
|
281
|
+
*/
|
|
282
|
+
export function getTimeAgo(date) {
|
|
283
|
+
const now = new Date();
|
|
284
|
+
const diffMs = now - date;
|
|
285
|
+
const diffMins = Math.floor(diffMs / TIME.MINUTE);
|
|
286
|
+
const diffHours = Math.floor(diffMs / TIME.HOUR);
|
|
287
|
+
const diffDays = Math.floor(diffMs / TIME.DAY);
|
|
288
|
+
|
|
289
|
+
if (diffMins < 1) return 'Just now';
|
|
290
|
+
if (diffMins < 60) return `${diffMins} minutes ago`;
|
|
291
|
+
if (diffHours < 24) return `${diffHours} hours ago`;
|
|
292
|
+
if (diffDays === 1) return 'Yesterday';
|
|
293
|
+
if (diffDays < 7) return `${diffDays} days ago`;
|
|
294
|
+
return date.toLocaleDateString();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Calculate duration string from start time
|
|
299
|
+
* @param {string} startTime - ISO timestamp string
|
|
300
|
+
* @returns {string} Duration string (e.g., "5m 30s")
|
|
301
|
+
*/
|
|
302
|
+
export function calculateDuration(startTime) {
|
|
303
|
+
const start = new Date(startTime);
|
|
304
|
+
const end = new Date();
|
|
305
|
+
const durationMs = end - start;
|
|
306
|
+
const minutes = Math.floor(durationMs / TIME.MINUTE);
|
|
307
|
+
const seconds = Math.floor((durationMs % TIME.MINUTE) / TIME.SECOND);
|
|
308
|
+
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get ISO date string (YYYY-MM-DD)
|
|
313
|
+
* @param {Date} date - Date object (default: now)
|
|
314
|
+
* @returns {string} Date string
|
|
315
|
+
*/
|
|
316
|
+
export function getDateString(date = new Date()) {
|
|
317
|
+
return date.toISOString().split('T')[0];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ============================================================================
|
|
321
|
+
// Stdin Utilities
|
|
322
|
+
// ============================================================================
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read stdin with timeout protection
|
|
326
|
+
* Properly removes event listeners to prevent memory leaks
|
|
327
|
+
* @param {number} timeout - Timeout in milliseconds
|
|
328
|
+
* @returns {Promise<string>} Stdin content
|
|
329
|
+
*/
|
|
330
|
+
export function readStdin(timeout = 3000) {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
// Fast path: stdin already closed/ended — avoids 3s timeout hang
|
|
333
|
+
if (process.stdin.readableEnded || process.stdin.destroyed) {
|
|
334
|
+
return resolve('');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let data = '';
|
|
338
|
+
|
|
339
|
+
const cleanup = () => {
|
|
340
|
+
process.stdin.removeListener('data', onData);
|
|
341
|
+
process.stdin.removeListener('end', onEnd);
|
|
342
|
+
process.stdin.removeListener('error', onError);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const timer = setTimeout(() => {
|
|
346
|
+
cleanup();
|
|
347
|
+
reject(new Error('Stdin read timeout'));
|
|
348
|
+
}, timeout);
|
|
349
|
+
|
|
350
|
+
const onData = (chunk) => {
|
|
351
|
+
data += chunk;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const onEnd = () => {
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
cleanup();
|
|
357
|
+
resolve(data);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const onError = (err) => {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
cleanup();
|
|
363
|
+
reject(err);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
process.stdin.on('data', onData);
|
|
367
|
+
process.stdin.on('end', onEnd);
|
|
368
|
+
process.stdin.on('error', onError);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Batch SQLite Operations
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Execute multiple SQLite statements in a single process spawn.
|
|
378
|
+
* Wraps all statements in BEGIN/COMMIT for atomicity.
|
|
379
|
+
*
|
|
380
|
+
* Performance: 1 process spawn instead of N, saving ~100ms per avoided spawn.
|
|
381
|
+
*
|
|
382
|
+
* @param {string} dbPath - Path to SQLite database
|
|
383
|
+
* @param {Array<{query: string, params?: Array}>} statements - SQL statements
|
|
384
|
+
* @param {Object} options - Options
|
|
385
|
+
* @param {number} options.timeout - Timeout in ms (default: 10000)
|
|
386
|
+
* @param {number} options.chunkSize - Max statements per batch (default: 50)
|
|
387
|
+
* @returns {string|null} Combined output, or null on error
|
|
388
|
+
*/
|
|
389
|
+
export function sqliteBatch(dbPath, statements, options = {}) {
|
|
390
|
+
const { timeout = 10000, chunkSize = 50 } = options;
|
|
391
|
+
|
|
392
|
+
if (!statements || statements.length === 0) return '';
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const chunks = [];
|
|
396
|
+
for (let i = 0; i < statements.length; i += chunkSize) {
|
|
397
|
+
chunks.push(statements.slice(i, i + chunkSize));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let output = '';
|
|
401
|
+
for (const chunk of chunks) {
|
|
402
|
+
const batchSQL = ['BEGIN TRANSACTION;'];
|
|
403
|
+
|
|
404
|
+
for (const stmt of chunk) {
|
|
405
|
+
let finalQuery = stmt.query;
|
|
406
|
+
if (stmt.params && stmt.params.length > 0) {
|
|
407
|
+
let paramIndex = 0;
|
|
408
|
+
finalQuery = stmt.query.replace(/\?/g, () => {
|
|
409
|
+
if (paramIndex < stmt.params.length) {
|
|
410
|
+
return escapeSQL(stmt.params[paramIndex++]);
|
|
411
|
+
}
|
|
412
|
+
return '?';
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!finalQuery.trim().endsWith(';')) {
|
|
416
|
+
finalQuery += ';';
|
|
417
|
+
}
|
|
418
|
+
batchSQL.push(finalQuery);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
batchSQL.push('COMMIT;');
|
|
422
|
+
|
|
423
|
+
// Pipe SQL via stdin to avoid E2BIG on large batches
|
|
424
|
+
const result = execFileSync('sqlite3', [dbPath], {
|
|
425
|
+
encoding: 'utf-8',
|
|
426
|
+
timeout,
|
|
427
|
+
input: batchSQL.join('\n'),
|
|
428
|
+
});
|
|
429
|
+
if (result.trim()) {
|
|
430
|
+
output += result.trim() + '\n';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return output.trim();
|
|
435
|
+
} catch (error) {
|
|
436
|
+
logError('sqliteBatch', error);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Insert entity + observations + tags in minimal spawns.
|
|
443
|
+
* Common pattern used by post-commit and stop hooks.
|
|
444
|
+
*
|
|
445
|
+
* Uses a three-step approach:
|
|
446
|
+
* 1. INSERT entity (1 spawn)
|
|
447
|
+
* 2. SELECT entity ID (1 spawn)
|
|
448
|
+
* 3. Batch INSERT all observations + tags (1 spawn)
|
|
449
|
+
*
|
|
450
|
+
* Total: 3 spawns (was N+2 before batching).
|
|
451
|
+
*
|
|
452
|
+
* @param {string} dbPath - Path to SQLite database
|
|
453
|
+
* @param {Object} entity - Entity to insert
|
|
454
|
+
* @param {string} entity.name - Entity name (must be unique)
|
|
455
|
+
* @param {string} entity.type - Entity type
|
|
456
|
+
* @param {string} entity.metadata - JSON metadata string
|
|
457
|
+
* @param {Array<string>} observations - Observation content strings
|
|
458
|
+
* @param {Array<string>} tags - Tag strings
|
|
459
|
+
* @returns {number|null} Entity ID, or null on failure
|
|
460
|
+
*/
|
|
461
|
+
export function sqliteBatchEntity(dbPath, entity, observations = [], tags = []) {
|
|
462
|
+
try {
|
|
463
|
+
const now = new Date().toISOString();
|
|
464
|
+
|
|
465
|
+
// Step 1: Insert entity (need the ID for subsequent inserts)
|
|
466
|
+
const insertResult = sqliteQuery(
|
|
467
|
+
dbPath,
|
|
468
|
+
'INSERT INTO entities (name, type, created_at, metadata) VALUES (?, ?, ?, ?)',
|
|
469
|
+
[entity.name, entity.type, now, entity.metadata || '{}']
|
|
470
|
+
);
|
|
471
|
+
if (insertResult === null) return null;
|
|
472
|
+
|
|
473
|
+
const entityIdResult = sqliteQuery(
|
|
474
|
+
dbPath,
|
|
475
|
+
'SELECT id FROM entities WHERE name = ?',
|
|
476
|
+
[entity.name]
|
|
477
|
+
);
|
|
478
|
+
if (entityIdResult === null) return null;
|
|
479
|
+
const entityId = parseInt(entityIdResult, 10);
|
|
480
|
+
if (isNaN(entityId)) return null;
|
|
481
|
+
|
|
482
|
+
// Step 2: Batch all observations and tags in one spawn
|
|
483
|
+
const statements = [];
|
|
484
|
+
|
|
485
|
+
for (const obs of observations) {
|
|
486
|
+
statements.push({
|
|
487
|
+
query: 'INSERT INTO observations (entity_id, content, created_at) VALUES (?, ?, ?)',
|
|
488
|
+
params: [entityId, obs, now],
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
for (const tag of tags) {
|
|
493
|
+
statements.push({
|
|
494
|
+
query: 'INSERT INTO tags (entity_id, tag) VALUES (?, ?)',
|
|
495
|
+
params: [entityId, tag],
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (statements.length > 0) {
|
|
500
|
+
const batchResult = sqliteBatch(dbPath, statements);
|
|
501
|
+
if (batchResult === null) {
|
|
502
|
+
// Clean up orphaned entity — batch failed so observations/tags rolled back
|
|
503
|
+
logError('sqliteBatchEntity', new Error(`Batch failed for entity ${entity.name}, cleaning up orphan`));
|
|
504
|
+
sqliteQuery(dbPath, 'DELETE FROM entities WHERE id = ?', [entityId]);
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return entityId;
|
|
510
|
+
} catch (error) {
|
|
511
|
+
logError('sqliteBatchEntity', error);
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ============================================================================
|
|
517
|
+
// Async File I/O
|
|
518
|
+
// ============================================================================
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Write JSON file asynchronously (non-blocking).
|
|
522
|
+
* Returns a promise so callers can await if needed.
|
|
523
|
+
* @param {string} filePath - Path to JSON file
|
|
524
|
+
* @param {*} data - Data to write
|
|
525
|
+
* @returns {Promise<boolean>} True if successful
|
|
526
|
+
*/
|
|
527
|
+
export function writeJSONFileAsync(filePath, data) {
|
|
528
|
+
return new Promise((resolve) => {
|
|
529
|
+
const content = JSON.stringify(data, null, 2);
|
|
530
|
+
fs.writeFile(filePath, content, 'utf-8', (err) => {
|
|
531
|
+
if (err) {
|
|
532
|
+
logError(`Async write error ${path.basename(filePath)}`, err);
|
|
533
|
+
}
|
|
534
|
+
resolve(!err);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Ensure state directory exists on module load
|
|
540
|
+
ensureDir(STATE_DIR);
|
|
541
|
+
|
|
542
|
+
// ============================================================================
|
|
543
|
+
// Plan-Aware Memory Hooks (Beta)
|
|
544
|
+
// ============================================================================
|
|
545
|
+
|
|
546
|
+
/** File path patterns that indicate a plan file (requires docs/ context) */
|
|
547
|
+
const PLAN_PATTERNS = [
|
|
548
|
+
/docs\/plans\/.*\.md$/,
|
|
549
|
+
/docs\/.*-design\.md$/,
|
|
550
|
+
/docs\/.*-plan\.md$/,
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Check if a file path matches plan file patterns.
|
|
555
|
+
* @param {string} filePath - File path to check
|
|
556
|
+
* @returns {boolean}
|
|
557
|
+
*/
|
|
558
|
+
export function isPlanFile(filePath) {
|
|
559
|
+
if (!filePath) return false;
|
|
560
|
+
return PLAN_PATTERNS.some(p => p.test(filePath));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Common English stop words to filter from tokenization */
|
|
564
|
+
const STOP_WORDS = new Set([
|
|
565
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
566
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
567
|
+
'should', 'may', 'might', 'shall', 'can', 'need', 'dare', 'ought',
|
|
568
|
+
'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from',
|
|
569
|
+
'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below',
|
|
570
|
+
'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then',
|
|
571
|
+
'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'both',
|
|
572
|
+
'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor',
|
|
573
|
+
'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just',
|
|
574
|
+
'because', 'but', 'and', 'or', 'if', 'while', 'that', 'this', 'these',
|
|
575
|
+
'those', 'it', 'its', 'up', 'set',
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Tokenize text into lowercase meaningful words.
|
|
580
|
+
* Removes punctuation, stop words, and words shorter than 3 characters.
|
|
581
|
+
* @param {string} text - Input text
|
|
582
|
+
* @returns {string[]} Array of meaningful words
|
|
583
|
+
*/
|
|
584
|
+
export function tokenize(text) {
|
|
585
|
+
if (!text) return [];
|
|
586
|
+
return text
|
|
587
|
+
.toLowerCase()
|
|
588
|
+
.replace(/[^\w\s]/g, ' ')
|
|
589
|
+
.split(/\s+/)
|
|
590
|
+
.filter(w => w.length > 2 && !STOP_WORDS.has(w));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Extract module/file hints from a step description.
|
|
595
|
+
* Returns words that could match file paths or module names.
|
|
596
|
+
* @param {string} description - Step description text
|
|
597
|
+
* @returns {string[]} Module hint words
|
|
598
|
+
*/
|
|
599
|
+
export function extractModuleHints(description) {
|
|
600
|
+
return tokenize(description);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Derive a human-readable plan name from a file path.
|
|
605
|
+
* Strips date prefixes (YYYY-MM-DD-) and .md extension.
|
|
606
|
+
* @param {string} filePath - File path
|
|
607
|
+
* @returns {string} Plan name
|
|
608
|
+
*/
|
|
609
|
+
export function derivePlanName(filePath) {
|
|
610
|
+
let name = path.basename(filePath, '.md');
|
|
611
|
+
// Remove date prefix (YYYY-MM-DD-)
|
|
612
|
+
name = name.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
613
|
+
return name;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Parse plan steps from markdown content.
|
|
618
|
+
* Supports checkbox format (- [ ] ...) and heading format (## Step N: ...).
|
|
619
|
+
* @param {string} content - Markdown file content
|
|
620
|
+
* @returns {Array<{number: number, description: string, completed: boolean}>}
|
|
621
|
+
*/
|
|
622
|
+
export function parsePlanSteps(content) {
|
|
623
|
+
if (!content) return [];
|
|
624
|
+
|
|
625
|
+
const steps = [];
|
|
626
|
+
const lines = content.split('\n');
|
|
627
|
+
let inCodeFence = false;
|
|
628
|
+
|
|
629
|
+
for (const line of lines) {
|
|
630
|
+
const trimmed = line.trim();
|
|
631
|
+
|
|
632
|
+
// Track code fence boundaries (``` with optional language tag)
|
|
633
|
+
if (/^`{3,}/.test(trimmed)) {
|
|
634
|
+
inCodeFence = !inCodeFence;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (inCodeFence) continue;
|
|
638
|
+
|
|
639
|
+
// Format A: Checkbox "- [ ] Step N: description" or "- [ ] description"
|
|
640
|
+
const checkboxMatch = trimmed.match(/^-\s+\[([ xX])\]\s+(?:(?:Step|Task)\s+\d+\s*[:.]\s*)?(.+)/);
|
|
641
|
+
if (checkboxMatch) {
|
|
642
|
+
steps.push({
|
|
643
|
+
number: steps.length + 1,
|
|
644
|
+
description: checkboxMatch[2].trim(),
|
|
645
|
+
completed: checkboxMatch[1].toLowerCase() === 'x',
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Format B: Heading "## Step N: description" or "### Task N: description"
|
|
651
|
+
const headingStepMatch = trimmed.match(/^#{2,4}\s+(?:Step|Task)\s+(\d+)\s*[:.]\s*(.+)/);
|
|
652
|
+
if (headingStepMatch) {
|
|
653
|
+
steps.push({
|
|
654
|
+
number: parseInt(headingStepMatch[1], 10),
|
|
655
|
+
description: headingStepMatch[2].trim(),
|
|
656
|
+
completed: false,
|
|
657
|
+
});
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Format C: Numbered heading "### 1. description"
|
|
662
|
+
const numberedMatch = trimmed.match(/^#{2,4}\s+(\d+)\.\s+(.+)/);
|
|
663
|
+
if (numberedMatch) {
|
|
664
|
+
steps.push({
|
|
665
|
+
number: parseInt(numberedMatch[1], 10),
|
|
666
|
+
description: numberedMatch[2].trim(),
|
|
667
|
+
completed: false,
|
|
668
|
+
});
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return steps;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Match a commit to the best matching uncompleted plan step.
|
|
678
|
+
* Uses keyword overlap + file path hints. Threshold: 0.3.
|
|
679
|
+
* @param {{ subject: string, filesChanged: string[] }} commitInfo
|
|
680
|
+
* @param {Array<{ number: number, description: string, completed: boolean }>} planSteps
|
|
681
|
+
* @returns {{ step: object, confidence: number } | null}
|
|
682
|
+
*/
|
|
683
|
+
export function matchCommitToStep(commitInfo, planSteps) {
|
|
684
|
+
if (!planSteps || planSteps.length === 0) return null;
|
|
685
|
+
if (!commitInfo || !commitInfo.subject) return null;
|
|
686
|
+
|
|
687
|
+
const commitWords = tokenize(commitInfo.subject);
|
|
688
|
+
if (commitWords.length === 0) return null;
|
|
689
|
+
|
|
690
|
+
let bestMatch = null;
|
|
691
|
+
let bestScore = 0;
|
|
692
|
+
|
|
693
|
+
for (const step of planSteps) {
|
|
694
|
+
if (step.completed) continue;
|
|
695
|
+
|
|
696
|
+
const stepWords = tokenize(step.description);
|
|
697
|
+
if (stepWords.length === 0) continue;
|
|
698
|
+
|
|
699
|
+
// Keyword overlap score (0~1)
|
|
700
|
+
const overlap = commitWords.filter(w => stepWords.includes(w));
|
|
701
|
+
let score = overlap.length / stepWords.length;
|
|
702
|
+
|
|
703
|
+
// File path bonus (+0.3)
|
|
704
|
+
const moduleHints = extractModuleHints(step.description);
|
|
705
|
+
const filesChanged = commitInfo.filesChanged || [];
|
|
706
|
+
const fileMatch = filesChanged.some(f =>
|
|
707
|
+
moduleHints.some(hint => f.toLowerCase().includes(hint))
|
|
708
|
+
);
|
|
709
|
+
if (fileMatch) score += 0.3;
|
|
710
|
+
|
|
711
|
+
if (score > bestScore && score > 0.3) {
|
|
712
|
+
bestScore = score;
|
|
713
|
+
bestMatch = step;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!bestMatch) return null;
|
|
718
|
+
|
|
719
|
+
// Return step + confidence (capped at 1.0)
|
|
720
|
+
return { step: bestMatch, confidence: Math.min(bestScore, 1.0) };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Render a full Style B timeline visualization.
|
|
725
|
+
* @param {Object} plan - Plan entity with metadata.stepsDetail
|
|
726
|
+
* @param {number} [highlightStep] - Step number to highlight (just matched)
|
|
727
|
+
* @returns {string} Multi-line timeline string
|
|
728
|
+
*/
|
|
729
|
+
export function renderTimeline(plan, highlightStep = null) {
|
|
730
|
+
const { stepsDetail, totalSteps, completed = 0 } = plan.metadata;
|
|
731
|
+
if (!stepsDetail || stepsDetail.length === 0 || !totalSteps) return '';
|
|
732
|
+
|
|
733
|
+
const pct = Math.round((completed / totalSteps) * 100);
|
|
734
|
+
const planName = plan.name.replace('Plan: ', '');
|
|
735
|
+
const nextStep = stepsDetail.find(st => !st.completed);
|
|
736
|
+
|
|
737
|
+
// Node symbols: ◉ highlighted (just-matched), ● completed, ◉ next, ○ pending
|
|
738
|
+
const nodes = stepsDetail.map(s => {
|
|
739
|
+
if (s.number === highlightStep) return '\u25c9';
|
|
740
|
+
if (s.completed) return '\u25cf';
|
|
741
|
+
if (nextStep && s.number === nextStep.number) return '\u25c9';
|
|
742
|
+
return '\u25cb';
|
|
743
|
+
}).join(' \u2500\u2500\u2500\u2500 ');
|
|
744
|
+
|
|
745
|
+
// Step numbers row
|
|
746
|
+
const numbers = stepsDetail.map(s =>
|
|
747
|
+
String(s.number).padEnd(6)
|
|
748
|
+
).join('');
|
|
749
|
+
|
|
750
|
+
const separator = '\u2501'.repeat(40);
|
|
751
|
+
|
|
752
|
+
const lines = [
|
|
753
|
+
` \ud83d\udccb ${planName}`,
|
|
754
|
+
` ${separator}`,
|
|
755
|
+
` ${nodes}`,
|
|
756
|
+
` ${numbers} ${pct}% done`,
|
|
757
|
+
` ${separator}`,
|
|
758
|
+
];
|
|
759
|
+
|
|
760
|
+
if (highlightStep) {
|
|
761
|
+
const commitRef = plan._lastCommit || '';
|
|
762
|
+
const confidence = plan._matchConfidence || 1.0;
|
|
763
|
+
const marker = confidence < 0.6 ? ' (?)' : '';
|
|
764
|
+
lines.push(` \u2705 Step ${highlightStep} matched${marker} \u2190 ${commitRef}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (nextStep && completed < totalSteps) {
|
|
768
|
+
lines.push(` \u25b6 Next: Step ${nextStep.number} - ${nextStep.description}`);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (completed === totalSteps) {
|
|
772
|
+
lines.push(` \ud83c\udf89 Plan complete!`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return lines.join('\n');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Render a compact Style B timeline for session-start display.
|
|
780
|
+
* @param {Object} plan - Plan entity with metadata.stepsDetail
|
|
781
|
+
* @returns {string} 3-line compact timeline string
|
|
782
|
+
*/
|
|
783
|
+
export function renderTimelineCompact(plan) {
|
|
784
|
+
const { stepsDetail, totalSteps, completed = 0 } = plan.metadata;
|
|
785
|
+
if (!stepsDetail || stepsDetail.length === 0 || !totalSteps) return '';
|
|
786
|
+
|
|
787
|
+
const pct = Math.round((completed / totalSteps) * 100);
|
|
788
|
+
const planName = plan.name.replace('Plan: ', '');
|
|
789
|
+
|
|
790
|
+
const nodes = stepsDetail.map(s =>
|
|
791
|
+
s.completed ? '\u25cf' : '\u25cb'
|
|
792
|
+
).join(' \u2500\u2500\u2500\u2500 ');
|
|
793
|
+
|
|
794
|
+
const next = stepsDetail.find(s => !s.completed);
|
|
795
|
+
|
|
796
|
+
return [
|
|
797
|
+
` \ud83d\udccb ${planName}`,
|
|
798
|
+
` ${nodes} ${pct}%`,
|
|
799
|
+
next ? ` \u25b6 Next: ${next.description}` : ` \ud83c\udf89 Complete`,
|
|
800
|
+
].join('\n');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ============================================================================
|
|
804
|
+
// Plan DB Operations
|
|
805
|
+
// ============================================================================
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Query active plan entities from KG.
|
|
809
|
+
* @param {string} dbPath - Path to SQLite database
|
|
810
|
+
* @returns {Array<{name: string, metadata: object}>}
|
|
811
|
+
*/
|
|
812
|
+
export function queryActivePlans(dbPath) {
|
|
813
|
+
try {
|
|
814
|
+
if (!fs.existsSync(dbPath)) return [];
|
|
815
|
+
|
|
816
|
+
const rows = sqliteQueryJSON(dbPath,
|
|
817
|
+
`SELECT e.name, e.metadata FROM entities e
|
|
818
|
+
JOIN tags t ON t.entity_id = e.id
|
|
819
|
+
WHERE e.type = ? AND t.tag = ?`,
|
|
820
|
+
['workflow_checkpoint', 'active']
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
if (!rows) return [];
|
|
824
|
+
|
|
825
|
+
return rows.map(row => ({
|
|
826
|
+
name: row.name,
|
|
827
|
+
metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata || '{}') : row.metadata,
|
|
828
|
+
}));
|
|
829
|
+
} catch (error) {
|
|
830
|
+
logError('queryActivePlans', error);
|
|
831
|
+
return [];
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Add an observation to an existing entity.
|
|
837
|
+
* @param {string} dbPath - Path to SQLite database
|
|
838
|
+
* @param {string} entityName - Entity name
|
|
839
|
+
* @param {string} content - Observation content
|
|
840
|
+
* @returns {boolean}
|
|
841
|
+
*/
|
|
842
|
+
export function addObservation(dbPath, entityName, content) {
|
|
843
|
+
const result = sqliteQuery(dbPath,
|
|
844
|
+
`INSERT INTO observations (entity_id, content, created_at)
|
|
845
|
+
SELECT id, ?, ? FROM entities WHERE name = ?`,
|
|
846
|
+
[content, new Date().toISOString(), entityName]
|
|
847
|
+
);
|
|
848
|
+
return result !== null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Update an entity's metadata JSON.
|
|
853
|
+
* @param {string} dbPath - Path to SQLite database
|
|
854
|
+
* @param {string} entityName - Entity name
|
|
855
|
+
* @param {object} metadata - New metadata object
|
|
856
|
+
* @returns {boolean}
|
|
857
|
+
*/
|
|
858
|
+
export function updateEntityMetadata(dbPath, entityName, metadata) {
|
|
859
|
+
const result = sqliteQuery(dbPath,
|
|
860
|
+
'UPDATE entities SET metadata = ? WHERE name = ?',
|
|
861
|
+
[JSON.stringify(metadata), entityName]
|
|
862
|
+
);
|
|
863
|
+
return result !== null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Replace a tag on an entity.
|
|
868
|
+
* @param {string} dbPath - Path to SQLite database
|
|
869
|
+
* @param {string} entityName - Entity name
|
|
870
|
+
* @param {string} oldTag - Tag to replace
|
|
871
|
+
* @param {string} newTag - New tag value
|
|
872
|
+
* @returns {boolean}
|
|
873
|
+
*/
|
|
874
|
+
export function updateEntityTag(dbPath, entityName, oldTag, newTag) {
|
|
875
|
+
const result = sqliteQuery(dbPath,
|
|
876
|
+
`UPDATE tags SET tag = ? WHERE tag = ? AND entity_id = (SELECT id FROM entities WHERE name = ?)`,
|
|
877
|
+
[newTag, oldTag, entityName]
|
|
878
|
+
);
|
|
879
|
+
return result !== null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Create a relation between two entities.
|
|
884
|
+
* @param {string} dbPath - Path to SQLite database
|
|
885
|
+
* @param {string} fromName - Source entity name
|
|
886
|
+
* @param {string} toName - Target entity name
|
|
887
|
+
* @param {string} relationType - Relation type (e.g. 'depends_on')
|
|
888
|
+
* @returns {boolean}
|
|
889
|
+
*/
|
|
890
|
+
export function createRelation(dbPath, fromName, toName, relationType) {
|
|
891
|
+
const result = sqliteQuery(dbPath,
|
|
892
|
+
`INSERT OR IGNORE INTO relations (from_entity_id, to_entity_id, relation_type, created_at)
|
|
893
|
+
SELECT f.id, t.id, ?, ?
|
|
894
|
+
FROM entities f, entities t
|
|
895
|
+
WHERE f.name = ? AND t.name = ?`,
|
|
896
|
+
[relationType, new Date().toISOString(), fromName, toName]
|
|
897
|
+
);
|
|
898
|
+
return result !== null;
|
|
899
|
+
}
|