@awareness-sdk/local 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awareness-sdk/local",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Local-first AI agent memory system. No account needed.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -475,6 +475,12 @@ export class CloudSync {
475
475
 
476
476
  res.on('data', (chunk) => {
477
477
  buffer += chunk;
478
+ // SECURITY C7: Cap SSE buffer to prevent unbounded memory growth
479
+ if (buffer.length > 1024 * 1024) {
480
+ console.warn(`${LOG_PREFIX} SSE buffer overflow (>1MB) — dropping`);
481
+ buffer = '';
482
+ return;
483
+ }
478
484
  const { parsed, remainder } = parseSSE(buffer);
479
485
  buffer = remainder;
480
486
 
@@ -181,7 +181,10 @@ export function initLocalConfig(projectDir) {
181
181
  config.device.id = deviceId;
182
182
  config.device.name = deviceName;
183
183
 
184
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
184
+ // SECURITY C6: Atomic write (tmp+rename) to prevent corruption on crash
185
+ const tmpPath = configPath + '.tmp';
186
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
187
+ fs.renameSync(tmpPath, configPath);
185
188
  return config;
186
189
  }
187
190
 
@@ -235,9 +238,11 @@ export function saveCloudConfig(projectDir, { apiKey, memoryId, apiBase }) {
235
238
  }
236
239
 
237
240
  const configPath = getConfigPath(projectDir);
238
- // Ensure dirs exist before writing
239
241
  ensureLocalDirs(projectDir);
240
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
242
+ // SECURITY C6: Atomic write
243
+ const tmpPath = configPath + '.tmp';
244
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), 'utf-8');
245
+ fs.renameSync(tmpPath, configPath);
241
246
 
242
247
  return config;
243
248
  }
