shieldcortex 3.1.0 → 3.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.
Files changed (58) hide show
  1. package/README.md +16 -0
  2. package/dashboard/.next/standalone/dashboard/.next/BUILD_ID +1 -1
  3. package/dashboard/.next/standalone/dashboard/.next/build-manifest.json +2 -2
  4. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.html +2 -2
  5. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.rsc +1 -1
  6. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/dashboard/.next/standalone/dashboard/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.html +1 -1
  13. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.rsc +2 -2
  14. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  17. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/dashboard/.next/standalone/dashboard/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/dashboard/.next/standalone/dashboard/.next/server/app/index.html +1 -1
  21. package/dashboard/.next/standalone/dashboard/.next/server/app/index.rsc +3 -3
  22. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  23. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_full.segment.rsc +3 -3
  24. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_head.segment.rsc +1 -1
  25. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_index.segment.rsc +2 -2
  26. package/dashboard/.next/standalone/dashboard/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  27. package/dashboard/.next/standalone/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
  28. package/dashboard/.next/standalone/dashboard/.next/server/chunks/ssr/dashboard_3051539d._.js +1 -1
  29. package/dashboard/.next/standalone/dashboard/.next/server/pages/404.html +1 -1
  30. package/dashboard/.next/standalone/dashboard/.next/server/pages/500.html +2 -2
  31. package/dashboard/.next/standalone/dashboard/.next/static/chunks/98e2c181d5c4349f.js +1 -0
  32. package/dashboard/.next/standalone/dashboard/.next/static/chunks/9cb86821c1107fd6.js +9 -0
  33. package/dashboard/.next/standalone/dashboard/.next/static/chunks/a56c497e02afd4ba.css +3 -0
  34. package/dashboard/.next/standalone/dashboard/.next/static/chunks/a90355d73183a5e6.js +1 -0
  35. package/dist/api/routes/memories.js +366 -1
  36. package/dist/api/routes/recall.js +53 -0
  37. package/dist/cloud/graph-sync.js +6 -3
  38. package/dist/cloud/memory-sync.d.ts +1 -0
  39. package/dist/cloud/memory-sync.js +3 -0
  40. package/dist/database/init.js +29 -0
  41. package/dist/memory/search.d.ts +1 -0
  42. package/dist/memory/search.js +4 -0
  43. package/dist/memory/store.js +146 -28
  44. package/dist/memory/types.d.ts +31 -0
  45. package/dist/tools/context.d.ts +4 -4
  46. package/dist/tools/forget.d.ts +4 -4
  47. package/dist/tools/recall.d.ts +8 -8
  48. package/dist/tools/remember.d.ts +19 -4
  49. package/dist/tools/remember.js +17 -1
  50. package/hooks/openclaw/cortex-memory/handler.ts +8 -0
  51. package/package.json +1 -1
  52. package/dashboard/.next/standalone/dashboard/.next/static/chunks/0a69eb25d08447ee.js +0 -1
  53. package/dashboard/.next/standalone/dashboard/.next/static/chunks/3cc7e8d4f73cf5d2.js +0 -1
  54. package/dashboard/.next/standalone/dashboard/.next/static/chunks/97537d3db46c8467.css +0 -3
  55. package/dashboard/.next/standalone/dashboard/.next/static/chunks/aa6e9b8a52353969.js +0 -9
  56. /package/dashboard/.next/standalone/dashboard/.next/static/{RnvqrTXo_jN8SuMdaNcIj → ctp9eCBcHDpTWtUYMwJK7}/_buildManifest.js +0 -0
  57. /package/dashboard/.next/standalone/dashboard/.next/static/{RnvqrTXo_jN8SuMdaNcIj → ctp9eCBcHDpTWtUYMwJK7}/_clientMiddlewareManifest.json +0 -0
  58. /package/dashboard/.next/standalone/dashboard/.next/static/{RnvqrTXo_jN8SuMdaNcIj → ctp9eCBcHDpTWtUYMwJK7}/_ssgManifest.js +0 -0
@@ -1,3 +1,6 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, readdirSync, readFileSync } from 'fs';
1
4
  import { getDatabase } from '../../database/init.js';
2
5
  import { searchMemories, getRecentMemories, getHighPriorityMemories, getMemoryStats, getMemoryById, addMemory, deleteMemory, accessMemory, updateMemory, promoteMemory, createMemoryLink, rowToMemory, enrichMemory, } from '../../memory/store.js';
3
6
  import { calculateDecayedScore } from '../../memory/decay.js';
