@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/bin/awareness-local.mjs +489 -0
- package/package.json +31 -0
- package/src/api.mjs +122 -0
- package/src/core/cloud-sync.mjs +970 -0
- package/src/core/config.mjs +303 -0
- package/src/core/embedder.mjs +239 -0
- package/src/core/index.mjs +34 -0
- package/src/core/indexer.mjs +726 -0
- package/src/core/knowledge-extractor.mjs +629 -0
- package/src/core/memory-store.mjs +665 -0
- package/src/core/search.mjs +633 -0
- package/src/daemon.mjs +1720 -0
- package/src/mcp-server.mjs +335 -0
- package/src/spec/awareness-spec.json +393 -0
- package/src/web/index.html +1015 -0
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> · <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;
|