@@ -283,6 +288,8 @@ function deepClone(obj) {
283
288
  */
284
289
  function deepMerge(target, source) {
285
290
  for (const key of Object.keys(source)) {
291
+ // SECURITY H7: Prevent prototype pollution
292
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
286
293
  const srcVal = source[key];
287
294
  const tgtVal = target[key];
288
295
 
@@ -634,13 +634,21 @@ export class Indexer {
634
634
  *
635
635
  * @param {number} [limit=5]
636
636
  */
637
- getOpenTasks(limit = 5) {
637
+ getOpenTasks(limit = 0) {
638
+ if (limit > 0) {
639
+ return this.db
640
+ .prepare(
641
+ `SELECT * FROM tasks WHERE status = 'open'
642
+ ORDER BY created_at DESC LIMIT ?`
643
+ )
644
+ .all(limit);
645
+ }
638
646
  return this.db
639
647
  .prepare(
640
648
  `SELECT * FROM tasks WHERE status = 'open'
641
- ORDER BY created_at DESC LIMIT ?`
649
+ ORDER BY created_at DESC`
642
650
  )
643
- .all(limit);
651
+ .all();
644
652
  }
645
653
 
646
654
  /**
@@ -154,7 +154,18 @@ export class KnowledgeExtractor {
154
154
  }
155
155
  }
156
156
 
157
- return { cards, tasks, risks };
157
+ // Collect completed_tasks for auto-completion processing
158
+ const completedTasks = [];
159
+ if (insights.completed_tasks) {
160
+ for (const ct of insights.completed_tasks) {
161
+ const taskId = (ct.task_id || '').trim();
162
+ if (taskId) {
163
+ completedTasks.push({ task_id: taskId, reason: ct.reason || '' });
164
+ }
165
+ }
166
+ }
167
+
168
+ return { cards, tasks, risks, completedTasks };
158
169
  }
159
170
 
160
171
  // -------------------------------------------------------------------------
@@ -480,6 +491,26 @@ export class KnowledgeExtractor {
480
491
  }
481
492
 
482
493
  await Promise.all(promises);
494
+
495
+ // Auto-complete tasks identified by the LLM
496
+ if (result.completedTasks && this.indexer) {
497
+ for (const ct of result.completedTasks) {
498
+ try {
499
+ const existing = this.indexer.db
500
+ .prepare('SELECT * FROM tasks WHERE id = ?')
501
+ .get(ct.task_id);
502
+ if (existing && existing.status !== 'done') {
503
+ this.indexer.indexTask({
504
+ ...existing,
505
+ status: 'done',
506
+ updated_at: new Date().toISOString(),
507
+ });
508
+ }
509
+ } catch (err) {
510
+ console.warn(`[KnowledgeExtractor] Failed to auto-complete task ${ct.task_id}:`, err.message);
511
+ }
512
+ }
513
+ }
483
514
  }
484
515
 
485
516
  /**
package/src/daemon.mjs CHANGED
@@ -50,23 +50,38 @@ function nowISO() {
50
50
  */
51
51
  function jsonResponse(res, data, status = 200) {
52
52
  const body = JSON.stringify(data);
53
+ // SECURITY: Only allow requests from localhost dashboard (not arbitrary websites)
54
+ const origin = 'http://localhost:37800';
53
55
  res.writeHead(status, {
54
56
  'Content-Type': 'application/json',
55
57
  'Content-Length': Buffer.byteLength(body),
56
- 'Access-Control-Allow-Origin': '*',
58
+ 'Access-Control-Allow-Origin': origin,
57
59
  });
58
60
  res.end(body);
59
61
  }
60
62
 
63
+ /** Max request body size (10 MB) — prevents memory exhaustion DoS. */
64
+ const MAX_BODY_BYTES = 10 * 1024 * 1024;
65
+
61
66
  /**
62
67
  * Read the full request body as a string.
68
+ * Rejects with 413 if body exceeds MAX_BODY_BYTES.
63
69
  * @param {http.IncomingMessage} req
64
70
  * @returns {Promise<string>}
65
71
  */
66
72
  function readBody(req) {
67
73
  return new Promise((resolve, reject) => {
68
74
  const chunks = [];
69
- req.on('data', (chunk) => chunks.push(chunk));
75
+ let totalBytes = 0;
76
+ req.on('data', (chunk) => {
77
+ totalBytes += chunk.length;
78
+ if (totalBytes > MAX_BODY_BYTES) {
79
+ req.destroy();
80
+ reject(new Error('Payload too large (max 10MB)'));
81
+ return;
82
+ }
83
+ chunks.push(chunk);
84
+ });
70
85
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
71
86
  req.on('error', reject);
72
87
  });
@@ -151,6 +166,11 @@ export class AwarenessLocalDaemon {
151
166
  * 7. Start fs.watch on memories dir
152
167
  */
153
168
  async start() {
169
+ // SECURITY C4: Prevent unhandled rejections from crashing the daemon
170
+ process.on('unhandledRejection', (err) => {
171
+ console.error('[awareness-local] unhandled rejection:', err?.message || err);
172
+ });
173
+
154
174
  if (await this.isRunning()) {
155
175
  console.log(
156
176
  `[awareness-local] daemon already running on port ${this.port}`
@@ -623,10 +643,31 @@ export class AwarenessLocalDaemon {
623
643
  const session = this._createSession(args.source);
624
644
  const stats = this.indexer.getStats();
625
645
  const recentCards = this.indexer.getRecentKnowledge(args.max_cards ?? 5);
626
- const openTasks = this.indexer.getOpenTasks(args.max_tasks ?? 5);
646
+ const openTasks = this.indexer.getOpenTasks(args.max_tasks ?? 0);
627
647
  const recentSessions = this.indexer.getRecentSessions(args.days ?? 7);
628
648
  const spec = this._loadSpec();
629
649
 
650
+ // Compute attention_summary for LLM-side triage
651
+ const now = Date.now();
652
+ const staleDays = 3;
653
+ const staleCutoff = now - staleDays * 86400000;
654
+ const staleTasks = openTasks.filter(t => {
655
+ const created = t.created_at ? new Date(t.created_at).getTime() : now;
656
+ return created < staleCutoff;
657
+ }).length;
658
+ const riskCards = this.indexer.db
659
+ .prepare("SELECT COUNT(*) as cnt FROM knowledge_cards WHERE (category = 'risk' OR category = 'pitfall') AND status = 'active'")
660
+ .get();
661
+ const highRisks = riskCards?.cnt || 0;
662
+
663
+ const attentionSummary = {
664
+ stale_tasks: staleTasks,
665
+ high_risks: highRisks,
666
+ total_open_tasks: openTasks.length,
667
+ total_knowledge_cards: recentCards.length,
668
+ needs_attention: staleTasks > 0 || highRisks > 0,
669
+ };
670
+
630
671
  return {
631
672
  content: [{
632
673
  type: 'text',
@@ -637,6 +678,7 @@ export class AwarenessLocalDaemon {
637
678
  open_tasks: openTasks,
638
679
  recent_sessions: recentSessions,
639
680
  stats,
681
+ attention_summary: attentionSummary,
640
682
  synthesized_rules: spec.core_lines?.join('\n') || '',
641
683
  init_guides: spec.init_guides || {},
642
684
  agent_profiles: [],
@@ -653,31 +695,20 @@ export class AwarenessLocalDaemon {
653
695
  const items = this.search
654
696
  ? await this.search.getFullContent(args.ids)
655
697
  : [];
698
+ // Return as readable text (no JSON noise for the Agent)
699
+ const sections = items.map(r => {
700
+ const header = r.title ? `## ${r.title}` : '';
701
+ return `${header}\n\n${r.content || '(no content)'}`;
702
+ });
656
703
  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
- }],
704
+ content: [{ type: 'text', text: sections.join('\n\n---\n\n') || '(no results)' }],
666
705
  };
667
706
  }
668
707
 
669
708
  // Phase 1: search + summary
670
709
  if (!args.semantic_query && !args.keyword_query) {
671
710
  return {
672
- content: [{
673
- type: 'text',
674
- text: JSON.stringify({
675
- results: [],
676
- total: 0,
677
- mode: 'local',
678
- detail: 'summary',
679
- }),
680
- }],
711
+ content: [{ type: 'text', text: 'No query provided. Use semantic_query or keyword_query to search.' }],
681
712
  };
682
713
  }
683
714
 
@@ -685,17 +716,32 @@ export class AwarenessLocalDaemon {
685
716
  ? await this.search.recall(args)
686
717
  : [];
687
718
 
719
+ if (!summaries.length) {
720
+ return {
721
+ content: [{ type: 'text', text: 'No matching memories found.' }],
722
+ };
723
+ }
724
+
725
+ // Format as readable text for the Agent (not raw JSON)
726
+ const lines = summaries.map((r, i) => {
727
+ const type = r.type ? `[${r.type}]` : '';
728
+ const title = r.title || '(untitled)';
729
+ const summary = r.summary ? `\n ${r.summary}` : '';
730
+ return `${i + 1}. ${type} ${title}${summary}`;
731
+ });
732
+ const readableText = `Found ${summaries.length} memories:\n\n${lines.join('\n\n')}`;
733
+
734
+ // IDs in a separate content block for Phase 2 expansion
735
+ const idsMeta = JSON.stringify({
736
+ _ids: summaries.map(r => r.id),
737
+ _hint: 'To see full content, call awareness_recall(detail="full", ids=[...]) with IDs above.',
738
+ });
739
+
688
740
  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
- }],
741
+ content: [
742
+ { type: 'text', text: readableText },
743
+ { type: 'text', text: idsMeta },
744
+ ],
699
745
  };
