@awareness-sdk/local 0.1.13 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -433,6 +433,17 @@ async function cmdReindex(flags) {
433
433
  }
434
434
  }
435
435
 
436
+ /**
437
+ * Run as a stdio MCP server (for IDE integrations like Claude Code).
438
+ */
439
+ async function cmdMcp(flags) {
440
+ const projectDir = resolveProjectDir(flags);
441
+ const port = resolvePort(flags);
442
+
443
+ const { startStdioMcp } = await import('../src/mcp-stdio.mjs');
444
+ await startStdioMcp({ port, projectDir });
445
+ }
446
+
436
447
  // ---------------------------------------------------------------------------
437
448
  // Help
438
449
  // ---------------------------------------------------------------------------
@@ -449,6 +460,7 @@ Commands:
449
460
  stop Stop the daemon
450
461
  status Show daemon status and stats
451
462
  reindex Rebuild the search index
463
+ mcp Run as stdio MCP server
452
464
 
453
465
  Options:
454
466
  --project <dir> Project directory (default: current directory)
@@ -461,6 +473,7 @@ Examples:
461
473
  npx @awareness-sdk/local status
462
474
  npx @awareness-sdk/local stop
463
475
  npx @awareness-sdk/local reindex --project /path/to/project
476
+ npx @awareness-sdk/local mcp --project /path/to/project --port 37800
464
477
  `);
465
478
  }
466
479
 
@@ -489,6 +502,9 @@ async function main() {
489
502
  case 'reindex':
490
503
  await cmdReindex(flags);
491
504
  break;
505
+ case 'mcp':
506
+ await cmdMcp(flags);
507
+ break;
492
508
  default:
493
509
  console.error(`Unknown command: ${command}`);
494
510
  printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awareness-sdk/local",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
4
4
  "description": "Local-first AI agent memory system. No account needed.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -375,6 +375,10 @@ export class CloudSync {
375
375
  console.error(`${LOG_PREFIX} syncToCloud failed:`, err.message);
376
376
  }
377
377
 
378
+ if (synced > 0) {
379
+ this._recordSyncEvent('memories', { count: synced, direction: 'push' });
380
+ }
381
+
378
382
  return { synced, errors };
379
383
  }
380
384
 
@@ -426,6 +430,7 @@ export class CloudSync {
426
430
 
427
431
  if (pulled > 0) {
428
432
  console.log(`${LOG_PREFIX} Pulled ${pulled} memories from cloud`);
433
+ this._recordSyncEvent('memories', { count: pulled, direction: 'pull' });
429
434
  }
430
435
  } catch (err) {
431
436
  console.error(`${LOG_PREFIX} pullFromCloud failed:`, err.message);
@@ -496,6 +501,7 @@ export class CloudSync {
496
501
 
497
502
  if (synced > 0) {
498
503
  console.log(`${LOG_PREFIX} Pushed ${synced} knowledge cards to cloud` + (errors ? ` (${errors} errors)` : ''));
504
+ this._recordSyncEvent('insights', { count: synced, direction: 'push' });
499
505
  }
500
506
  } catch (err) {
501
507
  console.error(`${LOG_PREFIX} syncInsightsToCloud failed:`, err.message);
@@ -563,6 +569,7 @@ export class CloudSync {
563
569
 
564
570
  if (synced > 0) {
565
571
  console.log(`${LOG_PREFIX} Pushed ${synced} tasks to cloud` + (errors ? ` (${errors} errors)` : ''));
572
+ this._recordSyncEvent('tasks', { count: synced, direction: 'push' });
566
573
  }
567
574
  } catch (err) {
568
575
  console.error(`${LOG_PREFIX} syncTasksToCloud failed:`, err.message);
@@ -1171,6 +1178,51 @@ export class CloudSync {
1171
1178
  }
1172
1179
  }
1173
1180
 
1181
+ // =========================================================================
1182
+ // Public — sync history
1183
+ // =========================================================================
1184
+
1185
+ /**
1186
+ * Record a sync event to the sync_state table for history tracking.
1187
+ * @param {string} type — "memories" | "insights" | "tasks"
1188
+ * @param {object} details — { count, direction: "push"|"pull" }
1189
+ */
1190
+ _recordSyncEvent(type, details) {
1191
+ try {
1192
+ const timestamp = new Date().toISOString();
1193
+ const key = `sync_log:${timestamp}`;
1194
+ const value = JSON.stringify({ type, details, timestamp });
1195
+ this._setSyncState(key, value);
1196
+ } catch {
1197
+ // Non-critical — don't crash on history logging failure
1198
+ }
1199
+ }
1200
+
1201
+ /**
1202
+ * Get recent sync history events.
1203
+ * @param {number} [limit=20] — Maximum number of events to return
1204
+ * @returns {Array<{ type: string, details: object, timestamp: string }>}
1205
+ */
1206
+ getSyncHistory(limit = 20) {
1207
+ try {
1208
+ const rows = this.indexer.db
1209
+ .prepare(
1210
+ `SELECT value FROM sync_state WHERE key LIKE 'sync_log:%' ORDER BY key DESC LIMIT ?`
1211
+ )
1212
+ .all(limit);
1213
+
1214
+ return rows.map((row) => {
1215
+ try {
1216
+ return JSON.parse(row.value);
1217
+ } catch {
1218
+ return null;
1219
+ }
1220
+ }).filter(Boolean);
1221
+ } catch {
1222
+ return [];
1223
+ }
1224
+ }
1225
+
1174
1226
  // =========================================================================
1175
1227
  // Internal — utilities
1176
1228
  // =========================================================================
package/src/daemon.mjs CHANGED
@@ -1072,6 +1072,8 @@ export class AwarenessLocalDaemon {
1072
1072
  const config = this._loadConfig();
1073
1073
  const cloud = config.cloud || {};
1074
1074
 
1075
+ const history = this.cloudSync ? this.cloudSync.getSyncHistory() : [];
1076
+
1075
1077
  return jsonResponse(res, {
1076
1078
  cloud_enabled: !!cloud.enabled,
1077
1079
  api_base: cloud.api_base || null,
@@ -1079,6 +1081,7 @@ export class AwarenessLocalDaemon {
1079
1081
  auto_sync: cloud.auto_sync ?? true,
1080
1082
  last_push_at: cloud.last_push_at || null,
1081
1083
  last_pull_at: cloud.last_pull_at || null,
1084
+ history,
1082
1085
  });
1083
1086
  }
1084
1087
 
@@ -0,0 +1,398 @@
1
+ /**
2
+ * mcp-stdio.mjs — Lightweight stdio MCP proxy for Awareness Local.
3
+ *
4
+ * Registers the same 5 tools as the HTTP daemon (awareness_init,
5
+ * awareness_recall, awareness_record, awareness_lookup,
6
+ * awareness_get_agent_prompt) but proxies every call to the local daemon
7
+ * via HTTP JSON-RPC at http://localhost:{port}/mcp.
8
+ *
9
+ * If the daemon is not running it is auto-started before the first call.
10
+ *
11
+ * stdout is reserved for the stdio MCP protocol — all logging goes to stderr.
12
+ */
13
+
14
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
+ import { z } from 'zod';
17
+ import { spawn } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import http from 'node:http';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Logging — always to stderr so stdout stays clean for stdio protocol
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function log(...args) {
30
+ process.stderr.write(`[awareness-stdio] ${args.join(' ')}\n`);
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // HTTP helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Simple HTTP POST that returns parsed JSON.
39
+ * Uses only node:http to avoid external dependencies.
40
+ */
41
+ function httpPost(url, body) {
42
+ return new Promise((resolve, reject) => {
43
+ const u = new URL(url);
44
+ const data = JSON.stringify(body);
45
+ const req = http.request(
46
+ {
47
+ hostname: u.hostname,
48
+ port: u.port,
49
+ path: u.pathname,
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'Content-Length': Buffer.byteLength(data),
54
+ },
55
+ },
56
+ (res) => {
57
+ const chunks = [];
58
+ res.on('data', (c) => chunks.push(c));
59
+ res.on('end', () => {
60
+ try {
61
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
62
+ } catch (e) {
63
+ reject(new Error(`Failed to parse daemon response: ${e.message}`));
64
+ }
65
+ });
66
+ },
67
+ );
68
+ req.on('error', reject);
69
+ req.write(data);
70
+ req.end();
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Quick health check — resolves true if daemon responds, false otherwise.
76
+ */
77
+ function checkHealth(port) {
78
+ return new Promise((resolve) => {
79
+ const req = http.get(`http://127.0.0.1:${port}/healthz`, (res) => {
80
+ // Any response means daemon is up
81
+ res.resume();
82
+ resolve(true);
83
+ });
84
+ req.on('error', () => resolve(false));
85
+ req.setTimeout(2000, () => {
86
+ req.destroy();
87
+ resolve(false);
88
+ });
89
+ });
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Daemon lifecycle
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Ensure the daemon is running. If not, spawn it and poll /healthz for up
98
+ * to 15 seconds.
99
+ */
100
+ async function ensureDaemon(port) {
101
+ if (await checkHealth(port)) return;
102
+
103
+ log('Daemon not reachable — starting...');
104
+ const binPath = join(__dirname, '..', 'bin', 'awareness-local.mjs');
105
+ const child = spawn(process.execPath, [binPath, 'start'], {
106
+ stdio: 'ignore',
107
+ detached: true,
108
+ env: { ...process.env, PORT: String(port) },
109
+ });
110
+ child.unref();
111
+
112
+ // Poll healthz for up to 15 seconds
113
+ const deadline = Date.now() + 15_000;
114
+ while (Date.now() < deadline) {
115
+ await new Promise((r) => setTimeout(r, 500));
116
+ if (await checkHealth(port)) {
117
+ log('Daemon is ready.');
118
+ return;
119
+ }
120
+ }
121
+ throw new Error(
122
+ `Daemon did not become healthy within 15s (port ${port}). ` +
123
+ `Try running "npx awareness-local start" manually.`,
124
+ );
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // JSON-RPC proxy
129
+ // ---------------------------------------------------------------------------
130
+
131
+ let _daemonChecked = false;
132
+
133
+ /**
134
+ * Proxy a tool call to the daemon via JSON-RPC over HTTP.
135
+ *
136
+ * @param {number} port
137
+ * @param {string} toolName — MCP tool name (e.g. "awareness_init")
138
+ * @param {object} args — tool arguments
139
+ * @returns {object} parsed result from daemon
140
+ */
141
+ async function proxyCall(port, toolName, args) {
142
+ // Lazy daemon startup — only check once per process
143
+ if (!_daemonChecked) {
144
+ await ensureDaemon(port);
145
+ _daemonChecked = true;
146
+ }
147
+
148
+ const rpcBody = {
149
+ jsonrpc: '2.0',
150
+ id: 1,
151
+ method: 'tools/call',
152
+ params: { name: toolName, arguments: args },
153
+ };
154
+
155
+ let response;
156
+ try {
157
+ response = await httpPost(`http://127.0.0.1:${port}/mcp`, rpcBody);
158
+ } catch (err) {
159
+ // Daemon may have died — try to restart once
160
+ log(`Proxy error, retrying after daemon restart: ${err.message}`);
161
+ _daemonChecked = false;
162
+ await ensureDaemon(port);
163
+ _daemonChecked = true;
164
+ response = await httpPost(`http://127.0.0.1:${port}/mcp`, rpcBody);
165
+ }
166
+
167
+ // JSON-RPC error
168
+ if (response.error) {
169
+ throw new Error(
170
+ `Daemon RPC error ${response.error.code}: ${response.error.message}`,
171
+ );
172
+ }
173
+
174
+ // The daemon wraps results as:
175
+ // result.content[0].text = JSON.stringify(payload)
176
+ const result = response.result;
177
+ if (result?.content?.[0]?.text) {
178
+ try {
179
+ return JSON.parse(result.content[0].text);
180
+ } catch {
181
+ // If it's not parseable JSON, return as-is
182
+ return result;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Tool registration helpers
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Wrap a proxy result in the standard MCP content envelope.
194
+ */
195
+ function mcpResult(data) {
196
+ return { content: [{ type: 'text', text: JSON.stringify(data) }] };
197
+ }
198
+
199
+ function mcpError(message) {
200
+ return {
201
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
202
+ isError: true,
203
+ };
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Register tools — schemas match mcp-server.mjs exactly
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function registerTools(server, port) {
211
+ // ======================== awareness_init ==================================
212
+
213
+ server.tool(
214
+ 'awareness_init',
215
+ {
216
+ memory_id: z.string().optional().describe(
217
+ 'Memory identifier (ignored in local mode, uses project dir)',
218
+ ),
219
+ source: z.string().optional().describe('Client source identifier'),
220
+ days: z.number().optional().default(7).describe(
221
+ 'Days of history to load',
222
+ ),
223
+ max_cards: z.number().optional().default(5),
224
+ max_tasks: z.number().optional().default(5),
225
+ },
226
+ async (params) => {
227
+ try {
228
+ return mcpResult(await proxyCall(port, 'awareness_init', params));
229
+ } catch (err) {
230
+ return mcpError(`awareness_init failed: ${err.message}`);
231
+ }
232
+ },
233
+ );
234
+
235
+ // ======================== awareness_recall ================================
236
+
237
+ server.tool(
238
+ 'awareness_recall',
239
+ {
240
+ semantic_query: z.string().optional().default('').describe(
241
+ 'Natural language search query (required for search)',
242
+ ),
243
+ keyword_query: z.string().optional().default('').describe(
244
+ 'Exact keyword match for BM25 full-text search',
245
+ ),
246
+ scope: z.enum(['all', 'timeline', 'knowledge', 'insights'])
247
+ .optional().default('all')
248
+ .describe('Search scope'),
249
+ recall_mode: z.enum(['precise', 'session', 'structured', 'hybrid', 'auto'])
250
+ .optional().default('hybrid')
251
+ .describe('Search mode (hybrid recommended)'),
252
+ limit: z.number().min(1).max(30).optional().default(10)
253
+ .describe('Max results'),
254
+ detail: z.enum(['summary', 'full']).optional().default('summary')
255
+ .describe(
256
+ 'summary = lightweight index (~50-100 tokens each); ' +
257
+ 'full = complete content for specified ids',
258
+ ),
259
+ ids: z.array(z.string()).optional().describe(
260
+ 'Item IDs to expand when detail=full (from a prior detail=summary call)',
261
+ ),
262
+ agent_role: z.string().optional().default('').describe('Agent role filter'),
263
+ },
264
+ async (params) => {
265
+ try {
266
+ return mcpResult(await proxyCall(port, 'awareness_recall', params));
267
+ } catch (err) {
268
+ return mcpError(`awareness_recall failed: ${err.message}`);
269
+ }
270
+ },
271
+ );
272
+
273
+ // ======================== awareness_record ================================
274
+
275
+ server.tool(
276
+ 'awareness_record',
277
+ {
278
+ action: z.enum([
279
+ 'remember', 'remember_batch', 'update_task', 'submit_insights',
280
+ ]).describe('Record action type'),
281
+ content: z.string().optional().describe('Memory content (markdown)'),
282
+ title: z.string().optional().describe('Memory title'),
283
+ items: z.array(z.object({
284
+ content: z.string(),
285
+ title: z.string().optional(),
286
+ event_type: z.string().optional(),
287
+ tags: z.array(z.string()).optional(),
288
+ insights: z.any().optional(),
289
+ })).optional().describe('Batch items for remember_batch'),
290
+ insights: z.object({
291
+ knowledge_cards: z.array(z.any()).optional(),
292
+ action_items: z.array(z.any()).optional(),
293
+ risks: z.array(z.any()).optional(),
294
+ }).optional().describe('Pre-extracted knowledge cards, tasks, risks'),
295
+ session_id: z.string().optional(),
296
+ agent_role: z.string().optional(),
297
+ event_type: z.string().optional(),
298
+ tags: z.array(z.string()).optional(),
299
+ // Task update fields
300
+ task_id: z.string().optional(),
301
+ status: z.string().optional(),
302
+ },
303
+ async (params) => {
304
+ try {
305
+ return mcpResult(await proxyCall(port, 'awareness_record', params));
306
+ } catch (err) {
307
+ return mcpError(`awareness_record failed: ${err.message}`);
308
+ }
309
+ },
310
+ );
311
+
312
+ // ======================== awareness_lookup ================================
313
+
314
+ server.tool(
315
+ 'awareness_lookup',
316
+ {
317
+ type: z.enum([
318
+ 'context', 'tasks', 'knowledge', 'risks',
319
+ 'session_history', 'timeline',
320
+ ]).describe(
321
+ 'Data type to look up. ' +
322
+ 'context = full dump, tasks = open tasks, knowledge = cards, ' +
323
+ 'risks = risk items, session_history = past sessions, timeline = events',
324
+ ),
325
+ limit: z.number().optional().default(10).describe('Max items'),
326
+ status: z.string().optional().describe('Status filter'),
327
+ category: z.string().optional().describe('Category filter (knowledge cards)'),
328
+ priority: z.string().optional().describe('Priority filter (tasks/risks)'),
329
+ session_id: z.string().optional().describe('Session ID (for session_history)'),
330
+ agent_role: z.string().optional().describe('Agent role filter'),
331
+ query: z.string().optional().describe('Keyword filter'),
332
+ },
333
+ async (params) => {
334
+ try {
335
+ return mcpResult(await proxyCall(port, 'awareness_lookup', params));
336
+ } catch (err) {
337
+ return mcpError(`awareness_lookup failed: ${err.message}`);
338
+ }
339
+ },
340
+ );
341
+
342
+ // ======================== awareness_get_agent_prompt ======================
343
+
344
+ server.tool(
345
+ 'awareness_get_agent_prompt',
346
+ {
347
+ role: z.string().optional().describe('Agent role to get prompt for'),
348
+ },
349
+ async (params) => {
350
+ try {
351
+ return mcpResult(await proxyCall(port, 'awareness_get_agent_prompt', params));
352
+ } catch (err) {
353
+ return mcpError(`awareness_get_agent_prompt failed: ${err.message}`);
354
+ }
355
+ },
356
+ );
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Public API
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Start the stdio MCP proxy server.
365
+ *
366
+ * @param {object} opts
367
+ * @param {number} [opts.port=37800] — daemon HTTP port to proxy to
368
+ * @param {string} [opts.projectDir] — project directory (unused in proxy,
369
+ * but accepted for API symmetry with direct-mode startup)
370
+ */
371
+ export async function startStdioMcp({ port = 37800, projectDir } = {}) {
372
+ log(`Starting stdio MCP proxy (daemon port=${port})`);
373
+
374
+ const server = new McpServer({
375
+ name: 'awareness-local-stdio',
376
+ version: '1.0.0',
377
+ });
378
+
379
+ registerTools(server, port);
380
+
381
+ const transport = new StdioServerTransport();
382
+ await server.connect(transport);
383
+
384
+ log('stdio MCP proxy connected and ready.');
385
+ return server;
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // CLI entry — run directly with `node src/mcp-stdio.mjs`
390
+ // ---------------------------------------------------------------------------
391
+
392
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
393
+ const port = parseInt(process.env.AWARENESS_PORT || process.env.PORT || '37800', 10);
394
+ startStdioMcp({ port }).catch((err) => {
395
+ process.stderr.write(`[awareness-stdio] Fatal: ${err.message}\n`);
396
+ process.exit(1);
397
+ });
398
+ }
@@ -215,6 +215,17 @@ body {
215
215
  .sync-history { margin-top: 20px; }
216
216
  .sync-history h4 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--text-secondary); }
217
217
  .sync-history-empty { font-size: 0.85rem; color: var(--text-muted); padding: 16px; text-align: center; }
218
+ .sync-history-list { max-height: 300px; overflow-y: auto; }
219
+ .sync-history-item {
220
+ display: flex; align-items: center; gap: 10px;
221
+ padding: 8px 12px; border-bottom: 1px solid var(--border);
222
+ font-size: 0.85rem;
223
+ }
224
+ .sync-history-item:last-child { border-bottom: none; }
225
+ .sync-history-icon { font-size: 1rem; flex-shrink: 0; }
226
+ .sync-history-type { font-weight: 500; min-width: 70px; }
227
+ .sync-history-count { color: var(--text-secondary); }
228
+ .sync-history-time { margin-left: auto; color: var(--text-muted); font-size: 0.8rem; white-space: nowrap; }
218
229
 
219
230
  /* ---- Settings ---- */
220
231
  .settings-section { margin-bottom: 28px; }
@@ -754,6 +765,31 @@ async function loadSync() {
754
765
  descEl.textContent = 'Memories are stored locally only. Connect to cloud for backup and cross-device sync.';
755
766
  btnEl.textContent = 'Connect to Cloud';
756
767
  }
768
+
769
+ // Render sync history
770
+ var historyEl = document.getElementById('sync-history');
771
+ if (data.history && data.history.length > 0) {
772
+ var html = '<h4>Sync History</h4><div class="sync-history-list">';
773
+ for (var i = 0; i < data.history.length; i++) {
774
+ var item = data.history[i];
775
+ var dir = (item.details && item.details.direction) || 'push';
776
+ var icon = dir === 'pull' ? '\u2B07\uFE0F' : '\u2B06\uFE0F';
777
+ var type = item.type || 'memories';
778
+ var count = (item.details && item.details.count) || 0;
779
+ var time = item.timestamp ? formatDate(item.timestamp) : '';
780
+ html += '<div class="sync-history-item">'
781
+ + '<span class="sync-history-icon">' + icon + '</span>'
782
+ + '<span class="sync-history-type">' + type + '</span>'
783
+ + '<span class="sync-history-count">' + count + ' ' + dir + '</span>'
784
+ + '<span class="sync-history-time">' + time + '</span>'
785
+ + '</div>';
786
+ }
787
+ html += '</div>';
788
+ historyEl.innerHTML = html;
789
+ } else {
790
+ historyEl.innerHTML = '<h4>Sync History</h4>'
791
+ + '<div class="sync-history-empty">No sync history yet. Connect to cloud to enable sync.</div>';
792
+ }
757
793
  }
758
794
 
759
795
  var _authApiKey = null;