@@ -6,6 +9,185 @@ import { getActivationStats, getActiveMemories } from '../../memory/activation.j
6
9
  import { detectContradictions, getContradictionsFor } from '../../memory/contradiction.js';
7
10
  import { emitConsolidation } from '../events.js';
8
11
  export function registerMemoryRoutes(app, requireNotLocked) {
12
+ app.get('/api/capture/openclaw/sessions', requireNotLocked, (req, res) => {
13
+ try {
14
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
15
+ const auditDir = join(homedir(), '.shieldcortex', 'audit');
16
+ const db = getDatabase();
17
+ const rows = db.prepare(`
18
+ SELECT * FROM memories
19
+ WHERE source_kind IN ('hook', 'plugin') OR source LIKE 'hook:openclaw%' OR source LIKE 'agent:openclaw-plugin%'
20
+ ORDER BY updated_at DESC
21
+ LIMIT 500
22
+ `).all();
23
+ const openClawMemories = rows.map(rowToMemory);
24
+ const sessionMap = new Map();
25
+ const getSession = (sessionId) => {
26
+ let session = sessionMap.get(sessionId);
27
+ if (!session) {
28
+ session = {
29
+ sessionId,
30
+ firstSeenAt: new Date().toISOString(),
31
+ lastSeenAt: new Date(0).toISOString(),
32
+ storedMemoryCount: 0,
33
+ loggedSaved: 0,
34
+ skipped: 0,
35
+ threats: 0,
36
+ blocked: 0,
37
+ quarantined: 0,
38
+ autoExtracted: 0,
39
+ keywordTriggered: 0,
40
+ pinned: 0,
41
+ suppressed: 0,
42
+ hooks: new Set(),
43
+ models: new Set(),
44
+ agentIds: new Set(),
45
+ memoryIds: new Set(),
46
+ previews: [],
47
+ events: [],
48
+ };
49
+ sessionMap.set(sessionId, session);
50
+ }
51
+ return session;
52
+ };
53
+ for (const memory of openClawMemories) {
54
+ const sessionId = typeof memory.metadata?.sessionId === 'string'
55
+ ? memory.metadata.sessionId
56
+ : memory.source?.startsWith('agent:openclaw-plugin:')
57
+ ? memory.source.slice('agent:openclaw-plugin:'.length)
58
+ : null;
59
+ if (!sessionId)
60
+ continue;
61
+ const session = getSession(sessionId);
62
+ const createdAt = memory.createdAt.toISOString();
63
+ if (createdAt < session.firstSeenAt)
64
+ session.firstSeenAt = createdAt;
65
+ if (createdAt > session.lastSeenAt)
66
+ session.lastSeenAt = createdAt;
67
+ session.storedMemoryCount += 1;
68
+ session.memoryIds.add(memory.id);
69
+ if (typeof memory.metadata?.agentId === 'string')
70
+ session.agentIds.add(memory.metadata.agentId);
71
+ if (memory.captureMethod === 'auto' || memory.captureMethod === 'plugin' || memory.captureMethod === 'hook') {
72
+ session.autoExtracted += 1;
73
+ }
74
+ if (typeof memory.metadata?.trigger === 'string' || memory.tags.includes('keyword_trigger')) {
75
+ session.keywordTriggered += 1;
76
+ }
77
+ if (memory.pinned)
78
+ session.pinned += 1;
79
+ if (memory.status === 'suppressed')
80
+ session.suppressed += 1;
81
+ }
82
+ if (existsSync(auditDir)) {
83
+ const files = readdirSync(auditDir)
84
+ .filter((file) => /^realtime-\d{4}-\d{2}-\d{2}\.jsonl$/.test(file))
85
+ .sort()
86
+ .slice(-14);
87
+ for (const file of files) {
88
+ const raw = readFileSync(join(auditDir, file), 'utf-8');
89
+ for (const line of raw.split('\n')) {
90
+ if (!line.trim())
91
+ continue;
92
+ try {
93
+ const entry = JSON.parse(line);
94
+ const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId : null;
95
+ if (!sessionId)
96
+ continue;
97
+ const session = getSession(sessionId);
98
+ const ts = typeof entry.ts === 'string' ? entry.ts : new Date().toISOString();
99
+ if (ts < session.firstSeenAt)
100
+ session.firstSeenAt = ts;
101
+ if (ts > session.lastSeenAt)
102
+ session.lastSeenAt = ts;
103
+ if (typeof entry.hook === 'string')
104
+ session.hooks.add(entry.hook);
105
+ if (typeof entry.model === 'string')
106
+ session.models.add(entry.model);
107
+ if (entry.type === 'memory') {
108
+ const count = Number(entry.count ?? 0);
109
+ const skipped = Number(entry.skipped ?? 0);
110
+ session.loggedSaved += count;
111
+ session.skipped += skipped;
112
+ session.events.push({
113
+ ts,
114
+ type: 'memory',
115
+ hook: typeof entry.hook === 'string' ? entry.hook : undefined,
116
+ model: typeof entry.model === 'string' ? entry.model : undefined,
117
+ preview: typeof entry.preview === 'string' ? entry.preview : undefined,
118
+ count,
119
+ skipped,
120
+ });
121
+ if (typeof entry.preview === 'string' && session.previews.length < 6) {
122
+ session.previews.push(entry.preview);
123
+ }
124
+ }
125
+ if (entry.type === 'threat') {
126
+ session.threats += 1;
127
+ const preview = typeof entry.preview === 'string' ? entry.preview : undefined;
128
+ const lower = preview?.toLowerCase() ?? '';
129
+ if (lower.includes('quarantine'))
130
+ session.quarantined += 1;
131
+ if (lower.includes('block'))
132
+ session.blocked += 1;
133
+ session.events.push({
134
+ ts,
135
+ type: lower.includes('quarantine') ? 'quarantine' : lower.includes('block') ? 'blocked' : 'threat',
136
+ hook: typeof entry.hook === 'string' ? entry.hook : undefined,
137
+ model: typeof entry.model === 'string' ? entry.model : undefined,
138
+ preview,
139
+ });
140
+ if (preview && session.previews.length < 6) {
141
+ session.previews.push(preview);
142
+ }
143
+ }
144
+ }
145
+ catch {
146
+ // ignore malformed lines
147
+ }
148
+ }
149
+ }
150
+ }
151
+ const sessions = Array.from(sessionMap.values())
152
+ .map((session) => ({
153
+ sessionId: session.sessionId,
154
+ firstSeenAt: session.firstSeenAt,
155
+ lastSeenAt: session.lastSeenAt,
156
+ saved: Math.max(session.storedMemoryCount, session.loggedSaved),
157
+ skipped: session.skipped,
158
+ threats: session.threats,
159
+ blocked: session.blocked,
160
+ quarantined: session.quarantined,
161
+ autoExtracted: session.autoExtracted,
162
+ keywordTriggered: session.keywordTriggered,
163
+ pinned: session.pinned,
164
+ suppressed: session.suppressed,
165
+ hooks: Array.from(session.hooks),
166
+ models: Array.from(session.models),
167
+ agentIds: Array.from(session.agentIds),
168
+ previews: session.previews,
169
+ events: session.events
170
+ .sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
171
+ .slice(0, 24),
172
+ memories: openClawMemories
173
+ .filter((memory) => session.memoryIds.has(memory.id))
174
+ .sort((a, b) => new Date(b.updatedAt ?? b.createdAt).getTime() - new Date(a.updatedAt ?? a.createdAt).getTime())
175
+ .slice(0, 12),
176
+ }))
177
+ .sort((a, b) => new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime())
178
+ .slice(0, limit);
179
+ const summary = {
180
+ sessions: sessions.length,
181
+ saved: sessions.reduce((sum, session) => sum + session.saved, 0),
182
+ skipped: sessions.reduce((sum, session) => sum + session.skipped, 0),
183
+ threats: sessions.reduce((sum, session) => sum + session.threats, 0),
184
+ };
185
+ res.json({ summary, sessions });
186
+ }
187
+ catch (error) {
188
+ res.status(500).json({ error: error.message });
189
+ }
190
+ });
9
191
  app.get('/api/memories', requireNotLocked, async (req, res) => {
10
192
  try {
11
193
  const project = typeof req.query.project === 'string' ? req.query.project : undefined;
@@ -110,11 +292,127 @@ export function registerMemoryRoutes(app, requireNotLocked) {
110
292
  JOIN memories m2 ON m1.title = m2.title AND m1.id < m2.id
111
293
  ${project ? 'WHERE m1.project = ?' : ''}
112
294
  LIMIT 50
295
+ `).all(...params);
296
+ const lowTrust = db.prepare(`
297
+ SELECT id, title, category, project, trust_score, source_kind, capture_method
298
+ FROM memories WHERE trust_score < 0.7 ${projectFilter}
299
+ ORDER BY trust_score ASC, updated_at DESC LIMIT 50
300
+ `).all(...params);
301
+ const noisyAutoExtracted = db.prepare(`
302
+ SELECT id, title, category, project, source_kind, capture_method, tags, trust_score
303
+ FROM memories
304
+ WHERE (capture_method = 'auto' OR tags LIKE '%auto-extracted%') ${projectFilter}
305
+ ORDER BY updated_at DESC LIMIT 50
306
+ `).all(...params);
307
+ const projectless = db.prepare(`
308
+ SELECT id, title, category, scope, source_kind, capture_method
309
+ FROM memories
310
+ WHERE (project IS NULL OR project = '') AND scope != 'global'
311
+ ORDER BY updated_at DESC LIMIT 50
312
+ `).all();
313
+ const statusCounts = db.prepare(`
314
+ SELECT status, COUNT(*) as count
315
+ FROM memories
316
+ ${project ? 'WHERE project = ?' : ''}
317
+ GROUP BY status
113
318
  `).all(...params);
114
319
  res.json({
115
320
  neverAccessed: { count: neverAccessed.length, items: neverAccessed },
116
321
  stale: { count: stale.length, items: stale },
117
322
  duplicates: { count: duplicates.length, items: duplicates },
323
+ lowTrust: { count: lowTrust.length, items: lowTrust },
324
+ noisyAutoExtracted: { count: noisyAutoExtracted.length, items: noisyAutoExtracted },
325
+ projectless: { count: projectless.length, items: projectless },
326
+ status: statusCounts.reduce((acc, row) => {
327
+ acc[row.status] = row.count;
328
+ return acc;
329
+ }, {}),
330
+ });
331
+ }
332
+ catch (error) {
333
+ res.status(500).json({ error: error.message });
334
+ }
335
+ });
336
+ app.get('/api/review/queue', requireNotLocked, (req, res) => {
337
+ try {
338
+ const project = typeof req.query.project === 'string' ? req.query.project : undefined;
339
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
340
+ const db = getDatabase();
341
+ const projectFilter = project ? 'AND project = ?' : '';
342
+ const params = project ? [project] : [];
343
+ const stale = db.prepare(`
344
+ SELECT * FROM memories
345
+ WHERE decayed_score < 0.3 ${projectFilter}
346
+ AND last_accessed < datetime('now', '-30 days')
347
+ ORDER BY decayed_score ASC LIMIT ?
348
+ `).all(...params, limit);
349
+ const neverUsed = db.prepare(`
350
+ SELECT * FROM memories
351
+ WHERE access_count = 0 ${projectFilter}
352
+ AND created_at < datetime('now', '-1 day')
353
+ ORDER BY created_at DESC LIMIT ?
354
+ `).all(...params, limit);
355
+ const lowTrust = db.prepare(`
356
+ SELECT * FROM memories
357
+ WHERE trust_score < 0.7 ${projectFilter}
358
+ ORDER BY trust_score ASC, updated_at DESC LIMIT ?
359
+ `).all(...params, limit);
360
+ const noisyAutoExtracted = db.prepare(`
361
+ SELECT * FROM memories
362
+ WHERE (capture_method = 'auto' OR tags LIKE '%auto-extracted%') ${projectFilter}
363
+ ORDER BY updated_at DESC LIMIT ?
364
+ `).all(...params, limit);
365
+ const projectless = db.prepare(`
366
+ SELECT * FROM memories
367
+ WHERE (project IS NULL OR project = '') AND scope != 'global'
368
+ ORDER BY updated_at DESC LIMIT ?
369
+ `).all(limit);
370
+ const openClawSummary = db.prepare(`
371
+ SELECT
372
+ COUNT(*) as total,
373
+ SUM(CASE WHEN capture_method = 'auto' THEN 1 ELSE 0 END) as auto_count,
374
+ SUM(CASE WHEN tags LIKE '%keyword-trigger%' THEN 1 ELSE 0 END) as keyword_count,
375
+ SUM(CASE WHEN status = 'suppressed' THEN 1 ELSE 0 END) as suppressed_count,
376
+ SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned_count
377
+ FROM memories
378
+ WHERE (source_kind IN ('hook', 'plugin') OR tags LIKE '%openclaw%')
379
+ ${project ? 'AND project = ?' : ''}
380
+ `).get(...params);
381
+ const contradictions = detectContradictions({
382
+ project,
383
+ minScore: 0.4,
384
+ limit,
385
+ });
386
+ res.json({
387
+ summary: {
388
+ stale: stale.length,
389
+ neverUsed: neverUsed.length,
390
+ lowTrust: lowTrust.length,
391
+ noisyAutoExtracted: noisyAutoExtracted.length,
392
+ projectless: projectless.length,
393
+ contradictions: contradictions.length,
394
+ },
395
+ openClaw: {
396
+ total: openClawSummary.total ?? 0,
397
+ autoExtracted: openClawSummary.auto_count ?? 0,
398
+ keywordTriggered: openClawSummary.keyword_count ?? 0,
399
+ suppressed: openClawSummary.suppressed_count ?? 0,
400
+ pinned: openClawSummary.pinned_count ?? 0,
401
+ },
402
+ sections: {
403
+ stale: stale.map(rowToMemory),
404
+ neverUsed: neverUsed.map(rowToMemory),
405
+ lowTrust: lowTrust.map(rowToMemory),
406
+ noisyAutoExtracted: noisyAutoExtracted.map(rowToMemory),
407
+ projectless: projectless.map(rowToMemory),
408
+ contradictions: contradictions.map((item) => ({
409
+ memoryA: item.memoryA,
410
+ memoryB: item.memoryB,
411
+ score: item.score,
412
+ reason: item.reason,
413
+ sharedTopics: item.sharedTopics,
414
+ })),
415
+ },
118
416
  });
119
417
  }
120
418
  catch (error) {
@@ -585,7 +883,7 @@ export function registerMemoryRoutes(app, requireNotLocked) {
585
883
  app.patch('/api/memories/:id', requireNotLocked, (req, res) => {
586
884
  try {
587
885
  const id = parseInt(req.params.id, 10);
588
- const { title, content, category, tags, importance } = req.body;
886
+ const { title, content, category, tags, importance, status, pinned, reviewedBy, cloudExcluded, scope, project } = req.body;
589
887
  if (title !== undefined && (typeof title !== 'string' || title.trim().length === 0)) {
590
888
  return res.status(400).json({ error: 'Title must be a non-empty string' });
591
889
  }
@@ -602,6 +900,24 @@ export function registerMemoryRoutes(app, requireNotLocked) {
602
900
  if (importance !== undefined && (typeof importance !== 'number' || importance < 0 || importance > 1)) {
603
901
  return res.status(400).json({ error: 'Importance must be a number between 0 and 1' });
604
902
  }
903
+ if (status !== undefined && !['active', 'archived', 'suppressed', 'canonical'].includes(status)) {
904
+ return res.status(400).json({ error: 'Invalid review status' });
905
+ }
906
+ if (pinned !== undefined && typeof pinned !== 'boolean') {
907
+ return res.status(400).json({ error: 'Pinned must be boolean' });
908
+ }
909
+ if (cloudExcluded !== undefined && typeof cloudExcluded !== 'boolean') {
910
+ return res.status(400).json({ error: 'cloudExcluded must be boolean' });
911
+ }
912
+ if (reviewedBy !== undefined && reviewedBy !== null && typeof reviewedBy !== 'string') {
913
+ return res.status(400).json({ error: 'reviewedBy must be string or null' });
914
+ }
915
+ if (scope !== undefined && !['project', 'global'].includes(scope)) {
916
+ return res.status(400).json({ error: 'scope must be project or global' });
917
+ }
918
+ if (project !== undefined && project !== null && typeof project !== 'string') {
919
+ return res.status(400).json({ error: 'project must be string or null' });
920
+ }
605
921
  const updates = {};
606
922
  if (title !== undefined)
607
923
  updates.title = title.trim();
@@ -613,6 +929,55 @@ export function registerMemoryRoutes(app, requireNotLocked) {
613
929
  updates.tags = tags;
614
930
  if (importance !== undefined)
615
931
  updates.salience = importance;
932
+ if (status !== undefined)
933
+ updates.status = status;
934
+ if (pinned !== undefined)
935
+ updates.pinned = pinned;
936
+ if (reviewedBy !== undefined)
937
+ updates.reviewedBy = reviewedBy;
938
+ if (cloudExcluded !== undefined)
939
+ updates.cloudExcluded = cloudExcluded;
940
+ if (scope !== undefined)
941
+ updates.scope = scope;
942
+ if (project !== undefined)
943
+ updates.project = project;
944
+ const updated = updateMemory(id, updates);
945
+ if (!updated) {
946
+ return res.status(404).json({ error: 'Memory not found' });
947
+ }
948
+ res.json(updated);
949
+ }
950
+ catch (error) {
951
+ res.status(500).json({ error: error.message });
952
+ }
953
+ });
954
+ app.patch('/api/memories/:id/review', requireNotLocked, (req, res) => {
955
+ try {
956
+ const id = parseInt(req.params.id, 10);
957
+ const { action, reviewedBy, project, scope } = req.body;
958
+ if (Number.isNaN(id)) {
959
+ return res.status(400).json({ error: 'Invalid memory ID' });
960
+ }
961
+ const reviewActor = typeof reviewedBy === 'string' && reviewedBy.trim() ? reviewedBy.trim() : 'dashboard';
962
+ const actionMap = {
963
+ archive: { status: 'archived', reviewedBy: reviewActor },
964
+ suppress: { status: 'suppressed', reviewedBy: reviewActor },
965
+ restore: { status: 'active', reviewedBy: reviewActor },
966
+ pin: { pinned: true, reviewedBy: reviewActor },
967
+ unpin: { pinned: false, reviewedBy: reviewActor },
968
+ canonicalize: { status: 'canonical', pinned: true, reviewedBy: reviewActor },
969
+ excludeCloud: { cloudExcluded: true, reviewedBy: reviewActor },
970
+ includeCloud: { cloudExcluded: false, reviewedBy: reviewActor },
971
+ rescopeProject: { scope: 'project', project: project ?? null, reviewedBy: reviewActor },
972
+ rescopeGlobal: { scope: 'global', project: null, reviewedBy: reviewActor },
973
+ };
974
+ if (!action || !actionMap[action]) {
975
+ return res.status(400).json({ error: 'Unsupported review action' });
976
+ }
977
+ const updates = {
978
+ ...actionMap[action],
979
+ ...(scope ? { scope } : {}),
980
+ };
616
981
  const updated = updateMemory(id, updates);
617
982
  if (!updated) {
618
983
  return res.status(404).json({ error: 'Memory not found' });
@@ -1,4 +1,5 @@
1
1
  import { searchMemoriesExplained } from '../../memory/store.js';
2
+ import { getMemoryById, getRecentMemories } from '../../memory/store.js';
2
3
  export function registerRecallRoutes(app, requireNotLocked) {
3
4
  app.get('/api/recall/explain', requireNotLocked, async (req, res) => {
4
5
  try {
@@ -12,6 +13,9 @@ export function registerRecallRoutes(app, requireNotLocked) {
12
13
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
13
14
  const includeDecayed = req.query.includeDecayed === 'true';
14
15
  const includeGlobal = req.query.includeGlobal !== 'false';
16
+ const includeArchived = req.query.includeArchived === 'true';
17
+ const includeSuppressed = req.query.includeSuppressed === 'true';
18
+ const expectedId = typeof req.query.expectedId === 'string' ? parseInt(req.query.expectedId, 10) : null;
15
19
  const results = await searchMemoriesExplained({
16
20
  query,
17
21
  project,
@@ -20,13 +24,62 @@ export function registerRecallRoutes(app, requireNotLocked) {
20
24
  limit,
21
25
  includeDecayed,
22
26
  includeGlobal,
27
+ includeArchived,
28
+ includeSuppressed,
23
29
  });
30
+ let expectedMemory = null;
31
+ if (expectedId && Number.isFinite(expectedId)) {
32
+ const memory = getMemoryById(expectedId);
33
+ if (memory) {
34
+ const foundIndex = results.findIndex((result) => result.memory.id === expectedId);
35
+ expectedMemory = {
36
+ id: memory.id,
37
+ title: memory.title,
38
+ status: memory.status,
39
+ pinned: memory.pinned,
40
+ cloudExcluded: memory.cloudExcluded,
41
+ trustScore: memory.trustScore,
42
+ captureMethod: memory.captureMethod,
43
+ sourceKind: memory.sourceKind,
44
+ rank: foundIndex >= 0 ? foundIndex + 1 : null,
45
+ eligible: memory.status !== 'archived' && memory.status !== 'suppressed',
46
+ reasons: [
47
+ ...(memory.status === 'archived' ? ['Archived memories are excluded from normal recall'] : []),
48
+ ...(memory.status === 'suppressed' ? ['Suppressed memories are excluded from normal recall'] : []),
49
+ ...(memory.trustScore < 0.7 ? [`Low trust source (${memory.trustScore.toFixed(2)})`] : []),
50
+ ...(memory.cloudExcluded ? ['Excluded from cloud sync'] : []),
51
+ ...(foundIndex === -1 ? ['Did not rank in the current result window'] : []),
52
+ ],
53
+ };
54
+ }
55
+ }
56
+ const misses = getRecentMemories(200, project).filter((memory) => {
57
+ if (results.some((result) => result.memory.id === memory.id))
58
+ return false;
59
+ if (memory.status === 'archived' || memory.status === 'suppressed')
60
+ return false;
61
+ return memory.salience >= 0.65 || memory.pinned;
62
+ }).slice(0, 5).map((memory) => ({
63
+ id: memory.id,
64
+ title: memory.title,
65
+ status: memory.status,
66
+ salience: memory.salience,
67
+ captureMethod: memory.captureMethod,
68
+ sourceKind: memory.sourceKind,
69
+ whyNotRecalled: [
70
+ 'Lower relevance for this query than the returned set',
71
+ ...(memory.pinned ? ['Pinned memories are still query-sensitive, not guaranteed results'] : []),
72
+ ...(memory.trustScore < 0.7 ? [`Low trust source (${memory.trustScore.toFixed(2)})`] : []),
73
+ ],
74
+ }));
24
75
  res.json({
25
76
  query,
26
77
  project: project ?? null,
27
78
  total: results.length,
28
79
  sideEffects: 'disabled',
29
80
  results,
81
+ expectedMemory,
82
+ misses,
30
83
  });
31
84
  }
32
85
  catch (error) {
@@ -19,13 +19,15 @@ function tripleExternalId(id) {
19
19
  }
20
20
  function getAllowedMemoryExternalIds() {
21
21
  const db = getDatabase();
22
- const rows = db.prepare('SELECT uuid, project, sensitivity_level FROM memories').all();
22
+ const rows = db.prepare('SELECT uuid, project, sensitivity_level, cloud_excluded FROM memories').all();
23
23
  return buildAllowedMemoryExternalIdSet(rows);
24
24
  }
25
25
  function buildAllowedMemoryExternalIdSet(rows) {
26
26
  const controls = getCloudSyncControls();
27
27
  return new Set(rows
28
28
  .filter((row) => {
29
+ if (row.cloud_excluded)
30
+ return false;
29
31
  if (!shouldSyncProject(row.project, controls))
30
32
  return false;
31
33
  if (controls.excludeSensitive && isSensitiveLevel(row.sensitivity_level))
@@ -114,11 +116,12 @@ function mapMemoryEntityRow(row) {
114
116
  }
115
117
  function getMemoryScopedEnvelope(memoryId) {
116
118
  const db = getDatabase();
117
- const memory = db.prepare('SELECT id, uuid, project, sensitivity_level FROM memories WHERE id = ?').get(memoryId);
119
+ const memory = db.prepare('SELECT id, uuid, project, sensitivity_level, cloud_excluded FROM memories WHERE id = ?').get(memoryId);
118
120
  if (!memory)
119
121
  return null;
120
122
  const controls = getCloudSyncControls();
121
- const memoryAllowed = shouldSyncProject(memory.project, controls) &&
123
+ const memoryAllowed = !memory.cloud_excluded &&
124
+ shouldSyncProject(memory.project, controls) &&
122
125
  !(controls.excludeSensitive && isSensitiveLevel(memory.sensitivity_level));
123
126
  if (!memoryAllowed) {
124
127
  return buildEnvelope([], [], [], [memory.uuid]);
@@ -15,6 +15,7 @@ export interface SyncedMemoryRecord {
15
15
  sensitivity_level: string | null;
16
16
  source: string | null;
17
17
  metadata: Record<string, unknown>;
18
+ cloud_excluded?: boolean;
18
19
  created_at: string;
19
20
  updated_at: string;
20
21
  deleted_at?: string | null;
@@ -28,6 +28,7 @@ function rowToSyncRecord(row) {
28
28
  sensitivity_level: row.sensitivity_level ?? null,
29
29
  source: row.source ?? null,
30
30
  metadata: safeJsonParse(row.metadata, {}),
31
+ cloud_excluded: Boolean(row.cloud_excluded),
31
32
  created_at: new Date(row.created_at ?? Date.now()).toISOString(),
32
33
  updated_at: new Date(row.updated_at ?? row.created_at ?? Date.now()).toISOString(),
33
34
  deleted_at: null,
@@ -44,6 +45,8 @@ function buildEnvelope(records) {
44
45
  };
45
46
  }
46
47
  function shouldSyncRecord(record) {
48
+ if (record.cloud_excluded)
49
+ return false;
47
50
  const controls = getCloudSyncControls();
48
51
  if (!shouldSyncProject(record.project, controls))
49
52
  return false;
@@ -261,6 +261,27 @@ function runMigrations(database) {
261
261
  if (!columnNames.has('source')) {
262
262
  database.exec("ALTER TABLE memories ADD COLUMN source TEXT DEFAULT 'user:direct'");
263
263
  }
264
+ if (!columnNames.has('status')) {
265
+ database.exec("ALTER TABLE memories ADD COLUMN status TEXT DEFAULT 'active'");
266
+ }
267
+ if (!columnNames.has('pinned')) {
268
+ database.exec('ALTER TABLE memories ADD COLUMN pinned INTEGER DEFAULT 0');
269
+ }
270
+ if (!columnNames.has('reviewed_at')) {
271
+ database.exec('ALTER TABLE memories ADD COLUMN reviewed_at TIMESTAMP');
272
+ }
273
+ if (!columnNames.has('reviewed_by')) {
274
+ database.exec('ALTER TABLE memories ADD COLUMN reviewed_by TEXT');
275
+ }
276
+ if (!columnNames.has('source_kind')) {
277
+ database.exec("ALTER TABLE memories ADD COLUMN source_kind TEXT DEFAULT 'user'");
278
+ }
279
+ if (!columnNames.has('capture_method')) {
280
+ database.exec("ALTER TABLE memories ADD COLUMN capture_method TEXT DEFAULT 'manual'");
281
+ }
282
+ if (!columnNames.has('cloud_excluded')) {
283
+ database.exec('ALTER TABLE memories ADD COLUMN cloud_excluded INTEGER DEFAULT 0');
284
+ }
264
285
  if (!columnNames.has('uuid')) {
265
286
  database.exec("ALTER TABLE memories ADD COLUMN uuid TEXT");
266
287
  }
@@ -274,8 +295,16 @@ function runMigrations(database) {
274
295
  setUuid.run(randomUUID(), row.id);
275
296
  }
276
297
  database.exec('UPDATE memories SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL');
298
+ database.exec("UPDATE memories SET status = COALESCE(status, 'active') WHERE status IS NULL OR status = ''");
299
+ database.exec('UPDATE memories SET pinned = COALESCE(pinned, 0) WHERE pinned IS NULL');
300
+ database.exec("UPDATE memories SET source_kind = CASE WHEN source LIKE 'hook:%' THEN 'hook' WHEN source LIKE 'api:%' THEN 'api' WHEN source LIKE 'agent:%' THEN 'agent' WHEN source LIKE 'cli:%' THEN 'cli' ELSE COALESCE(source_kind, 'user') END WHERE source_kind IS NULL OR source_kind = ''");
301
+ database.exec("UPDATE memories SET capture_method = CASE WHEN tags LIKE '%auto-extracted%' THEN 'auto' WHEN source_kind = 'hook' THEN 'hook' WHEN source_kind = 'api' THEN 'api' WHEN source_kind = 'agent' THEN 'plugin' WHEN source_kind = 'cli' THEN 'manual' ELSE COALESCE(capture_method, 'manual') END WHERE capture_method IS NULL OR capture_method = ''");
302
+ database.exec('UPDATE memories SET cloud_excluded = COALESCE(cloud_excluded, 0) WHERE cloud_excluded IS NULL');
277
303
  database.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_uuid ON memories(uuid)');
278
304
  database.exec('CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at DESC)');
305
+ database.exec('CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status)');
306
+ database.exec('CREATE INDEX IF NOT EXISTS idx_memories_pinned ON memories(pinned DESC)');
307
+ database.exec('CREATE INDEX IF NOT EXISTS idx_memories_source_kind ON memories(source_kind)');
279
308
  }
280
309
  catch {
281
310
  // Safe to ignore on partially migrated databases
@@ -23,6 +23,7 @@ export interface SearchScoreValues {
23
23
  linkBoost: number;
24
24
  tagBoost: number;
25
25
  activationBoost: number;
26
+ contradictionPenalty: number;
26
27
  finalScore: number;
27
28
  }
28
29
  export type MemoryRowConverter = (row: Record<string, unknown>) => Memory;
@@ -118,6 +118,9 @@ export function buildSearchExplanation(memory, context, values) {
118
118
  if (values.activationBoost > 0) {
119
119
  reasons.push('Activated by recent recall activity');
120
120
  }
121
+ if (values.contradictionPenalty > 0) {
122
+ reasons.push('Contradiction risk reduced its rank');
123
+ }
121
124
  if (reasons.length === 0) {
122
125
  reasons.push('Ranked by salience and base recall heuristics');
123
126
  }
@@ -135,6 +138,7 @@ export function buildSearchExplanation(memory, context, values) {
135
138
  linkBoost: values.linkBoost,
136
139
  tagBoost: values.tagBoost,
137
140
  activationBoost: values.activationBoost,
141
+ contradictionPenalty: values.contradictionPenalty,
138
142
  finalScore: values.finalScore,
139
143
  matchedTags,
140
144
  matchedCategory: values.categoryBoost > 0 ? context.detectedCategory : null,