@awareness-sdk/local 0.1.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/src/daemon.mjs ADDED
@@ -0,0 +1,1720 @@
1
+ /**
2
+ * AwarenessLocalDaemon — HTTP server + MCP transport for Awareness Local.
3
+ *
4
+ * Binds to 127.0.0.1 (loopback only) and routes:
5
+ * /healthz → health check JSON
6
+ * /mcp → MCP Streamable HTTP (JSON-RPC over POST)
7
+ * /api/v1/* → REST API (Phase 4)
8
+ * / → Web UI placeholder (Phase 4)
9
+ *
10
+ * Lifecycle:
11
+ * start() → init modules → incremental index → HTTP listen → PID file → fs.watch
12
+ * stop() → close watcher → close HTTP → remove PID
13
+ * isRunning() → PID file + healthz probe
14
+ */
15
+
16
+ import http from 'node:http';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { MemoryStore } from './core/memory-store.mjs';
22
+ import { Indexer } from './core/indexer.mjs';
23
+ import { CloudSync } from './core/cloud-sync.mjs';
24
+ import { LocalMcpServer } from './mcp-server.mjs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Constants
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const DEFAULT_PORT = 37800;
31
+ const BIND_HOST = '127.0.0.1';
32
+ const AWARENESS_DIR = '.awareness';
33
+ const PID_FILENAME = 'daemon.pid';
34
+ const LOG_FILENAME = 'daemon.log';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Current ISO timestamp. */
41
+ function nowISO() {
42
+ return new Date().toISOString();
43
+ }
44
+
45
+ /**
46
+ * Send a JSON response.
47
+ * @param {http.ServerResponse} res
48
+ * @param {object} data
49
+ * @param {number} [status=200]
50
+ */
51
+ function jsonResponse(res, data, status = 200) {
52
+ const body = JSON.stringify(data);
53
+ res.writeHead(status, {
54
+ 'Content-Type': 'application/json',
55
+ 'Content-Length': Buffer.byteLength(body),
56
+ 'Access-Control-Allow-Origin': '*',
57
+ });
58
+ res.end(body);
59
+ }
60
+
61
+ /**
62
+ * Read the full request body as a string.
63
+ * @param {http.IncomingMessage} req
64
+ * @returns {Promise<string>}
65
+ */
66
+ function readBody(req) {
67
+ return new Promise((resolve, reject) => {
68
+ const chunks = [];
69
+ req.on('data', (chunk) => chunks.push(chunk));
70
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
71
+ req.on('error', reject);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Minimal HTTP GET health check against localhost.
77
+ * Resolves true if status 200, false otherwise.
78
+ * @param {number} port
79
+ * @param {number} [timeoutMs=2000]
80
+ * @returns {Promise<boolean>}
81
+ */
82
+ function httpHealthCheck(port, timeoutMs = 2000) {
83
+ return new Promise((resolve) => {
84
+ const req = http.get(
85
+ { hostname: '127.0.0.1', port, path: '/healthz', timeout: timeoutMs },
86
+ (res) => {
87
+ // Drain body
88
+ res.resume();
89
+ resolve(res.statusCode === 200);
90
+ }
91
+ );
92
+ req.on('error', () => resolve(false));
93
+ req.on('timeout', () => {
94
+ req.destroy();
95
+ resolve(false);
96
+ });
97
+ });
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // AwarenessLocalDaemon
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export class AwarenessLocalDaemon {
105
+ /**
106
+ * @param {object} [options]
107
+ * @param {number} [options.port=37800] — HTTP listen port
108
+ * @param {string} [options.projectDir=cwd] — project root directory
109
+ */
110
+ constructor(options = {}) {
111
+ this.port = options.port || DEFAULT_PORT;
112
+ this.projectDir = options.projectDir || process.cwd();
113
+
114
+ this.awarenessDir = path.join(this.projectDir, AWARENESS_DIR);
115
+ this.pidFile = path.join(this.awarenessDir, PID_FILENAME);
116
+ this.logFile = path.join(this.awarenessDir, LOG_FILENAME);
117
+
118
+ // Modules — initialised in start()
119
+ this.memoryStore = null;
120
+ this.indexer = null;
121
+ this.search = null;
122
+ this.extractor = null;
123
+ this.mcpServer = null;
124
+ this.cloudSync = null;
125
+ this.httpServer = null;
126
+ this.watcher = null;
127
+
128
+ // Debounce timer for fs.watch reindex
129
+ this._reindexTimer = null;
130
+ this._reindexDebounceMs = 1000;
131
+
132
+ // Track uptime
133
+ this._startedAt = null;
134
+
135
+ // Active MCP sessions (session-id → transport)
136
+ this._mcpSessions = new Map();
137
+ }
138
+
139
+ // -----------------------------------------------------------------------
140
+ // Lifecycle
141
+ // -----------------------------------------------------------------------
142
+
143
+ /**
144
+ * Start the daemon.
145
+ * 1. Check if another instance is running
146
+ * 2. Initialise all core modules
147
+ * 3. Run incremental index
148
+ * 4. Start HTTP server
149
+ * 5. Set up MCP server
150
+ * 6. Write PID file
151
+ * 7. Start fs.watch on memories dir
152
+ */
153
+ async start() {
154
+ if (await this.isRunning()) {
155
+ console.log(
156
+ `[awareness-local] daemon already running on port ${this.port}`
157
+ );
158
+ return { alreadyRunning: true, port: this.port };
159
+ }
160
+
161
+ // Ensure directory structure
162
+ fs.mkdirSync(path.join(this.awarenessDir, 'memories'), { recursive: true });
163
+ fs.mkdirSync(path.join(this.awarenessDir, 'knowledge'), { recursive: true });
164
+ fs.mkdirSync(path.join(this.awarenessDir, 'tasks'), { recursive: true });
165
+
166
+ // ---- Init core modules ----
167
+ this.memoryStore = new MemoryStore(this.projectDir);
168
+ this.indexer = new Indexer(
169
+ path.join(this.awarenessDir, 'index.db')
170
+ );
171
+
172
+ // Search and extractor are optional Phase 1 modules — import dynamically
173
+ // so that missing files don't break daemon startup.
174
+ this.search = await this._loadSearchEngine();
175
+ this.extractor = await this._loadKnowledgeExtractor();
176
+
177
+ // ---- Incremental index ----
178
+ try {
179
+ const indexResult = await this.indexer.incrementalIndex(this.memoryStore);
180
+ console.log(
181
+ `[awareness-local] indexed ${indexResult.indexed} files, ` +
182
+ `skipped ${indexResult.skipped}`
183
+ );
184
+ } catch (err) {
185
+ console.error('[awareness-local] incremental index error:', err.message);
186
+ }
187
+
188
+ // ---- MCP server ----
189
+ this.mcpServer = new LocalMcpServer({
190
+ memoryStore: this.memoryStore,
191
+ indexer: this.indexer,
192
+ search: this.search,
193
+ extractor: this.extractor,
194
+ config: this._loadConfig(),
195
+ loadSpec: () => this._loadSpec(),
196
+ createSession: (source) => this._createSession(source),
197
+ remember: (params) => this._remember(params),
198
+ rememberBatch: (params) => this._rememberBatch(params),
199
+ updateTask: (params) => this._updateTask(params),
200
+ submitInsights: (params) => this._submitInsights(params),
201
+ lookup: (params) => this._lookup(params),
202
+ });
203
+
204
+ // ---- Cloud sync (optional) ----
205
+ const config = this._loadConfig();
206
+ if (config.cloud?.enabled) {
207
+ try {
208
+ this.cloudSync = new CloudSync(config, this.indexer, this.memoryStore);
209
+ if (this.cloudSync.isEnabled()) {
210
+ // Start cloud sync (non-blocking — errors won't prevent daemon startup)
211
+ this.cloudSync.start().catch((err) => {
212
+ console.warn('[awareness-local] cloud sync start failed:', err.message);
213
+ });
214
+ }
215
+ } catch (err) {
216
+ console.warn('[awareness-local] cloud sync init failed:', err.message);
217
+ this.cloudSync = null;
218
+ }
219
+ }
220
+
221
+ // ---- HTTP server ----
222
+ this.httpServer = http.createServer((req, res) =>
223
+ this._handleRequest(req, res)
224
+ );
225
+
226
+ await new Promise((resolve, reject) => {
227
+ this.httpServer.on('error', reject);
228
+ this.httpServer.listen(this.port, BIND_HOST, () => resolve());
229
+ });
230
+
231
+ this._startedAt = Date.now();
232
+
233
+ // ---- PID file ----
234
+ fs.writeFileSync(this.pidFile, String(process.pid), 'utf-8');
235
+
236
+ // ---- File watcher ----
237
+ this._startFileWatcher();
238
+
239
+ console.log(
240
+ `[awareness-local] daemon running at http://localhost:${this.port}`
241
+ );
242
+ console.log(
243
+ `[awareness-local] MCP endpoint: http://localhost:${this.port}/mcp`
244
+ );
245
+
246
+ return { started: true, port: this.port, pid: process.pid };
247
+ }
248
+
249
+ /**
250
+ * Stop the daemon gracefully.
251
+ */
252
+ async stop() {
253
+ // Stop file watcher
254
+ if (this.watcher) {
255
+ this.watcher.close();
256
+ this.watcher = null;
257
+ }
258
+ if (this._reindexTimer) {
259
+ clearTimeout(this._reindexTimer);
260
+ this._reindexTimer = null;
261
+ }
262
+
263
+ // Stop cloud sync
264
+ if (this.cloudSync) {
265
+ this.cloudSync.stop();
266
+ this.cloudSync = null;
267
+ }
268
+
269
+ // Close MCP sessions
270
+ this._mcpSessions.clear();
271
+
272
+ // Close HTTP server
273
+ if (this.httpServer) {
274
+ await new Promise((resolve) => this.httpServer.close(resolve));
275
+ this.httpServer = null;
276
+ }
277
+
278
+ // Close SQLite
279
+ if (this.indexer) {
280
+ this.indexer.close();
281
+ this.indexer = null;
282
+ }
283
+
284
+ // Remove PID file
285
+ try {
286
+ if (fs.existsSync(this.pidFile)) {
287
+ fs.unlinkSync(this.pidFile);
288
+ }
289
+ } catch {
290
+ // ignore cleanup errors
291
+ }
292
+
293
+ console.log('[awareness-local] daemon stopped');
294
+ }
295
+
296
+ /**
297
+ * Check if a daemon instance is already running.
298
+ * Validates both PID file and HTTP healthz endpoint.
299
+ * @returns {Promise<boolean>}
300
+ */
301
+ async isRunning() {
302
+ if (!fs.existsSync(this.pidFile)) return false;
303
+
304
+ let pid;
305
+ try {
306
+ pid = parseInt(fs.readFileSync(this.pidFile, 'utf-8').trim(), 10);
307
+ } catch {
308
+ return false;
309
+ }
310
+
311
+ // Check if process exists
312
+ try {
313
+ process.kill(pid, 0);
314
+ } catch {
315
+ // Process dead — stale PID file
316
+ this._cleanPidFile();
317
+ return false;
318
+ }
319
+
320
+ // Also verify HTTP endpoint is responsive
321
+ const healthy = await httpHealthCheck(this.port);
322
+ if (!healthy) {
323
+ this._cleanPidFile();
324
+ return false;
325
+ }
326
+
327
+ return true;
328
+ }
329
+
330
+ // -----------------------------------------------------------------------
331
+ // HTTP routing
332
+ // -----------------------------------------------------------------------
333
+
334
+ /**
335
+ * Route incoming HTTP requests.
336
+ * @param {http.IncomingMessage} req
337
+ * @param {http.ServerResponse} res
338
+ */
339
+ async _handleRequest(req, res) {
340
+ // CORS preflight
341
+ if (req.method === 'OPTIONS') {
342
+ res.writeHead(204, {
343
+ 'Access-Control-Allow-Origin': '*',
344
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
345
+ 'Access-Control-Allow-Headers': 'Content-Type, Mcp-Session-Id',
346
+ });
347
+ res.end();
348
+ return;
349
+ }
350
+
351
+ const url = new URL(req.url, `http://localhost:${this.port}`);
352
+
353
+ try {
354
+ // /healthz
355
+ if (url.pathname === '/healthz') {
356
+ return this._handleHealthz(req, res);
357
+ }
358
+
359
+ // /mcp — MCP JSON-RPC over HTTP
360
+ if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) {
361
+ return await this._handleMcp(req, res);
362
+ }
363
+
364
+ // /api/v1/* — REST API
365
+ if (url.pathname.startsWith('/api/v1')) {
366
+ return await this._handleApi(req, res, url);
367
+ }
368
+
369
+ // / — Web Dashboard
370
+ if (url.pathname === '/' || url.pathname.startsWith('/web')) {
371
+ return this._handleWebUI(req, res);
372
+ }
373
+
374
+ // 404
375
+ jsonResponse(res, { error: 'Not Found' }, 404);
376
+ } catch (err) {
377
+ console.error('[awareness-local] request error:', err.message);
378
+ jsonResponse(res, { error: 'Internal Server Error' }, 500);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * GET /healthz — health check + stats.
384
+ */
385
+ _handleHealthz(_req, res) {
386
+ const stats = this.indexer
387
+ ? this.indexer.getStats()
388
+ : { totalMemories: 0, totalKnowledge: 0, totalTasks: 0, totalSessions: 0 };
389
+
390
+ jsonResponse(res, {
391
+ status: 'ok',
392
+ mode: 'local',
393
+ version: '0.1.0',
394
+ uptime: this._startedAt
395
+ ? Math.floor((Date.now() - this._startedAt) / 1000)
396
+ : 0,
397
+ pid: process.pid,
398
+ port: this.port,
399
+ project_dir: this.projectDir,
400
+ stats,
401
+ });
402
+ }
403
+
404
+ /**
405
+ * POST /mcp — Handle MCP JSON-RPC requests.
406
+ *
407
+ * This implements a lightweight JSON-RPC adapter that dispatches to the
408
+ * McpServer instance. Instead of using StreamableHTTPServerTransport
409
+ * (which requires specific Express-like middleware), we handle the
410
+ * JSON-RPC protocol directly — simpler and zero-dep.
411
+ */
412
+ async _handleMcp(req, res) {
413
+ // Only POST with JSON body
414
+ if (req.method !== 'POST') {
415
+ // GET /mcp returns server capabilities info
416
+ if (req.method === 'GET') {
417
+ jsonResponse(res, {
418
+ name: 'awareness-local',
419
+ version: '0.1.0',
420
+ protocol: 'mcp',
421
+ capabilities: {
422
+ tools: ['awareness_init', 'awareness_recall', 'awareness_record',
423
+ 'awareness_lookup', 'awareness_get_agent_prompt'],
424
+ },
425
+ });
426
+ return;
427
+ }
428
+ jsonResponse(res, { error: 'Method not allowed' }, 405);
429
+ return;
430
+ }
431
+
432
+ const body = await readBody(req);
433
+ let rpcRequest;
434
+
435
+ try {
436
+ rpcRequest = JSON.parse(body);
437
+ } catch {
438
+ jsonResponse(
439
+ res,
440
+ { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null },
441
+ 400
442
+ );
443
+ return;
444
+ }
445
+
446
+ // Handle JSON-RPC request
447
+ const rpcResponse = await this._dispatchJsonRpc(rpcRequest);
448
+ jsonResponse(res, rpcResponse);
449
+ }
450
+
451
+ /**
452
+ * Dispatch a JSON-RPC request to the appropriate handler.
453
+ * Supports the MCP protocol methods: initialize, tools/list, tools/call.
454
+ * @param {object} rpcRequest
455
+ * @returns {object} JSON-RPC response
456
+ */
457
+ async _dispatchJsonRpc(rpcRequest) {
458
+ const { method, params, id } = rpcRequest;
459
+
460
+ try {
461
+ switch (method) {
462
+ case 'initialize': {
463
+ return {
464
+ jsonrpc: '2.0',
465
+ id,
466
+ result: {
467
+ protocolVersion: '2025-03-26',
468
+ serverInfo: { name: 'awareness-local', version: '0.1.0' },
469
+ capabilities: { tools: {} },
470
+ },
471
+ };
472
+ }
473
+
474
+ case 'notifications/initialized': {
475
+ // Client acknowledgment — no response needed for notifications
476
+ return { jsonrpc: '2.0', id, result: {} };
477
+ }
478
+
479
+ case 'tools/list': {
480
+ const tools = this._getToolDefinitions();
481
+ return { jsonrpc: '2.0', id, result: { tools } };
482
+ }
483
+
484
+ case 'tools/call': {
485
+ const { name, arguments: args } = params || {};
486
+ const result = await this._callTool(name, args || {});
487
+ return { jsonrpc: '2.0', id, result };
488
+ }
489
+
490
+ default: {
491
+ return {
492
+ jsonrpc: '2.0',
493
+ id,
494
+ error: { code: -32601, message: `Method not found: ${method}` },
495
+ };
496
+ }
497
+ }
498
+ } catch (err) {
499
+ return {
500
+ jsonrpc: '2.0',
501
+ id,
502
+ error: { code: -32603, message: err.message },
503
+ };
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Return MCP tool definitions for tools/list.
509
+ * @returns {Array<object>}
510
+ */
511
+ _getToolDefinitions() {
512
+ return [
513
+ {
514
+ name: 'awareness_init',
515
+ description:
516
+ 'Start a new session and load context (knowledge cards, tasks, rules). ' +
517
+ 'Call this at the beginning of every conversation.',
518
+ inputSchema: {
519
+ type: 'object',
520
+ properties: {
521
+ memory_id: { type: 'string', description: 'Memory identifier (ignored in local mode)' },
522
+ source: { type: 'string', description: 'Client source identifier' },
523
+ days: { type: 'number', description: 'Days of history to load', default: 7 },
524
+ max_cards: { type: 'number', default: 5 },
525
+ max_tasks: { type: 'number', default: 5 },
526
+ },
527
+ },
528
+ },
529
+ {
530
+ name: 'awareness_recall',
531
+ description:
532
+ 'Search persistent memory for past decisions, solutions, and knowledge. ' +
533
+ 'Use progressive disclosure: detail=summary first, then detail=full with ids.',
534
+ inputSchema: {
535
+ type: 'object',
536
+ properties: {
537
+ semantic_query: { type: 'string', description: 'Natural language search query' },
538
+ keyword_query: { type: 'string', description: 'Exact keyword match' },
539
+ scope: { type: 'string', enum: ['all', 'timeline', 'knowledge', 'insights'], default: 'all' },
540
+ recall_mode: { type: 'string', enum: ['precise', 'session', 'structured', 'hybrid', 'auto'], default: 'hybrid' },
541
+ limit: { type: 'number', default: 10, maximum: 30 },
542
+ detail: {
543
+ type: 'string', enum: ['summary', 'full'], default: 'summary',
544
+ description: 'summary = lightweight index; full = complete content for specified ids',
545
+ },
546
+ ids: { type: 'array', items: { type: 'string' }, description: 'Item IDs to expand (with detail=full)' },
547
+ agent_role: { type: 'string' },
548
+ },
549
+ },
550
+ },
551
+ {
552
+ name: 'awareness_record',
553
+ description:
554
+ 'Record memories, update tasks, or submit insights. ' +
555
+ 'Use action=remember for single records, remember_batch for bulk.',
556
+ inputSchema: {
557
+ type: 'object',
558
+ properties: {
559
+ action: {
560
+ type: 'string',
561
+ enum: ['remember', 'remember_batch', 'update_task', 'submit_insights'],
562
+ },
563
+ content: { type: 'string', description: 'Memory content (markdown)' },
564
+ title: { type: 'string', description: 'Memory title' },
565
+ items: { type: 'array', description: 'Batch items for remember_batch' },
566
+ insights: { type: 'object', description: 'Pre-extracted knowledge cards, tasks, risks' },
567
+ session_id: { type: 'string' },
568
+ agent_role: { type: 'string' },
569
+ event_type: { type: 'string' },
570
+ tags: { type: 'array', items: { type: 'string' } },
571
+ task_id: { type: 'string' },
572
+ status: { type: 'string' },
573
+ },
574
+ required: ['action'],
575
+ },
576
+ },
577
+ {
578
+ name: 'awareness_lookup',
579
+ description:
580
+ 'Fast DB lookup — use instead of awareness_recall when you know what type of data you want.',
581
+ inputSchema: {
582
+ type: 'object',
583
+ properties: {
584
+ type: {
585
+ type: 'string',
586
+ enum: ['context', 'tasks', 'knowledge', 'risks', 'session_history', 'timeline'],
587
+ },
588
+ limit: { type: 'number', default: 10 },
589
+ status: { type: 'string' },
590
+ category: { type: 'string' },
591
+ priority: { type: 'string' },
592
+ session_id: { type: 'string' },
593
+ agent_role: { type: 'string' },
594
+ query: { type: 'string' },
595
+ },
596
+ required: ['type'],
597
+ },
598
+ },
599
+ {
600
+ name: 'awareness_get_agent_prompt',
601
+ description: 'Get the activation prompt for a specific agent role.',
602
+ inputSchema: {
603
+ type: 'object',
604
+ properties: {
605
+ role: { type: 'string', description: 'Agent role to get prompt for' },
606
+ },
607
+ },
608
+ },
609
+ ];
610
+ }
611
+
612
+ /**
613
+ * Execute a tool call by name, dispatching to the engine methods.
614
+ * This is the bridge for the JSON-RPC /mcp endpoint.
615
+ *
616
+ * @param {string} name — tool name
617
+ * @param {object} args — tool arguments
618
+ * @returns {object} MCP result envelope
619
+ */
620
+ async _callTool(name, args) {
621
+ switch (name) {
622
+ case 'awareness_init': {
623
+ const session = this._createSession(args.source);
624
+ const stats = this.indexer.getStats();
625
+ const recentCards = this.indexer.getRecentKnowledge(args.max_cards ?? 5);
626
+ const openTasks = this.indexer.getOpenTasks(args.max_tasks ?? 5);
627
+ const recentSessions = this.indexer.getRecentSessions(args.days ?? 7);
628
+ const spec = this._loadSpec();
629
+
630
+ return {
631
+ content: [{
632
+ type: 'text',
633
+ text: JSON.stringify({
634
+ session_id: session.id,
635
+ mode: 'local',
636
+ knowledge_cards: recentCards,
637
+ open_tasks: openTasks,
638
+ recent_sessions: recentSessions,
639
+ stats,
640
+ synthesized_rules: spec.core_lines?.join('\n') || '',
641
+ init_guides: spec.init_guides || {},
642
+ agent_profiles: [],
643
+ active_skills: [],
644
+ setup_hints: [],
645
+ }),
646
+ }],
647
+ };
648
+ }
649
+
650
+ case 'awareness_recall': {
651
+ // Phase 2: full content for specific IDs
652
+ if (args.detail === 'full' && args.ids?.length) {
653
+ const items = this.search
654
+ ? await this.search.getFullContent(args.ids)
655
+ : [];
656
+ return {
657
+ content: [{
658
+ type: 'text',
659
+ text: JSON.stringify({
660
+ results: items,
661
+ total: items.length,
662
+ mode: 'local',
663
+ detail: 'full',
664
+ }),
665
+ }],
666
+ };
667
+ }
668
+
669
+ // Phase 1: search + summary
670
+ if (!args.semantic_query && !args.keyword_query) {
671
+ return {
672
+ content: [{
673
+ type: 'text',
674
+ text: JSON.stringify({
675
+ results: [],
676
+ total: 0,
677
+ mode: 'local',
678
+ detail: 'summary',
679
+ }),
680
+ }],
681
+ };
682
+ }
683
+
684
+ const summaries = this.search
685
+ ? await this.search.recall(args)
686
+ : [];
687
+
688
+ return {
689
+ content: [{
690
+ type: 'text',
691
+ text: JSON.stringify({
692
+ results: summaries,
693
+ total: summaries.length,
694
+ mode: 'local',
695
+ detail: args.detail || 'summary',
696
+ search_method: 'hybrid',
697
+ }),
698
+ }],
699
+ };
700
+ }
701
+
702
+ case 'awareness_record': {
703
+ let result;
704
+ switch (args.action) {
705
+ case 'remember':
706
+ result = await this._remember(args);
707
+ break;
708
+ case 'remember_batch':
709
+ result = await this._rememberBatch(args);
710
+ break;
711
+ case 'update_task':
712
+ result = await this._updateTask(args);
713
+ break;
714
+ case 'submit_insights':
715
+ result = await this._submitInsights(args);
716
+ break;
717
+ default:
718
+ result = { error: `Unknown action: ${args.action}` };
719
+ }
720
+ return {
721
+ content: [{ type: 'text', text: JSON.stringify(result) }],
722
+ };
723
+ }
724
+
725
+ case 'awareness_lookup': {
726
+ const result = await this._lookup(args);
727
+ return {
728
+ content: [{ type: 'text', text: JSON.stringify(result) }],
729
+ };
730
+ }
731
+
732
+ case 'awareness_get_agent_prompt': {
733
+ const spec = this._loadSpec();
734
+ return {
735
+ content: [{
736
+ type: 'text',
737
+ text: JSON.stringify({
738
+ prompt: spec.init_guides?.sub_agent_guide || '',
739
+ role: args.role || '',
740
+ mode: 'local',
741
+ }),
742
+ }],
743
+ };
744
+ }
745
+
746
+ default:
747
+ throw new Error(`Unknown tool: ${name}`);
748
+ }
749
+ }
750
+
751
+ // -----------------------------------------------------------------------
752
+ // REST API
753
+ // -----------------------------------------------------------------------
754
+
755
+ /**
756
+ * Route REST API requests.
757
+ * @param {http.IncomingMessage} req
758
+ * @param {http.ServerResponse} res
759
+ * @param {URL} url
760
+ */
761
+ async _handleApi(req, res, url) {
762
+ const route = url.pathname.replace('/api/v1', '');
763
+
764
+ // GET /api/v1/stats
765
+ if (route === '/stats' && req.method === 'GET') {
766
+ const stats = this.indexer ? this.indexer.getStats() : {};
767
+ return jsonResponse(res, stats);
768
+ }
769
+
770
+ // GET /api/v1/memories
771
+ if (route === '/memories' && req.method === 'GET') {
772
+ return this._apiListMemories(req, res, url);
773
+ }
774
+
775
+ // GET /api/v1/memories/search?q=query
776
+ if (route === '/memories/search' && req.method === 'GET') {
777
+ return this._apiSearchMemories(req, res, url);
778
+ }
779
+
780
+ // GET /api/v1/knowledge
781
+ if (route === '/knowledge' && req.method === 'GET') {
782
+ return this._apiListKnowledge(req, res, url);
783
+ }
784
+
785
+ // GET /api/v1/tasks
786
+ if (route === '/tasks' && req.method === 'GET') {
787
+ return this._apiListTasks(req, res, url);
788
+ }
789
+
790
+ // PUT /api/v1/tasks/:id
791
+ if (route.startsWith('/tasks/') && req.method === 'PUT') {
792
+ const taskId = decodeURIComponent(route.replace('/tasks/', ''));
793
+ return await this._apiUpdateTask(req, res, taskId);
794
+ }
795
+
796
+ // GET /api/v1/sync/status
797
+ if (route === '/sync/status' && req.method === 'GET') {
798
+ return this._apiSyncStatus(req, res);
799
+ }
800
+
801
+ // GET /api/v1/config
802
+ if (route === '/config' && req.method === 'GET') {
803
+ return this._apiGetConfig(req, res);
804
+ }
805
+
806
+ // PUT /api/v1/config
807
+ if (route === '/config' && req.method === 'PUT') {
808
+ return await this._apiUpdateConfig(req, res);
809
+ }
810
+
811
+ // POST /api/v1/cloud/auth/start — initiate device-auth
812
+ if (route === '/cloud/auth/start' && req.method === 'POST') {
813
+ return await this._apiCloudAuthStart(req, res);
814
+ }
815
+
816
+ // POST /api/v1/cloud/auth/poll — poll device-auth result
817
+ if (route === '/cloud/auth/poll' && req.method === 'POST') {
818
+ return await this._apiCloudAuthPoll(req, res);
819
+ }
820
+
821
+ // GET /api/v1/cloud/memories — list memories (after auth)
822
+ if (route.startsWith('/cloud/memories') && req.method === 'GET') {
823
+ return await this._apiCloudListMemories(req, res, url);
824
+ }
825
+
826
+ // POST /api/v1/cloud/connect — save cloud config
827
+ if (route === '/cloud/connect' && req.method === 'POST') {
828
+ return await this._apiCloudConnect(req, res);
829
+ }
830
+
831
+ // POST /api/v1/cloud/disconnect
832
+ if (route === '/cloud/disconnect' && req.method === 'POST') {
833
+ return await this._apiCloudDisconnect(req, res);
834
+ }
835
+
836
+ // 404
837
+ jsonResponse(res, { error: 'Not found', route }, 404);
838
+ }
839
+
840
+ // -----------------------------------------------------------------------
841
+ // REST API handlers
842
+ // -----------------------------------------------------------------------
843
+
844
+ /**
845
+ * GET /api/v1/memories?limit=50&offset=0
846
+ * Lists memories from SQLite index with FTS content.
847
+ */
848
+ _apiListMemories(_req, res, url) {
849
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
850
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
851
+
852
+ if (!this.indexer) {
853
+ return jsonResponse(res, { items: [], total: 0 });
854
+ }
855
+
856
+ const rows = this.indexer.db
857
+ .prepare(
858
+ `SELECT m.*, f.content AS fts_content
859
+ FROM memories m
860
+ LEFT JOIN memories_fts f ON f.id = m.id
861
+ WHERE m.status = 'active'
862
+ ORDER BY m.created_at DESC
863
+ LIMIT ? OFFSET ?`
864
+ )
865
+ .all(limit, offset);
866
+
867
+ const total = this.indexer.db
868
+ .prepare(`SELECT COUNT(*) AS c FROM memories WHERE status = 'active'`)
869
+ .get().c;
870
+
871
+ return jsonResponse(res, { items: rows, total, limit, offset });
872
+ }
873
+
874
+ /**
875
+ * GET /api/v1/memories/search?q=query&limit=20
876
+ * Full-text search over memories via FTS5.
877
+ */
878
+ _apiSearchMemories(_req, res, url) {
879
+ const q = url.searchParams.get('q') || '';
880
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
881
+
882
+ if (!q || !this.indexer) {
883
+ return jsonResponse(res, { items: [], total: 0, query: q });
884
+ }
885
+
886
+ const results = this.indexer.search(q, { limit });
887
+ return jsonResponse(res, { items: results, total: results.length, query: q });
888
+ }
889
+
890
+ /**
891
+ * GET /api/v1/knowledge?category=decision&limit=100
892
+ * Lists knowledge cards, optionally filtered by category.
893
+ */
894
+ _apiListKnowledge(_req, res, url) {
895
+ const category = url.searchParams.get('category') || null;
896
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10);
897
+
898
+ if (!this.indexer) {
899
+ return jsonResponse(res, { items: [], total: 0 });
900
+ }
901
+
902
+ let sql = `SELECT * FROM knowledge_cards WHERE status = 'active'`;
903
+ const params = [];
904
+
905
+ if (category) {
906
+ sql += ` AND category = ?`;
907
+ params.push(category);
908
+ }
909
+
910
+ sql += ` ORDER BY created_at DESC LIMIT ?`;
911
+ params.push(limit);
912
+
913
+ const rows = this.indexer.db.prepare(sql).all(...params);
914
+ return jsonResponse(res, { items: rows, total: rows.length });
915
+ }
916
+
917
+ /**
918
+ * GET /api/v1/tasks?status=open
919
+ * Lists tasks sorted by priority then date.
920
+ */
921
+ _apiListTasks(_req, res, url) {
922
+ const status = url.searchParams.get('status') || null;
923
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10);
924
+
925
+ if (!this.indexer) {
926
+ return jsonResponse(res, { items: [], total: 0 });
927
+ }
928
+
929
+ let sql = `SELECT * FROM tasks`;
930
+ const conditions = [];
931
+ const params = [];
932
+
933
+ if (status) {
934
+ conditions.push('status = ?');
935
+ params.push(status);
936
+ }
937
+
938
+ if (conditions.length) {
939
+ sql += ' WHERE ' + conditions.join(' AND ');
940
+ }
941
+
942
+ sql += ` ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, created_at DESC LIMIT ?`;
943
+ params.push(limit);
944
+
945
+ const rows = this.indexer.db.prepare(sql).all(...params);
946
+ return jsonResponse(res, { items: rows, total: rows.length });
947
+ }
948
+
949
+ /**
950
+ * PUT /api/v1/tasks/:id — update task status/priority.
951
+ */
952
+ async _apiUpdateTask(req, res, taskId) {
953
+ const body = await readBody(req);
954
+ let payload;
955
+ try {
956
+ payload = JSON.parse(body);
957
+ } catch {
958
+ return jsonResponse(res, { error: 'Invalid JSON' }, 400);
959
+ }
960
+
961
+ if (!this.indexer) {
962
+ return jsonResponse(res, { error: 'Indexer not available' }, 503);
963
+ }
964
+
965
+ const task = this.indexer.db
966
+ .prepare('SELECT * FROM tasks WHERE id = ?')
967
+ .get(taskId);
968
+
969
+ if (!task) {
970
+ return jsonResponse(res, { error: 'Task not found' }, 404);
971
+ }
972
+
973
+ const newStatus = payload.status || task.status;
974
+ const newPriority = payload.priority || task.priority;
975
+
976
+ this.indexer.indexTask({
977
+ ...task,
978
+ status: newStatus,
979
+ priority: newPriority,
980
+ updated_at: nowISO(),
981
+ });
982
+
983
+ return jsonResponse(res, {
984
+ status: 'ok',
985
+ task_id: taskId,
986
+ new_status: newStatus,
987
+ });
988
+ }
989
+
990
+ /**
991
+ * GET /api/v1/sync/status — cloud sync status from config.
992
+ */
993
+ _apiSyncStatus(_req, res) {
994
+ const config = this._loadConfig();
995
+ const cloud = config.cloud || {};
996
+
997
+ return jsonResponse(res, {
998
+ cloud_enabled: !!cloud.enabled,
999
+ api_base: cloud.api_base || null,
1000
+ memory_id: cloud.memory_id || null,
1001
+ auto_sync: cloud.auto_sync ?? true,
1002
+ last_push_at: cloud.last_push_at || null,
1003
+ last_pull_at: cloud.last_pull_at || null,
1004
+ });
1005
+ }
1006
+
1007
+ /**
1008
+ * GET /api/v1/config — return config with redacted API key.
1009
+ */
1010
+ _apiGetConfig(_req, res) {
1011
+ const config = this._loadConfig();
1012
+ // Redact API key for security
1013
+ if (config.cloud && config.cloud.api_key) {
1014
+ const key = config.cloud.api_key;
1015
+ config.cloud.api_key = key.length > 8
1016
+ ? key.slice(0, 4) + '...' + key.slice(-4)
1017
+ : '****';
1018
+ }
1019
+ return jsonResponse(res, config);
1020
+ }
1021
+
1022
+ /**
1023
+ * PUT /api/v1/config — partial config update (deep merge).
1024
+ */
1025
+ async _apiUpdateConfig(req, res) {
1026
+ const body = await readBody(req);
1027
+ let patch;
1028
+ try {
1029
+ patch = JSON.parse(body);
1030
+ } catch {
1031
+ return jsonResponse(res, { error: 'Invalid JSON' }, 400);
1032
+ }
1033
+
1034
+ const configPath = path.join(this.awarenessDir, 'config.json');
1035
+ const config = this._loadConfig();
1036
+
1037
+ // Deep merge patch into config (only known sections)
1038
+ const allowedSections = ['daemon', 'embedding', 'cloud', 'git_sync', 'agent', 'extraction'];
1039
+ for (const section of allowedSections) {
1040
+ if (patch[section] && typeof patch[section] === 'object') {
1041
+ config[section] = { ...(config[section] || {}), ...patch[section] };
1042
+ }
1043
+ }
1044
+
1045
+ try {
1046
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1047
+ } catch (err) {
1048
+ return jsonResponse(res, { error: 'Failed to save config: ' + err.message }, 500);
1049
+ }
1050
+
1051
+ // Redact API key in response
1052
+ if (config.cloud && config.cloud.api_key) {
1053
+ const key = config.cloud.api_key;
1054
+ config.cloud.api_key = key.length > 8
1055
+ ? key.slice(0, 4) + '...' + key.slice(-4)
1056
+ : '****';
1057
+ }
1058
+
1059
+ return jsonResponse(res, { status: 'ok', config });
1060
+ }
1061
+
1062
+ // -----------------------------------------------------------------------
1063
+ // Cloud Auth API (device-auth flow from Dashboard)
1064
+ // -----------------------------------------------------------------------
1065
+
1066
+ async _apiCloudAuthStart(_req, res) {
1067
+ const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1068
+ try {
1069
+ const data = await this._httpJson('POST', `${apiBase}/auth/device/init`, {});
1070
+ return jsonResponse(res, data);
1071
+ } catch (err) {
1072
+ return jsonResponse(res, { error: 'Failed to start auth: ' + err.message }, 502);
1073
+ }
1074
+ }
1075
+
1076
+ async _apiCloudAuthPoll(req, res) {
1077
+ const body = await readBody(req);
1078
+ let params;
1079
+ try { params = JSON.parse(body); } catch { return jsonResponse(res, { error: 'Invalid JSON' }, 400); }
1080
+
1081
+ const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1082
+ const interval = (params.interval || 5) * 1000;
1083
+ const maxPolls = 60; // 5 minutes max
1084
+
1085
+ for (let i = 0; i < maxPolls; i++) {
1086
+ try {
1087
+ const data = await this._httpJson('POST', `${apiBase}/auth/device/poll`, {
1088
+ device_code: params.device_code,
1089
+ });
1090
+ if (data.status === 'approved' && data.api_key) {
1091
+ return jsonResponse(res, { api_key: data.api_key });
1092
+ }
1093
+ if (data.status === 'expired') {
1094
+ return jsonResponse(res, { error: 'Auth expired' }, 410);
1095
+ }
1096
+ } catch { /* continue polling */ }
1097
+ await new Promise(r => setTimeout(r, interval));
1098
+ }
1099
+ return jsonResponse(res, { error: 'Auth timeout' }, 408);
1100
+ }
1101
+
1102
+ async _apiCloudListMemories(req, res, url) {
1103
+ const apiKey = url.searchParams.get('api_key');
1104
+ if (!apiKey) return jsonResponse(res, { error: 'api_key required' }, 400);
1105
+
1106
+ const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1107
+ try {
1108
+ const data = await this._httpJson('GET', `${apiBase}/memories`, null, {
1109
+ 'Authorization': `Bearer ${apiKey}`,
1110
+ });
1111
+ return jsonResponse(res, data);
1112
+ } catch (err) {
1113
+ return jsonResponse(res, { error: 'Failed to list memories: ' + err.message }, 502);
1114
+ }
1115
+ }
1116
+
1117
+ async _apiCloudConnect(req, res) {
1118
+ const body = await readBody(req);
1119
+ let params;
1120
+ try { params = JSON.parse(body); } catch { return jsonResponse(res, { error: 'Invalid JSON' }, 400); }
1121
+
1122
+ const { api_key, memory_id } = params;
1123
+ if (!api_key) return jsonResponse(res, { error: 'api_key required' }, 400);
1124
+
1125
+ // Save cloud config
1126
+ const configPath = path.join(this.awarenessDir, 'config.json');
1127
+ const config = this._loadConfig();
1128
+ config.cloud = {
1129
+ ...config.cloud,
1130
+ enabled: true,
1131
+ api_key,
1132
+ memory_id: memory_id || '',
1133
+ auto_sync: true,
1134
+ };
1135
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1136
+ this.config = config;
1137
+
1138
+ // Start cloud sync if not already running
1139
+ if (this.cloudSync) {
1140
+ this.cloudSync.stop();
1141
+ }
1142
+ try {
1143
+ const { CloudSync } = await import('./core/cloud-sync.mjs');
1144
+ this.cloudSync = new CloudSync(config, this.indexer, this.memoryStore);
1145
+ this.cloudSync.start().catch(err => {
1146
+ console.warn('[awareness-local] cloud sync start failed:', err.message);
1147
+ });
1148
+ } catch { /* CloudSync not available */ }
1149
+
1150
+ return jsonResponse(res, { status: 'ok', cloud_enabled: true });
1151
+ }
1152
+
1153
+ async _apiCloudDisconnect(_req, res) {
1154
+ const configPath = path.join(this.awarenessDir, 'config.json');
1155
+ const config = this._loadConfig();
1156
+ config.cloud = { ...config.cloud, enabled: false, api_key: '', memory_id: '' };
1157
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1158
+ this.config = config;
1159
+
1160
+ if (this.cloudSync) {
1161
+ this.cloudSync.stop();
1162
+ this.cloudSync = null;
1163
+ }
1164
+
1165
+ return jsonResponse(res, { status: 'ok', cloud_enabled: false });
1166
+ }
1167
+
1168
+ /** Simple HTTP JSON request helper for cloud API calls. */
1169
+ async _httpJson(method, urlStr, body = null, extraHeaders = {}) {
1170
+ const parsedUrl = new URL(urlStr);
1171
+ const isHttps = parsedUrl.protocol === 'https:';
1172
+ const httpMod = isHttps ? (await import('https')).default : (await import('http')).default;
1173
+
1174
+ return new Promise((resolve, reject) => {
1175
+ const options = {
1176
+ hostname: parsedUrl.hostname,
1177
+ port: parsedUrl.port || (isHttps ? 443 : 80),
1178
+ path: parsedUrl.pathname + parsedUrl.search,
1179
+ method,
1180
+ headers: {
1181
+ 'Content-Type': 'application/json',
1182
+ ...extraHeaders,
1183
+ },
1184
+ };
1185
+
1186
+ const req = httpMod.request(options, (res) => {
1187
+ let data = '';
1188
+ res.on('data', chunk => { data += chunk; });
1189
+ res.on('end', () => {
1190
+ try { resolve(JSON.parse(data)); }
1191
+ catch { resolve(data); }
1192
+ });
1193
+ });
1194
+
1195
+ req.on('error', reject);
1196
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('Timeout')); });
1197
+
1198
+ if (body !== null) {
1199
+ req.write(typeof body === 'string' ? body : JSON.stringify(body));
1200
+ }
1201
+ req.end();
1202
+ });
1203
+ }
1204
+
1205
+ // -----------------------------------------------------------------------
1206
+ // Web UI
1207
+ // -----------------------------------------------------------------------
1208
+
1209
+ /**
1210
+ * Serve the web dashboard SPA from web/index.html.
1211
+ */
1212
+ _handleWebUI(_req, res) {
1213
+ try {
1214
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
1215
+ const htmlPath = path.join(thisDir, 'web', 'index.html');
1216
+ if (fs.existsSync(htmlPath)) {
1217
+ const html = fs.readFileSync(htmlPath, 'utf-8');
1218
+ res.writeHead(200, {
1219
+ 'Content-Type': 'text/html; charset=utf-8',
1220
+ 'Cache-Control': 'no-cache',
1221
+ });
1222
+ res.end(html);
1223
+ return;
1224
+ }
1225
+ } catch (err) {
1226
+ console.error('[awareness-local] failed to load web UI:', err.message);
1227
+ }
1228
+
1229
+ // Fallback if index.html not found
1230
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1231
+ res.end(`<!DOCTYPE html>
1232
+ <html lang="en">
1233
+ <head><meta charset="utf-8"><title>Awareness Local</title></head>
1234
+ <body style="font-family:system-ui;max-width:600px;margin:80px auto;color:#333">
1235
+ <h1>Awareness Local</h1>
1236
+ <p>Daemon is running. Web dashboard file not found.</p>
1237
+ <p><a href="/healthz">/healthz</a> &middot; <a href="/api/v1/stats">/api/v1/stats</a></p>
1238
+ </body>
1239
+ </html>`);
1240
+ }
1241
+
1242
+ // -----------------------------------------------------------------------
1243
+ // Engine methods (called by MCP tools)
1244
+ // -----------------------------------------------------------------------
1245
+
1246
+ /** Create a new session and return session metadata. */
1247
+ _createSession(source) {
1248
+ return this.indexer.createSession(source || 'local');
1249
+ }
1250
+
1251
+ /** Write a single memory, index it, and trigger knowledge extraction. */
1252
+ async _remember(params) {
1253
+ if (!params.content) {
1254
+ return { error: 'content is required for remember action' };
1255
+ }
1256
+
1257
+ // Auto-generate title from content if not provided
1258
+ let title = params.title || '';
1259
+ if (!title && params.content) {
1260
+ // Take first sentence or first 80 chars, whichever is shorter
1261
+ const firstLine = params.content.split(/[.\n!?。!?]/)[0].trim();
1262
+ title = firstLine.length > 80 ? firstLine.substring(0, 77) + '...' : firstLine;
1263
+ }
1264
+
1265
+ const memory = {
1266
+ type: params.event_type || 'turn_summary',
1267
+ content: params.content,
1268
+ title,
1269
+ tags: params.tags || [],
1270
+ agent_role: params.agent_role || 'builder_agent',
1271
+ session_id: params.session_id || '',
1272
+ source: 'mcp',
1273
+ };
1274
+
1275
+ // Write markdown file
1276
+ const { id, filepath } = await this.memoryStore.write(memory);
1277
+
1278
+ // Index in SQLite
1279
+ this.indexer.indexMemory(id, { ...memory, filepath }, params.content);
1280
+
1281
+ // Knowledge extraction (fire-and-forget)
1282
+ this._extractAndIndex(id, params.content, memory, params.insights);
1283
+
1284
+ // Cloud sync (fire-and-forget — don't block the response)
1285
+ if (this.cloudSync?.isEnabled()) {
1286
+ this.cloudSync.syncToCloud().catch((err) => {
1287
+ console.warn('[awareness-local] cloud sync after remember failed:', err.message);
1288
+ });
1289
+ }
1290
+
1291
+ return {
1292
+ status: 'ok',
1293
+ id,
1294
+ filepath,
1295
+ mode: 'local',
1296
+ };
1297
+ }
1298
+
1299
+ /** Write multiple memories in batch. */
1300
+ async _rememberBatch(params) {
1301
+ const items = params.items || [];
1302
+ if (!items.length) {
1303
+ return { error: 'items array is required for remember_batch' };
1304
+ }
1305
+
1306
+ const results = [];
1307
+ for (const item of items) {
1308
+ const result = await this._remember({
1309
+ content: item.content,
1310
+ title: item.title,
1311
+ event_type: item.event_type,
1312
+ tags: item.tags,
1313
+ insights: item.insights,
1314
+ session_id: params.session_id,
1315
+ agent_role: params.agent_role,
1316
+ });
1317
+ results.push(result);
1318
+ }
1319
+
1320
+ return {
1321
+ status: 'ok',
1322
+ count: results.length,
1323
+ items: results,
1324
+ mode: 'local',
1325
+ };
1326
+ }
1327
+
1328
+ /** Update a task's status. */
1329
+ async _updateTask(params) {
1330
+ if (!params.task_id) {
1331
+ return { error: 'task_id is required for update_task' };
1332
+ }
1333
+
1334
+ const task = this.indexer.db
1335
+ .prepare('SELECT * FROM tasks WHERE id = ?')
1336
+ .get(params.task_id);
1337
+
1338
+ if (!task) {
1339
+ return { error: `Task not found: ${params.task_id}` };
1340
+ }
1341
+
1342
+ this.indexer.indexTask({
1343
+ ...task,
1344
+ status: params.status || task.status,
1345
+ updated_at: nowISO(),
1346
+ });
1347
+
1348
+ return {
1349
+ status: 'ok',
1350
+ task_id: params.task_id,
1351
+ new_status: params.status || task.status,
1352
+ mode: 'local',
1353
+ };
1354
+ }
1355
+
1356
+ /** Process pre-extracted insights and index them. */
1357
+ async _submitInsights(params) {
1358
+ const insights = params.insights || {};
1359
+ let cardsCreated = 0;
1360
+ let tasksCreated = 0;
1361
+
1362
+ // Process knowledge cards
1363
+ if (Array.isArray(insights.knowledge_cards)) {
1364
+ for (const card of insights.knowledge_cards) {
1365
+ const cardId = `kc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
1366
+ const cardFilepath = path.join(
1367
+ this.awarenessDir,
1368
+ 'knowledge',
1369
+ card.category || 'insights',
1370
+ `${cardId}.md`
1371
+ );
1372
+
1373
+ // Ensure category directory exists
1374
+ fs.mkdirSync(path.dirname(cardFilepath), { recursive: true });
1375
+
1376
+ // Write markdown file for the card
1377
+ const cardContent = `---
1378
+ id: ${cardId}
1379
+ category: ${card.category || 'insight'}
1380
+ title: "${(card.title || '').replace(/"/g, '\\"')}"
1381
+ confidence: ${card.confidence ?? 0.8}
1382
+ status: ${card.status || 'active'}
1383
+ tags: ${JSON.stringify(card.tags || [])}
1384
+ created_at: ${nowISO()}
1385
+ ---
1386
+
1387
+ ${card.summary || card.title || ''}
1388
+ `;
1389
+ fs.mkdirSync(path.dirname(cardFilepath), { recursive: true });
1390
+ fs.writeFileSync(cardFilepath, cardContent, 'utf-8');
1391
+
1392
+ this.indexer.indexKnowledgeCard({
1393
+ id: cardId,
1394
+ category: card.category || 'insight',
1395
+ title: card.title || '',
1396
+ summary: card.summary || '',
1397
+ source_memories: JSON.stringify([]),
1398
+ confidence: card.confidence ?? 0.8,
1399
+ status: card.status || 'active',
1400
+ tags: card.tags || [],
1401
+ created_at: nowISO(),
1402
+ filepath: cardFilepath,
1403
+ content: card.summary || card.title || '',
1404
+ });
1405
+
1406
+ cardsCreated++;
1407
+ }
1408
+ }
1409
+
1410
+ // Process action items / tasks
1411
+ if (Array.isArray(insights.action_items)) {
1412
+ for (const item of insights.action_items) {
1413
+ const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
1414
+ const taskFilepath = path.join(
1415
+ this.awarenessDir, 'tasks', 'open', `${taskId}.md`
1416
+ );
1417
+
1418
+ const taskContent = `---
1419
+ id: ${taskId}
1420
+ title: "${(item.title || '').replace(/"/g, '\\"')}"
1421
+ priority: ${item.priority || 'medium'}
1422
+ status: ${item.status || 'open'}
1423
+ created_at: ${nowISO()}
1424
+ ---
1425
+
1426
+ ${item.description || item.title || ''}
1427
+ `;
1428
+ fs.mkdirSync(path.dirname(taskFilepath), { recursive: true });
1429
+ fs.writeFileSync(taskFilepath, taskContent, 'utf-8');
1430
+
1431
+ this.indexer.indexTask({
1432
+ id: taskId,
1433
+ title: item.title || '',
1434
+ description: item.description || '',
1435
+ status: item.status || 'open',
1436
+ priority: item.priority || 'medium',
1437
+ agent_role: params.agent_role || null,
1438
+ created_at: nowISO(),
1439
+ updated_at: nowISO(),
1440
+ filepath: taskFilepath,
1441
+ });
1442
+
1443
+ tasksCreated++;
1444
+ }
1445
+ }
1446
+
1447
+ return {
1448
+ status: 'ok',
1449
+ cards_created: cardsCreated,
1450
+ tasks_created: tasksCreated,
1451
+ mode: 'local',
1452
+ };
1453
+ }
1454
+
1455
+ /** Handle structured data lookups. */
1456
+ async _lookup(params) {
1457
+ const { type, limit = 10, status, category, priority, session_id, agent_role, query } = params;
1458
+
1459
+ switch (type) {
1460
+ case 'context': {
1461
+ // Full context dump
1462
+ const stats = this.indexer.getStats();
1463
+ const knowledge = this.indexer.getRecentKnowledge(limit);
1464
+ const tasks = this.indexer.getOpenTasks(limit);
1465
+ const sessions = this.indexer.getRecentSessions(7);
1466
+ return { stats, knowledge_cards: knowledge, open_tasks: tasks, recent_sessions: sessions, mode: 'local' };
1467
+ }
1468
+
1469
+ case 'tasks': {
1470
+ let sql = 'SELECT * FROM tasks';
1471
+ const conditions = [];
1472
+ const sqlParams = [];
1473
+
1474
+ if (status) {
1475
+ conditions.push('status = ?');
1476
+ sqlParams.push(status);
1477
+ } else {
1478
+ conditions.push("status = 'open'");
1479
+ }
1480
+ if (priority) {
1481
+ conditions.push('priority = ?');
1482
+ sqlParams.push(priority);
1483
+ }
1484
+ if (agent_role) {
1485
+ conditions.push('agent_role = ?');
1486
+ sqlParams.push(agent_role);
1487
+ }
1488
+
1489
+ if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
1490
+ sql += ' ORDER BY created_at DESC LIMIT ?';
1491
+ sqlParams.push(limit);
1492
+
1493
+ const tasks = this.indexer.db.prepare(sql).all(...sqlParams);
1494
+ return { tasks, total: tasks.length, mode: 'local' };
1495
+ }
1496
+
1497
+ case 'knowledge': {
1498
+ let sql = 'SELECT * FROM knowledge_cards';
1499
+ const conditions = [];
1500
+ const sqlParams = [];
1501
+
1502
+ if (status) {
1503
+ conditions.push('status = ?');
1504
+ sqlParams.push(status);
1505
+ } else {
1506
+ conditions.push("status = 'active'");
1507
+ }
1508
+ if (category) {
1509
+ conditions.push('category = ?');
1510
+ sqlParams.push(category);
1511
+ }
1512
+
1513
+ if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
1514
+ sql += ' ORDER BY created_at DESC LIMIT ?';
1515
+ sqlParams.push(limit);
1516
+
1517
+ const cards = this.indexer.db.prepare(sql).all(...sqlParams);
1518
+ return { knowledge_cards: cards, total: cards.length, mode: 'local' };
1519
+ }
1520
+
1521
+ case 'risks': {
1522
+ // Risks are stored as knowledge_cards with category containing 'risk' or 'pitfall'
1523
+ let sql = "SELECT * FROM knowledge_cards WHERE (category = 'pitfall' OR category = 'risk')";
1524
+ const sqlParams = [];
1525
+
1526
+ if (status) {
1527
+ sql += ' AND status = ?';
1528
+ sqlParams.push(status);
1529
+ } else {
1530
+ sql += " AND status = 'active'";
1531
+ }
1532
+
1533
+ sql += ' ORDER BY created_at DESC LIMIT ?';
1534
+ sqlParams.push(limit);
1535
+
1536
+ const risks = this.indexer.db.prepare(sql).all(...sqlParams);
1537
+ return { risks, total: risks.length, mode: 'local' };
1538
+ }
1539
+
1540
+ case 'session_history': {
1541
+ let sql = 'SELECT * FROM sessions';
1542
+ const conditions = [];
1543
+ const sqlParams = [];
1544
+
1545
+ if (session_id) {
1546
+ conditions.push('id = ?');
1547
+ sqlParams.push(session_id);
1548
+ }
1549
+
1550
+ if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
1551
+ sql += ' ORDER BY started_at DESC LIMIT ?';
1552
+ sqlParams.push(limit);
1553
+
1554
+ const sessions = this.indexer.db.prepare(sql).all(...sqlParams);
1555
+ return { sessions, total: sessions.length, mode: 'local' };
1556
+ }
1557
+
1558
+ case 'timeline': {
1559
+ // Timeline = recent memories ordered by time
1560
+ const memories = this.indexer.db
1561
+ .prepare(
1562
+ "SELECT * FROM memories WHERE status = 'active' ORDER BY created_at DESC LIMIT ?"
1563
+ )
1564
+ .all(limit);
1565
+ return { events: memories, total: memories.length, mode: 'local' };
1566
+ }
1567
+
1568
+ default:
1569
+ return { error: `Unknown lookup type: ${type}`, mode: 'local' };
1570
+ }
1571
+ }
1572
+
1573
+ // -----------------------------------------------------------------------
1574
+ // Knowledge extraction
1575
+ // -----------------------------------------------------------------------
1576
+
1577
+ /**
1578
+ * Extract knowledge from a newly recorded memory and index the results.
1579
+ * Fire-and-forget — errors are logged but don't fail the record.
1580
+ */
1581
+ async _extractAndIndex(memoryId, content, metadata, preExtractedInsights) {
1582
+ try {
1583
+ if (!this.extractor) return;
1584
+
1585
+ // extractor.extract() internally calls _persistAll() which:
1586
+ // - Saves knowledge cards to .awareness/knowledge/*.md + indexes them
1587
+ // - Saves tasks to .awareness/tasks/*.md + indexes them
1588
+ // - Saves risks as knowledge cards with category 'risk'
1589
+ // So we just call extract() — no need to manually persist again.
1590
+ await this.extractor.extract(content, metadata, preExtractedInsights);
1591
+ } catch (err) {
1592
+ console.error('[awareness-local] extraction error:', err.message);
1593
+ }
1594
+ }
1595
+
1596
+ // -----------------------------------------------------------------------
1597
+ // File watcher
1598
+ // -----------------------------------------------------------------------
1599
+
1600
+ /** Start watching .awareness/memories/ for changes (debounced reindex). */
1601
+ _startFileWatcher() {
1602
+ const memoriesDir = path.join(this.awarenessDir, 'memories');
1603
+ if (!fs.existsSync(memoriesDir)) return;
1604
+
1605
+ try {
1606
+ this.watcher = fs.watch(memoriesDir, { recursive: true }, () => {
1607
+ // Debounce: wait for writes to settle before reindexing
1608
+ if (this._reindexTimer) clearTimeout(this._reindexTimer);
1609
+ this._reindexTimer = setTimeout(async () => {
1610
+ try {
1611
+ if (this.indexer && this.memoryStore) {
1612
+ const result = await this.indexer.incrementalIndex(this.memoryStore);
1613
+ if (result.indexed > 0) {
1614
+ console.log(
1615
+ `[awareness-local] auto-indexed ${result.indexed} changed files`
1616
+ );
1617
+ }
1618
+ }
1619
+ } catch (err) {
1620
+ console.error('[awareness-local] auto-reindex error:', err.message);
1621
+ }
1622
+ }, this._reindexDebounceMs);
1623
+ });
1624
+ } catch (err) {
1625
+ console.error('[awareness-local] fs.watch setup failed:', err.message);
1626
+ }
1627
+ }
1628
+
1629
+ // -----------------------------------------------------------------------
1630
+ // Config & spec loading
1631
+ // -----------------------------------------------------------------------
1632
+
1633
+ /** Load .awareness/config.json (or return defaults). */
1634
+ _loadConfig() {
1635
+ try {
1636
+ const configPath = path.join(this.awarenessDir, 'config.json');
1637
+ if (fs.existsSync(configPath)) {
1638
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1639
+ }
1640
+ } catch {
1641
+ // ignore
1642
+ }
1643
+ return { daemon: { port: this.port } };
1644
+ }
1645
+
1646
+ /** Load awareness-spec.json from the bundled spec directory. */
1647
+ _loadSpec() {
1648
+ try {
1649
+ // Resolve relative to this file's directory
1650
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
1651
+ const specPath = path.join(thisDir, 'spec', 'awareness-spec.json');
1652
+ if (fs.existsSync(specPath)) {
1653
+ return JSON.parse(fs.readFileSync(specPath, 'utf-8'));
1654
+ }
1655
+ } catch {
1656
+ // ignore
1657
+ }
1658
+ return { core_lines: [], init_guides: {} };
1659
+ }
1660
+
1661
+ // -----------------------------------------------------------------------
1662
+ // Dynamic module loading
1663
+ // -----------------------------------------------------------------------
1664
+
1665
+ /** Try to load SearchEngine from Phase 1 core. Returns null if not available. */
1666
+ async _loadSearchEngine() {
1667
+ try {
1668
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
1669
+ const modPath = path.join(thisDir, 'core', 'search.mjs');
1670
+ if (fs.existsSync(modPath)) {
1671
+ const mod = await import(modPath);
1672
+ const SearchEngine = mod.SearchEngine || mod.default;
1673
+ if (SearchEngine) {
1674
+ return new SearchEngine(this.indexer, this.memoryStore);
1675
+ }
1676
+ }
1677
+ } catch (err) {
1678
+ console.warn('[awareness-local] SearchEngine not available:', err.message);
1679
+ }
1680
+ return null;
1681
+ }
1682
+
1683
+ /** Try to load KnowledgeExtractor from Phase 1 core. Returns null if not available. */
1684
+ async _loadKnowledgeExtractor() {
1685
+ try {
1686
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
1687
+ const modPath = path.join(thisDir, 'core', 'knowledge-extractor.mjs');
1688
+ if (fs.existsSync(modPath)) {
1689
+ const mod = await import(modPath);
1690
+ const KnowledgeExtractor = mod.KnowledgeExtractor || mod.default;
1691
+ if (KnowledgeExtractor) {
1692
+ return new KnowledgeExtractor(this.memoryStore, this.indexer);
1693
+ }
1694
+ }
1695
+ } catch (err) {
1696
+ console.warn(
1697
+ '[awareness-local] KnowledgeExtractor not available:',
1698
+ err.message
1699
+ );
1700
+ }
1701
+ return null;
1702
+ }
1703
+
1704
+ // -----------------------------------------------------------------------
1705
+ // Utility
1706
+ // -----------------------------------------------------------------------
1707
+
1708
+ /** Remove stale PID file. */
1709
+ _cleanPidFile() {
1710
+ try {
1711
+ if (fs.existsSync(this.pidFile)) {
1712
+ fs.unlinkSync(this.pidFile);
1713
+ }
1714
+ } catch {
1715
+ // ignore
1716
+ }
1717
+ }
1718
+ }
1719
+
1720
+ export default AwarenessLocalDaemon;