@awareness-sdk/local 0.1.12 → 0.2.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.
@@ -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.12",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first AI agent memory system. No account needed.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
+ }