700
746
  }
701
747
 
@@ -920,7 +966,8 @@ export class AwarenessLocalDaemon {
920
966
  */
921
967
  _apiListTasks(_req, res, url) {
922
968
  const status = url.searchParams.get('status') || null;
923
- const limit = parseInt(url.searchParams.get('limit') || '100', 10);
969
+ const limitParam = url.searchParams.get('limit');
970
+ const limit = limitParam ? parseInt(limitParam, 10) : 0;
924
971
 
925
972
  if (!this.indexer) {
926
973
  return jsonResponse(res, { items: [], total: 0 });
@@ -939,8 +986,11 @@ export class AwarenessLocalDaemon {
939
986
  sql += ' WHERE ' + conditions.join(' AND ');
940
987
  }
941
988
 
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);
989
+ sql += ` ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, created_at DESC`;
990
+ if (limit > 0) {
991
+ sql += ` LIMIT ?`;
992
+ params.push(limit);
993
+ }
944
994
 
945
995
  const rows = this.indexer.db.prepare(sql).all(...params);
946
996
  return jsonResponse(res, { items: rows, total: rows.length });
@@ -1043,7 +1093,9 @@ export class AwarenessLocalDaemon {
1043
1093
  }
1044
1094
 
1045
1095
  try {
1046
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
1096
+ const tmpCfg = configPath + '.tmp';
1097
+ fs.writeFileSync(tmpCfg, JSON.stringify(config, null, 2), 'utf-8');
1098
+ fs.renameSync(tmpCfg, configPath);
1047
1099
  } catch (err) {
1048
1100
  return jsonResponse(res, { error: 'Failed to save config: ' + err.message }, 500);
1049
1101
  }
@@ -1078,9 +1130,13 @@ export class AwarenessLocalDaemon {
1078
1130
  let params;
1079
1131
  try { params = JSON.parse(body); } catch { return jsonResponse(res, { error: 'Invalid JSON' }, 400); }
1080
1132
 
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
1133
+ const config = this._loadConfig();
1134
+ const apiBase = config?.cloud?.api_base || 'https://awareness.market/api/v1';
1135
+
1136
+ // SECURITY C5: Don't hold connection for 5 minutes.
1137
+ // Poll a few times (max 30s), then return pending for client to retry.
1138
+ const interval = Math.max((params.interval || 5) * 1000, 3000);
1139
+ const maxPolls = Math.min(Math.floor(30000 / interval), 6);
1084
1140
 
1085
1141
  for (let i = 0; i < maxPolls; i++) {
1086
1142
  try {
@@ -1100,8 +1156,10 @@ export class AwarenessLocalDaemon {
1100
1156
  }
1101
1157
 
1102
1158
  async _apiCloudListMemories(req, res, url) {
1103
- const apiKey = url.searchParams.get('api_key');
1104
- if (!apiKey) return jsonResponse(res, { error: 'api_key required' }, 400);
1159
+ // SECURITY C3: Use cloud config API key instead of query param (avoid log/referer leaks)
1160
+ const config = this._loadConfig();
1161
+ const apiKey = config?.cloud?.api_key;
1162
+ if (!apiKey) return jsonResponse(res, { error: 'Cloud not configured. Connect via /api/v1/cloud/connect first.' }, 400);
1105
1163
 
1106
1164
  const apiBase = this.config?.cloud?.api_base || 'https://awareness.market/api/v1';
1107
1165
  try {
@@ -1248,11 +1306,18 @@ export class AwarenessLocalDaemon {
1248
1306
  return this.indexer.createSession(source || 'local');
1249
1307
  }
1250
1308
 
1309
+ /** Max content size per memory (1 MB). */
1310
+ static MAX_CONTENT_BYTES = 1024 * 1024;
1311
+
1251
1312
  /** Write a single memory, index it, and trigger knowledge extraction. */
1252
1313
  async _remember(params) {
1253
1314
  if (!params.content) {
1254
1315
  return { error: 'content is required for remember action' };
1255
1316
  }
1317
+ // SECURITY H1: Reject oversized content to prevent FTS5/embedding freeze
1318
+ if (typeof params.content === 'string' && params.content.length > AwarenessLocalDaemon.MAX_CONTENT_BYTES) {
1319
+ return { error: `Content too large (${params.content.length} bytes, max ${AwarenessLocalDaemon.MAX_CONTENT_BYTES})` };
1320
+ }
1256
1321
 
1257
1322
  // Auto-generate title from content if not provided
1258
1323
  let title = params.title || '';
@@ -1444,10 +1509,35 @@ ${item.description || item.title || ''}
1444
1509
  }
1445
1510
  }
1446
1511
 
1512
+ // Auto-complete tasks identified by the LLM
1513
+ let tasksAutoCompleted = 0;
1514
+ if (Array.isArray(insights.completed_tasks)) {
1515
+ for (const completed of insights.completed_tasks) {
1516
+ const taskId = (completed.task_id || '').trim();
1517
+ if (!taskId) continue;
1518
+ try {
1519
+ const existing = this.indexer.db
1520
+ .prepare('SELECT * FROM tasks WHERE id = ?')
1521
+ .get(taskId);
1522
+ if (existing && existing.status !== 'done') {
1523
+ this.indexer.indexTask({
1524
+ ...existing,
1525
+ status: 'done',
1526
+ updated_at: nowISO(),
1527
+ });
1528
+ tasksAutoCompleted++;
1529
+ }
1530
+ } catch (err) {
1531
+ console.warn(`[AwarenessDaemon] Failed to auto-complete task '${taskId}':`, err.message);
1532
+ }
1533
+ }
1534
+ }
1535
+
1447
1536
  return {
1448
1537
  status: 'ok',
1449
1538
  cards_created: cardsCreated,
1450
1539
  tasks_created: tasksCreated,
1540
+ tasks_auto_completed: tasksAutoCompleted,
1451
1541
  mode: 'local',
1452
1542
  };
1453
1543
  }
@@ -1461,7 +1551,7 @@ ${item.description || item.title || ''}
1461
1551
  // Full context dump
1462
1552
  const stats = this.indexer.getStats();
1463
1553
  const knowledge = this.indexer.getRecentKnowledge(limit);
1464
- const tasks = this.indexer.getOpenTasks(limit);
1554
+ const tasks = this.indexer.getOpenTasks(0);
1465
1555
  const sessions = this.indexer.getRecentSessions(7);
1466
1556
  return { stats, knowledge_cards: knowledge, open_tasks: tasks, recent_sessions: sessions, mode: 'local' };
1467
1557
  }
@@ -1487,8 +1577,11 @@ ${item.description || item.title || ''}
1487
1577
  }
1488
1578
 
1489
1579
  if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
1490
- sql += ' ORDER BY created_at DESC LIMIT ?';
1491
- sqlParams.push(limit);
1580
+ sql += ' ORDER BY created_at DESC';
1581
+ if (limit > 0) {
1582
+ sql += ' LIMIT ?';
1583
+ sqlParams.push(limit);
1584
+ }
1492
1585
 
1493
1586
  const tasks = this.indexer.db.prepare(sql).all(...sqlParams);
1494
1587
  return { tasks, total: tasks.length, mode: 'local' };
@@ -206,11 +206,48 @@ export class LocalMcpServer {
206
206
  ids: params.ids,
207
207
  });
208
208
 
209
+ const effectiveDetail = params.detail || 'summary';
210
+
211
+ if (effectiveDetail === 'summary' && summaries.length > 0) {
212
+ // Format summary as readable text for the Agent, with IDs hidden
213
+ // but available for Phase 2 expansion
214
+ const lines = summaries.map((r, i) => {
215
+ const title = r.title || '(untitled)';
216
+ const type = r.type ? `[${r.type}]` : '';
217
+ const summary = r.summary ? `\n ${r.summary}` : '';
218
+ return `${i + 1}. ${type} ${title}${summary}`;
219
+ });
220
+ const readableText = `Found ${summaries.length} memories:\n\n${lines.join('\n\n')}`;
221
+
222
+ // Return readable text + structured data for programmatic use
223
+ return {
224
+ content: [
225
+ { type: 'text', text: readableText },
226
+ { type: 'text', text: JSON.stringify({
227
+ _ids: summaries.map(r => r.id),
228
+ _meta: { detail: 'summary', total: summaries.length, mode: 'local' },
229
+ }) },
230
+ ],
231
+ };
232
+ }
233
+
234
+ if (effectiveDetail === 'full' && summaries.length > 0) {
235
+ // Full content — return as readable text
236
+ const sections = summaries.map(r => {
237
+ const header = r.title ? `## ${r.title}` : '';
238
+ return `${header}\n\n${r.content || '(no content)'}`;
239
+ });
240
+ return {
241
+ content: [{ type: 'text', text: sections.join('\n\n---\n\n') }],
242
+ };
243
+ }
244
+
245
+ // Fallback / empty results
209
246
  return mcpResult({
210
247
  results: summaries,
211
248
  total: summaries.length,
212
249
  mode: 'local',
213
- detail: params.detail || 'summary',
250
+ detail: effectiveDetail,
214
251
  search_method: 'hybrid',
215
252
  });
216
253
  } catch (err) {
@@ -58,7 +58,7 @@
58
58
  {
59
59
  "name": "awareness_init",
60
60
  "short_desc": "Load cross-session project memory and context",
61
- "long_desc": "Load cross-session project memory. Returns session_id, context (narratives, tasks, knowledge, active_skills), and usage rules. Call ONCE at session start."
61
+ "long_desc": "Load cross-session project memory. Returns session_id, context (narratives, tasks, knowledge, active_skills, attention_summary), and usage rules. Call ONCE at session start. ATTENTION PROTOCOL: When context.attention_summary.needs_attention is true, review all attention items (stale tasks, high risks) and automatically: (1) remind the user about stale tasks, (2) warn about high risks before starting work, (3) update resolved items via awareness_record."
62
62
  },
63
63
  {
64
64
  "name": "awareness_get_agent_prompt",
@@ -385,7 +385,8 @@
385
385
  "notes_lines": [
386
386
  "## OpenClaw Notes",
387
387
  "- OpenClaw uses a plugin-based configuration via ~/.openclaw/openclaw.json.",
388
- "- Install the Awareness plugin: openclaw plugins install @awareness-sdk/openclaw-memory",
388
+ "- Install as plugin: openclaw plugins install @awareness-sdk/openclaw-memory",
389
+ "- Or install as skill: npx clawhub@latest install awareness-memory",
389
390
  "- MCP tools are exposed through the plugin system, no rules file needed."
390
391
  ]
391
392
  }