@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.
Files changed (79) hide show
  1. package/LICENSE +21 -661
  2. package/README.de.md +171 -0
  3. package/README.es.md +171 -0
  4. package/README.fr.md +171 -0
  5. package/README.id.md +171 -0
  6. package/README.ja.md +171 -0
  7. package/README.ko.md +171 -0
  8. package/README.md +73 -100
  9. package/README.th.md +171 -0
  10. package/README.vi.md +171 -0
  11. package/README.zh-CN.md +171 -0
  12. package/README.zh-TW.md +71 -98
  13. package/dist/knowledge-graph/index.d.ts +22 -1
  14. package/dist/knowledge-graph/index.d.ts.map +1 -1
  15. package/dist/knowledge-graph/index.js +144 -3
  16. package/dist/knowledge-graph/index.js.map +1 -1
  17. package/dist/mcp/ServerInitializer.d.ts.map +1 -1
  18. package/dist/mcp/ServerInitializer.js +1 -1
  19. package/dist/mcp/ServerInitializer.js.map +1 -1
  20. package/dist/mcp/ToolDefinitions.d.ts.map +1 -1
  21. package/dist/mcp/ToolDefinitions.js +47 -55
  22. package/dist/mcp/ToolDefinitions.js.map +1 -1
  23. package/dist/mcp/ToolRouter.d.ts.map +1 -1
  24. package/dist/mcp/ToolRouter.js +4 -4
  25. package/dist/mcp/ToolRouter.js.map +1 -1
  26. package/dist/mcp/daemon/StdioProxyClient.d.ts.map +1 -1
  27. package/dist/mcp/daemon/StdioProxyClient.js +9 -1
  28. package/dist/mcp/daemon/StdioProxyClient.js.map +1 -1
  29. package/dist/mcp/handlers/BuddyHandlers.d.ts +3 -1
  30. package/dist/mcp/handlers/BuddyHandlers.d.ts.map +1 -1
  31. package/dist/mcp/handlers/BuddyHandlers.js +6 -5
  32. package/dist/mcp/handlers/BuddyHandlers.js.map +1 -1
  33. package/dist/mcp/handlers/ToolHandlers.d.ts.map +1 -1
  34. package/dist/mcp/handlers/ToolHandlers.js +1 -2
  35. package/dist/mcp/handlers/ToolHandlers.js.map +1 -1
  36. package/dist/mcp/resources/quick-reference.md +1 -1
  37. package/dist/mcp/schemas/OutputSchemas.d.ts +116 -53
  38. package/dist/mcp/schemas/OutputSchemas.d.ts.map +1 -1
  39. package/dist/mcp/schemas/OutputSchemas.js +64 -26
  40. package/dist/mcp/schemas/OutputSchemas.js.map +1 -1
  41. package/dist/mcp/server-bootstrap.js +89 -9
  42. package/dist/mcp/server-bootstrap.js.map +1 -1
  43. package/dist/mcp/tools/buddy-do.d.ts +2 -1
  44. package/dist/mcp/tools/buddy-do.d.ts.map +1 -1
  45. package/dist/mcp/tools/buddy-do.js +91 -4
  46. package/dist/mcp/tools/buddy-do.js.map +1 -1
  47. package/dist/mcp/tools/buddy-remember.d.ts +0 -5
  48. package/dist/mcp/tools/buddy-remember.d.ts.map +1 -1
  49. package/dist/mcp/tools/buddy-remember.js.map +1 -1
  50. package/dist/mcp/tools/memesh-agent-register.d.ts +20 -0
  51. package/dist/mcp/tools/memesh-agent-register.d.ts.map +1 -0
  52. package/dist/mcp/tools/memesh-agent-register.js +80 -0
  53. package/dist/mcp/tools/memesh-agent-register.js.map +1 -0
  54. package/dist/mcp/tools/memesh-cloud-sync.js +27 -8
  55. package/dist/mcp/tools/memesh-cloud-sync.js.map +1 -1
  56. package/dist/mcp/tools/memesh-metrics.d.ts +13 -0
  57. package/dist/mcp/tools/memesh-metrics.d.ts.map +1 -0
  58. package/dist/mcp/tools/memesh-metrics.js +193 -0
  59. package/dist/mcp/tools/memesh-metrics.js.map +1 -0
  60. package/dist/memory/UnifiedMemoryStore.d.ts +1 -1
  61. package/dist/memory/UnifiedMemoryStore.d.ts.map +1 -1
  62. package/dist/memory/UnifiedMemoryStore.js +4 -3
  63. package/dist/memory/UnifiedMemoryStore.js.map +1 -1
  64. package/package.json +9 -12
  65. package/plugin.json +2 -2
  66. package/scripts/hooks/README.md +230 -0
  67. package/scripts/hooks/__tests__/hook-test-harness.js +218 -0
  68. package/scripts/hooks/__tests__/hooks.test.js +267 -0
  69. package/scripts/hooks/hook-utils.js +899 -0
  70. package/scripts/hooks/post-commit.js +307 -0
  71. package/scripts/hooks/post-tool-use.js +812 -0
  72. package/scripts/hooks/pre-tool-use.js +462 -0
  73. package/scripts/hooks/session-start.js +544 -0
  74. package/scripts/hooks/stop.js +673 -0
  75. package/scripts/hooks/subagent-stop.js +184 -0
  76. package/scripts/hooks/templates/planning-template.md +46 -0
  77. package/scripts/postinstall-lib.js +8 -4
  78. package/scripts/postinstall-new.js +110 -7
  79. 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
+ }