memorix 1.7.3 → 1.8.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 (70) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/dist/cli/index.js +1407 -192
  5. package/dist/dashboard/static/app.js +88 -4
  6. package/dist/dashboard/static/index.html +10 -0
  7. package/dist/dashboard/static/style.css +86 -0
  8. package/dist/index.js +875 -164
  9. package/dist/maintenance-runner.js +607 -68
  10. package/dist/media-audio-runner.js +602 -63
  11. package/dist/media-video-runner.js +607 -68
  12. package/dist/memcode-runtime/CHANGELOG.md +32 -0
  13. package/dist/sdk.js +874 -163
  14. package/dist/types.d.ts +8 -0
  15. package/dist/vector-backfill-runner.js +607 -68
  16. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  17. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +75 -0
  18. package/docs/API_REFERENCE.md +37 -1
  19. package/docs/DASHBOARD.md +10 -0
  20. package/docs/DEVELOPMENT.md +3 -2
  21. package/docs/INTEGRATIONS.md +1 -1
  22. package/docs/README.md +11 -1
  23. package/package.json +1 -1
  24. package/plugins/antigravity/memorix/rules/memorix.md +1 -1
  25. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  26. package/plugins/claude/memorix/SKILL.md +1 -1
  27. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +2 -2
  28. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  29. package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +2 -2
  30. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +2 -2
  32. package/plugins/copilot/memorix/plugin.json +1 -1
  33. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +2 -2
  34. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +2 -2
  35. package/plugins/gemini/memorix/GEMINI.md +1 -1
  36. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  37. package/plugins/omp/memorix/package.json +1 -1
  38. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  39. package/plugins/pi/memorix/package.json +1 -1
  40. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +2 -2
  41. package/server.json +2 -2
  42. package/src/cli/capability-map.ts +2 -0
  43. package/src/cli/commands/context.ts +11 -0
  44. package/src/cli/commands/evidence.ts +56 -0
  45. package/src/cli/commands/feedback.ts +58 -0
  46. package/src/cli/commands/resume.ts +2 -0
  47. package/src/cli/commands/serve-http.ts +201 -12
  48. package/src/cli/fallback-budget.ts +59 -0
  49. package/src/cli/index.ts +5 -1
  50. package/src/compact/engine.ts +26 -1
  51. package/src/config/dotenv-loader.ts +17 -0
  52. package/src/dashboard/config-provenance.ts +17 -0
  53. package/src/dashboard/evidence.ts +63 -0
  54. package/src/dashboard/server.ts +93 -68
  55. package/src/dashboard/static/index.html +10 -0
  56. package/src/dashboard/static/style.css +86 -0
  57. package/src/hooks/installers/index.ts +1 -1
  58. package/src/hooks/official-skills.ts +3 -3
  59. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  60. package/src/memory/feedback-model.ts +4 -0
  61. package/src/memory/feedback.ts +220 -0
  62. package/src/memory/observations.ts +19 -0
  63. package/src/memory/retention.ts +38 -5
  64. package/src/server/mcp-binding-store.ts +79 -0
  65. package/src/server/mcp-diagnostics.ts +40 -0
  66. package/src/server/tool-profile.ts +4 -0
  67. package/src/server.ts +121 -1
  68. package/src/store/evidence-store.ts +261 -0
  69. package/src/store/sqlite-db.ts +104 -0
  70. package/src/types.ts +8 -0
@@ -0,0 +1,58 @@
1
+ import { defineCommand } from 'citty';
2
+ import { MemoryFeedbackStore, type FeedbackCandidateKind, type MemoryFeedbackSignal } from '../../memory/feedback.js';
3
+ import { emitError, emitResult, getCliProjectContext, getCliReadContext } from './operator-shared.js';
4
+
5
+ const signals: MemoryFeedbackSignal[] = ['user-correction', 'verification-success', 'verification-failure', 'code-change', 'used', 'not-used', 'code-conflict', 'strengthen', 'weaken', 'revoke'];
6
+
7
+ export default defineCommand({
8
+ meta: { name: 'feedback', description: 'Record and audit memory feedback' },
9
+ args: {
10
+ action: { type: 'positional', description: 'record, show, or audit', required: false },
11
+ id: { type: 'string', description: 'Candidate id' },
12
+ kind: { type: 'string', description: 'observation, claim, durable-memory, or workflow' },
13
+ signal: { type: 'string', description: 'Feedback signal' },
14
+ source: { type: 'string', description: 'Source reference for the feedback' },
15
+ actor: { type: 'string', description: 'Actor id or human label' },
16
+ note: { type: 'string', description: 'Short audit note' },
17
+ targetEventId: { type: 'string', description: 'Event id to revoke' },
18
+ limit: { type: 'string', description: 'Maximum audit rows' },
19
+ json: { type: 'boolean', description: 'Emit JSON' },
20
+ },
21
+ async run({ args }) {
22
+ const action = (args.action as string | undefined) || 'show';
23
+ const asJson = !!args.json;
24
+ try {
25
+ const context = action === 'show' || action === 'audit' ? await getCliReadContext() : await getCliProjectContext({ searchIndex: false });
26
+ const { project, dataDir } = context;
27
+ const id = String(args.id || '').trim();
28
+ const kind = (String(args.kind || 'observation').trim() || 'observation') as FeedbackCandidateKind;
29
+ if (!id) { emitError('--id is required.', asJson); return; }
30
+ if (!['observation', 'claim', 'durable-memory', 'workflow'].includes(kind)) { emitError('Invalid --kind.', asJson); return; }
31
+ const store = new MemoryFeedbackStore();
32
+ await store.init(dataDir);
33
+ if (action === 'show') {
34
+ const result = store.getState(project.id, kind, id);
35
+ emitResult({ project, result }, JSON.stringify(result, null, 2), asJson);
36
+ return;
37
+ }
38
+ if (action === 'audit') {
39
+ const result = store.audit(project.id, kind, id, Number(args.limit || 100));
40
+ emitResult({ project, result }, JSON.stringify(result, null, 2), asJson);
41
+ return;
42
+ }
43
+ if (action !== 'record') { emitError('action must be record, show, or audit.', asJson); return; }
44
+ const signal = String(args.signal || '').trim() as MemoryFeedbackSignal;
45
+ const sourceRef = String(args.source || '').trim();
46
+ if (!signals.includes(signal) || !sourceRef) { emitError(`record requires --signal (${signals.join(', ')}) and --source.`, asJson); return; }
47
+ const result = store.record({
48
+ projectId: project.id, candidateKind: kind, candidateId: id, signal, sourceRef,
49
+ ...(args.actor ? { actor: String(args.actor) } : {}),
50
+ ...(args.note ? { note: String(args.note) } : {}),
51
+ ...(args.targetEventId ? { targetEventId: String(args.targetEventId) } : {}),
52
+ });
53
+ emitResult({ project, result }, `Recorded ${signal} for ${kind}:${id}; weight=${result.state.weight.toFixed(2)}.`, asJson);
54
+ } catch (error) {
55
+ emitError(error instanceof Error ? error.message : String(error), asJson);
56
+ }
57
+ },
58
+ });
@@ -16,6 +16,7 @@ export default defineCommand({
16
16
  agent: { type: 'string', description: 'Optional target agent for compatible workflow selection' },
17
17
  json: { type: 'boolean', description: 'Emit machine-readable JSON output' },
18
18
  briefJson: { type: 'boolean', description: 'Emit only the bounded agent brief and receipt JSON' },
19
+ fallback: { type: 'boolean', description: 'Mark this as the one-shot MCP-unavailable fallback for the task' },
19
20
  },
20
21
  async run({ args }) {
21
22
  await contextCommand.run?.({
@@ -26,6 +27,7 @@ export default defineCommand({
26
27
  agent: args.agent,
27
28
  json: args.json,
28
29
  briefJson: args.briefJson,
30
+ fallback: args.fallback,
29
31
  resume: true,
30
32
  },
31
33
  rawArgs: [],
@@ -26,6 +26,7 @@ import { resolveToolProfile } from '../../server/tool-profile.js';
26
26
  import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
27
27
  import { projectObservationRetention, summarizeRetentionProjections } from '../../memory/retention.js';
28
28
  import { canManageObservation, filterReadableObservations } from '../../memory/visibility.js';
29
+ import { CURRENT_MCP_PROTOCOL_VERSION, MCP_SERVER_NAME, getMcpDiagnostics } from '../../server/mcp-diagnostics.js';
29
30
  import { parseTcpPortOrReport } from '../port.js';
30
31
  import { mcpFileUriToPath } from '../mcp-root-path.js';
31
32
 
@@ -36,6 +37,8 @@ import { mcpFileUriToPath } from '../mcp-root-path.js';
36
37
  */
37
38
  export const DEFAULT_SESSION_TIMEOUT_MS = 12 * 60 * 60 * 1000;
38
39
  export const EXPIRED_SESSION_TTL_MS = 10 * 60 * 1000;
40
+ /** Pinned SDK protocol accepted internally while the HTTP boundary speaks the current stateless contract. */
41
+ export const MCP_SDK_COMPAT_PROTOCOL_VERSION = '2025-11-25';
39
42
 
40
43
  export function parseSessionTimeoutMs(raw: string | undefined): number {
41
44
  const value = raw?.trim();
@@ -116,6 +119,11 @@ export default defineCommand({
116
119
  const { findGitInSubdirs } = await import('../../project/detector.js');
117
120
  const { writeFileSync, mkdirSync } = await import('node:fs');
118
121
  const { homedir } = await import('node:os');
122
+ // Keep SQLite-backed control-plane helpers lazy. Besides reducing the
123
+ // handshake import graph, this prevents agent integration tests that mock
124
+ // node:fs from being evaluated before their mock factories initialize.
125
+ const { McpBindingStore } = await import('../../server/mcp-binding-store.js');
126
+ const { MemoryFeedbackStore } = await import('../../memory/feedback.js');
119
127
 
120
128
  // The [server] config section feeds startup defaults; CLI flags win.
121
129
  let configuredDefaults: ServeHttpConfiguredDefaults = {};
@@ -176,11 +184,12 @@ export default defineCommand({
176
184
  return store;
177
185
  }
178
186
 
179
- // Bootstrap the initial project's TeamStore. Never open SQLite at $HOME
180
- // (that creates a decoy ~/memorix.db). Unresolved launches share ~/.memorix/data.
181
- const detectedProj = serveResolution.detectedProject ?? detectorMod.detectProject(projectRoot);
182
- const teamDataDir = await persistMod.getProjectDataDir(detectedProj?.id ?? '__unresolved__');
183
- const sharedTeamStore = await getTeamStore(teamDataDir);
187
+ // TeamStore is resolved per MCP session/project. Sharing one mutable
188
+ // singleton here made project A's coordination state visible to project B
189
+ // after a roots switch. The cache still reuses the same project's SQLite
190
+ // handle without crossing project data directories.
191
+ const statelessBindingStore = new McpBindingStore();
192
+ await statelessBindingStore.init(persistMod.getBaseDataDir());
184
193
 
185
194
  type SessionState = {
186
195
  transport: InstanceType<typeof StreamableHTTPServerTransport>;
@@ -396,8 +405,95 @@ export default defineCommand({
396
405
  // No Access-Control-Allow-Origin at all for disallowed origins —
397
406
  // browser will block the response (fail-closed).
398
407
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
399
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Last-Event-Id');
400
- res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
408
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Project-Handle, Mcp-Project-Root, Mcp-Stateless, Last-Event-Id');
409
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, Mcp-Project-Handle, Mcp-Protocol-Version, Mcp-Method, Mcp-Name');
410
+ res.setHeader('Mcp-Protocol-Version', req.headers['mcp-protocol-version'] || CURRENT_MCP_PROTOCOL_VERSION);
411
+ res.setHeader('Mcp-Method', req.method || 'UNKNOWN');
412
+ res.setHeader('Mcp-Name', MCP_SERVER_NAME);
413
+ }
414
+
415
+ /** Handle one 2026 stateless MCP request with a fresh server/transport. */
416
+ async function handleStatelessPost(req: IncomingMessage, res: ServerResponse, body: unknown): Promise<void> {
417
+ const handleId = typeof req.headers['mcp-project-handle'] === 'string'
418
+ ? req.headers['mcp-project-handle']
419
+ : undefined;
420
+ const persisted = handleId ? statelessBindingStore.touch(handleId) : undefined;
421
+ if (handleId && !persisted) {
422
+ res.writeHead(404, { 'Content-Type': 'application/json' });
423
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'MCP project handle not found or expired; initialize again.' }, id: null }));
424
+ return;
425
+ }
426
+ const requestedRoot = typeof req.headers['mcp-project-root'] === 'string'
427
+ ? req.headers['mcp-project-root']
428
+ : undefined;
429
+ const root = persisted?.projectRoot ?? requestedRoot ?? projectRoot;
430
+ if (!persisted && !isInitializeRequest(body)) {
431
+ res.writeHead(400, { 'Content-Type': 'application/json' });
432
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Stateless MCP requests require Mcp-Project-Handle; initialize first.' }, id: null }));
433
+ return;
434
+ }
435
+
436
+ const { createMemorixServer } = await import('../../server.js');
437
+ const { createProjectBindingController } = await import('../../server/request-context.js');
438
+ const binding = createProjectBindingController(root);
439
+ const { server, projectId, handleTransportClose } = await createMemorixServer(
440
+ root,
441
+ undefined,
442
+ undefined,
443
+ {
444
+ allowUntrackedFallback: false,
445
+ deferProjectInitUntilBound: true,
446
+ deferProjectRuntimeInit: true,
447
+ dashboardMode: 'control-plane',
448
+ dashboardPort: port,
449
+ toolProfile,
450
+ projectBinding: binding,
451
+ },
452
+ );
453
+ const transport = new StreamableHTTPServerTransport({
454
+ // No Mcp-Session-Id: the protocol state is carried by our explicit
455
+ // durable project handle and reconstructed on every request.
456
+ sessionIdGenerator: undefined,
457
+ enableJsonResponse: true,
458
+ });
459
+ try {
460
+ await server.connect(transport);
461
+ let effectiveHandle = persisted;
462
+ if (!effectiveHandle && projectId !== '__unresolved__') {
463
+ effectiveHandle = statelessBindingStore.create({
464
+ projectId,
465
+ projectRoot: root,
466
+ dataDir: await persistMod.getProjectDataDir(projectId),
467
+ });
468
+ }
469
+ if (effectiveHandle) res.setHeader('Mcp-Project-Handle', effectiveHandle.handleId);
470
+ // The 1.30 SDK predates the 2026-07-28 version string. Keep the
471
+ // external stateless headers at the current contract, but normalize
472
+ // only the SDK-facing request and initialize params to its newest
473
+ // accepted wire version. This is a real compatibility adapter, not a
474
+ // claim that the pinned SDK implements newer optional extensions.
475
+ const sdkBody = body && typeof body === 'object'
476
+ ? JSON.parse(JSON.stringify(body)) as Record<string, any>
477
+ : body;
478
+ const sdkRequest = sdkBody as Record<string, any> | undefined;
479
+ if (sdkRequest && typeof sdkRequest === 'object' && sdkRequest.method === 'initialize' && sdkRequest.params) {
480
+ sdkRequest.params.protocolVersion = MCP_SDK_COMPAT_PROTOCOL_VERSION;
481
+ }
482
+ if (req.headers['mcp-protocol-version']) {
483
+ req.headers['mcp-protocol-version'] = MCP_SDK_COMPAT_PROTOCOL_VERSION;
484
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
485
+ if (req.rawHeaders[i].toLowerCase() === 'mcp-protocol-version') {
486
+ req.rawHeaders[i + 1] = MCP_SDK_COMPAT_PROTOCOL_VERSION;
487
+ break;
488
+ }
489
+ }
490
+ }
491
+ patchMcpResponse(res);
492
+ await transport.handleRequest(req, res, sdkBody);
493
+ } finally {
494
+ handleTransportClose();
495
+ await transport.close().catch(() => undefined);
496
+ }
401
497
  }
402
498
 
403
499
  /**
@@ -407,6 +503,14 @@ export default defineCommand({
407
503
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
408
504
  const body = await parseBody(req);
409
505
 
506
+ const requestedProtocol = req.headers['mcp-protocol-version'];
507
+ const statelessRequested = req.headers['mcp-stateless'] === 'true'
508
+ || requestedProtocol === CURRENT_MCP_PROTOCOL_VERSION;
509
+ if (!sessionId && statelessRequested) {
510
+ await handleStatelessPost(req, res, body);
511
+ return;
512
+ }
513
+
410
514
  if (sessionId && sessions.has(sessionId)) {
411
515
  // Existing session — route to its transport
412
516
  sessionLastActivity.set(sessionId, Date.now());
@@ -464,7 +568,7 @@ export default defineCommand({
464
568
  const { server, switchProject, handleTransportClose } = await createMemorixServer(
465
569
  projectRoot,
466
570
  undefined,
467
- { teamStore: sharedTeamStore },
571
+ undefined,
468
572
  {
469
573
  allowUntrackedFallback: false,
470
574
  deferProjectInitUntilBound: true,
@@ -660,6 +764,20 @@ export default defineCommand({
660
764
  );
661
765
  }
662
766
 
767
+ async function loadProjectFeedback(dataDir: string, projectId: string): Promise<Map<string, { weight: number; status: any }>> {
768
+ const result = new Map<string, { weight: number; status: any }>();
769
+ try {
770
+ const feedback = new MemoryFeedbackStore();
771
+ await feedback.init(dataDir);
772
+ for (const state of feedback.listStates(projectId, 'observation')) {
773
+ result.set(state.candidateId, { weight: state.weight, status: state.status });
774
+ }
775
+ } catch {
776
+ // Older stores without the 1.8 tables retain the pre-feedback posture.
777
+ }
778
+ return result;
779
+ }
780
+
663
781
  // Resolve static directory (dist/dashboard/static)
664
782
  const cliDir = pathModule.default.dirname(fileURLToPath(import.meta.url));
665
783
  const dashStaticDir = pathModule.default.join(cliDir, '..', 'dashboard', 'static');
@@ -727,10 +845,53 @@ export default defineCommand({
727
845
  return;
728
846
  }
729
847
 
848
+ const maintenanceMatch = apiPath.match(/^\/maintenance\/(cleanup|deduplicate|consolidate|retention)\/(preview|execute)$/);
849
+ if (maintenanceMatch) {
850
+ if (req.method !== 'POST') {
851
+ sendJson({ error: 'Maintenance actions require POST.' }, 405);
852
+ return;
853
+ }
854
+ const bodyValue = await parseBody(req);
855
+ const body = bodyValue && typeof bodyValue === 'object' && !Array.isArray(bodyValue)
856
+ ? bodyValue as Record<string, unknown>
857
+ : {};
858
+ const { projectId: maintenanceProjectId, dataDir: maintenanceDataDir } = await resolveRequestProject(url);
859
+ const maintenanceStore = await getDashboardObservationStore(maintenanceDataDir);
860
+ const context = {
861
+ dataDir: maintenanceDataDir,
862
+ projectId: maintenanceProjectId,
863
+ projectRoot: maintenanceProjectId === defaultProject.id ? projectRoot : null,
864
+ store: maintenanceStore,
865
+ };
866
+ const maintenance = await import('../../dashboard/maintenance.js');
867
+ const action = maintenanceMatch[1];
868
+ const phase = maintenanceMatch[2];
869
+ let result: unknown;
870
+ if (action === 'cleanup') {
871
+ result = phase === 'preview'
872
+ ? await maintenance.previewCleanup(context, body.includeNoise === true)
873
+ : await maintenance.executeCleanup(context, body.payload, body.token);
874
+ } else if (action === 'deduplicate') {
875
+ result = phase === 'preview'
876
+ ? await maintenance.previewDeduplicate(context)
877
+ : await maintenance.executeDeduplicate(context, body.payload, body.token);
878
+ } else if (action === 'consolidate') {
879
+ result = phase === 'preview'
880
+ ? await maintenance.previewConsolidate(context)
881
+ : await maintenance.executeConsolidate(context, body.payload, body.token);
882
+ } else {
883
+ result = phase === 'preview'
884
+ ? await maintenance.previewRetentionArchive(context)
885
+ : await maintenance.executeRetentionArchive(context, body.payload, body.token);
886
+ }
887
+ sendJson(result);
888
+ return;
889
+ }
890
+
730
891
  if (apiPath === '/team') {
731
892
  // Phase 4a: All team state is in SQLite via TeamStore
732
- const { projectId: teamProjectId } = await resolveRequestProject(url);
733
- const ts = sharedTeamStore;
893
+ const { projectId: teamProjectId, dataDir: teamDataDir } = await resolveRequestProject(url);
894
+ const ts = await getTeamStore(teamDataDir);
734
895
  const scope = url.searchParams.get('scope') || 'project';
735
896
 
736
897
  // Determine effective project ID for query
@@ -847,6 +1008,7 @@ export default defineCommand({
847
1008
  const graph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
848
1009
 
849
1010
  const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active');
1011
+ const feedback = await loadProjectFeedback(statsDataDir, statsProjectId);
850
1012
  const statsStore = await getDashboardObservationStore(statsDataDir);
851
1013
  const nextId = await statsStore.loadIdCounter();
852
1014
  const typeCounts: Record<string, number> = {};
@@ -887,7 +1049,10 @@ export default defineCommand({
887
1049
  const retention = summarizeRetentionProjections(
888
1050
  observations
889
1051
  .filter((observation) => observation.type !== 'probe')
890
- .map((observation) => projectObservationRetention(observation, { referenceTime: new Date(now) })),
1052
+ .map((observation) => projectObservationRetention(observation, {
1053
+ referenceTime: new Date(now),
1054
+ feedback: feedback.get(String(observation.id)),
1055
+ })),
891
1056
  );
892
1057
  const retentionSummary = {
893
1058
  active: retention.active,
@@ -1006,6 +1171,23 @@ export default defineCommand({
1006
1171
  return;
1007
1172
  }
1008
1173
 
1174
+ if (apiPath === '/evidence') {
1175
+ const { projectId: evidenceProjectId, dataDir: evidenceDataDir } = await resolveRequestProject(url);
1176
+ const { EvidenceCardStore } = await import('../../store/evidence-store.js');
1177
+ const evidenceStore = new EvidenceCardStore();
1178
+ await evidenceStore.init(evidenceDataDir);
1179
+ const observations = await loadDashboardProjectObservations(evidenceDataDir, evidenceProjectId, 'active');
1180
+ evidenceStore.syncObservations(observations);
1181
+ const query = (url.searchParams.get('q') || '').trim().toLowerCase();
1182
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || 20)));
1183
+ const cards = evidenceStore.list(evidenceProjectId, { limit: 100 })
1184
+ .filter((card) => !query || [card.title, card.summary, card.sourceRef, card.locator ?? ''].join(' ').toLowerCase().includes(query))
1185
+ .slice(0, limit)
1186
+ .map((card) => ({ ...card, events: evidenceStore.listEvents(card.id, 8) }));
1187
+ sendJson({ projectId: evidenceProjectId, cards });
1188
+ return;
1189
+ }
1190
+
1009
1191
  if (apiPath === '/graph') {
1010
1192
  const { projectId: graphProjectId, dataDir: graphDataDir } = await resolveRequestProject(url);
1011
1193
  const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
@@ -1030,12 +1212,13 @@ export default defineCommand({
1030
1212
  if (apiPath === '/retention') {
1031
1213
  const { projectId: retProjectId, dataDir: retDataDir } = await resolveRequestProject(url);
1032
1214
  const observations = await loadDashboardProjectObservations(retDataDir, retProjectId, 'active');
1215
+ const feedback = await loadProjectFeedback(retDataDir, retProjectId);
1033
1216
  const referenceTime = new Date();
1034
1217
  const rows = observations
1035
1218
  .filter((observation) => observation.type !== 'probe')
1036
1219
  .map((observation) => ({
1037
1220
  observation,
1038
- retention: projectObservationRetention(observation, { referenceTime }),
1221
+ retention: projectObservationRetention(observation, { referenceTime, feedback: feedback.get(String(observation.id)) }),
1039
1222
  }))
1040
1223
  .sort((a, b) => b.retention.displayScore - a.retention.displayScore);
1041
1224
  const summary = summarizeRetentionProjections(rows.map((row) => row.retention));
@@ -1499,6 +1682,12 @@ export default defineCommand({
1499
1682
  return;
1500
1683
  }
1501
1684
 
1685
+ if (url.pathname === '/protocol-diagnostics') {
1686
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' });
1687
+ res.end(JSON.stringify(getMcpDiagnostics()));
1688
+ return;
1689
+ }
1690
+
1502
1691
  // MCP endpoint
1503
1692
  if (url.pathname === '/mcp') {
1504
1693
  // Ensure Accept header includes both application/json and text/event-stream.
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getDatabase } from '../store/sqlite-db.js';
3
+
4
+ export const DEFAULT_FALLBACK_TTL_MS = 10 * 60 * 1000;
5
+
6
+ export interface FallbackClaim {
7
+ allowed: boolean;
8
+ key: string;
9
+ reason?: string;
10
+ count: number;
11
+ }
12
+
13
+ export function fallbackRequestKey(projectId: string, task: string | undefined): string {
14
+ const normalized = (task ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
15
+ return createHash('sha256').update(`${projectId}\n${normalized}`).digest('hex').slice(0, 32);
16
+ }
17
+
18
+ /**
19
+ * Durable one-shot fallback guard. A failed MCP discovery must not turn into a
20
+ * loop of help/search/status probes; one bounded context request is enough.
21
+ */
22
+ export class CliFallbackBudget {
23
+ private db: any = null;
24
+
25
+ async init(dataDir: string): Promise<void> {
26
+ this.db = getDatabase(dataDir);
27
+ this.db.exec(`
28
+ CREATE TABLE IF NOT EXISTS cli_fallback_attempts (
29
+ project_id TEXT NOT NULL,
30
+ request_key TEXT NOT NULL,
31
+ count INTEGER NOT NULL DEFAULT 0,
32
+ last_attempt_at INTEGER NOT NULL,
33
+ expires_at INTEGER NOT NULL,
34
+ PRIMARY KEY (project_id, request_key)
35
+ )
36
+ `);
37
+ }
38
+
39
+ claim(projectId: string, task: string | undefined, now = Date.now(), ttlMs = DEFAULT_FALLBACK_TTL_MS): FallbackClaim {
40
+ if (!this.db) throw new Error('CliFallbackBudget is not initialized.');
41
+ const key = fallbackRequestKey(projectId, task);
42
+ const current = this.db.prepare(`
43
+ SELECT count, expires_at FROM cli_fallback_attempts WHERE project_id = ? AND request_key = ?
44
+ `).get(projectId, key);
45
+ const count = Number(current?.count ?? 0);
46
+ if (current && Number(current.expires_at) > now && count >= 1) {
47
+ return { allowed: false, key, count, reason: 'Fallback budget exhausted for this task. Stop probing and continue with the current project files.' };
48
+ }
49
+ this.db.prepare(`
50
+ INSERT INTO cli_fallback_attempts (project_id, request_key, count, last_attempt_at, expires_at)
51
+ VALUES (?, ?, 1, ?, ?)
52
+ ON CONFLICT(project_id, request_key) DO UPDATE SET
53
+ count = CASE WHEN excluded.expires_at > cli_fallback_attempts.expires_at THEN 1 ELSE cli_fallback_attempts.count + 1 END,
54
+ last_attempt_at = excluded.last_attempt_at,
55
+ expires_at = CASE WHEN excluded.expires_at > cli_fallback_attempts.expires_at THEN excluded.expires_at ELSE cli_fallback_attempts.expires_at END
56
+ `).run(projectId, key, now, now + Math.max(1_000, ttlMs));
57
+ return { allowed: true, key, count: 1 };
58
+ }
59
+ }
package/src/cli/index.ts CHANGED
@@ -248,6 +248,8 @@ const main = defineCommand({
248
248
  codegraph: () => import('./commands/codegraph.js').then(m => m.default),
249
249
  checkpoint: () => import('./commands/checkpoint.js').then(m => m.default),
250
250
  knowledge: () => import('./commands/knowledge.js').then(m => m.default),
251
+ evidence: () => import('./commands/evidence.js').then(m => m.default),
252
+ feedback: () => import('./commands/feedback.js').then(m => m.default),
251
253
  reasoning: () => import('./commands/reasoning.js').then(m => m.default),
252
254
  retention: () => import('./commands/retention.js').then(m => m.default),
253
255
  formation: () => import('./commands/formation.js').then(m => m.default),
@@ -318,7 +320,7 @@ const main = defineCommand({
318
320
  const firstArg = process.argv[2];
319
321
  const knownSubs = ['ask', 'search', 'remember', 'recent', 'help', 'workbench', 'memcode', 'config',
320
322
  'init', 'setup', 'integrate', 'memory', 'context', 'resume', 'explain', 'codegraph', 'checkpoint', 'knowledge', 'reasoning', 'retention', 'formation', 'audit', 'transfer', 'skills', 'identity',
321
- 'session', 'team', 'task', 'message', 'lock', 'handoff', 'poll',
323
+ 'session', 'team', 'task', 'message', 'lock', 'handoff', 'poll', 'evidence', 'feedback',
322
324
  'receipt',
323
325
  'serve', 'serve-http', 'status', 'sync',
324
326
  'hook', 'hooks', 'ingest', 'media', 'git-hook', 'git-hook-uninstall',
@@ -362,6 +364,8 @@ const main = defineCommand({
362
364
  console.error(' codegraph Refresh/status/context-pack for CodeGraph Memory');
363
365
  console.error(' checkpoint Inspect native compact continuity checkpoints');
364
366
  console.error(' knowledge Review source-backed knowledge pages and project workflows');
367
+ console.error(' evidence Inspect source-backed evidence cards');
368
+ console.error(' feedback Record or audit memory feedback');
365
369
  console.error(' reasoning Store/search decision rationale');
366
370
  console.error(' retention Inspect stale/archive status');
367
371
  console.error(' formation Inspect Memory Formation metrics');
@@ -22,6 +22,8 @@ import { getMiniSkillStore } from '../store/mini-skill-store.js';
22
22
  import { miniSkillToDocument, resolveProvenanceStatus, type ProvenanceStatus } from '../skills/mini-skills.js';
23
23
  import { redactCredentials } from '../memory/secret-filter.js';
24
24
  import { canReadObservation, filterReadableObservations } from '../memory/visibility.js';
25
+ import { getProjectDataDir } from '../store/persistence.js';
26
+ import { MemoryFeedbackStore, feedbackWeightMultiplier } from '../memory/feedback.js';
25
27
 
26
28
  export function normalizeMemoryBrowseQuery(query: string): string {
27
29
  const trimmed = query.trim();
@@ -71,9 +73,32 @@ export async function compactSearch(options: SearchOptions, surface: RetrievalSu
71
73
  }> {
72
74
  await ensureFreshIndex();
73
75
  const searchOptions = { ...options, query: normalizeMemoryBrowseQuery(options.query) };
74
- const entries = (await searchObservations(searchOptions)).map((entry) =>
76
+ let entries = (await searchObservations(searchOptions)).map((entry) =>
75
77
  entry.projectId || !options.projectId ? entry : { ...entry, projectId: options.projectId },
76
78
  );
79
+ if (options.projectId) {
80
+ try {
81
+ const feedback = new MemoryFeedbackStore();
82
+ await feedback.init(await getProjectDataDir(options.projectId));
83
+ const states = feedback.weights(options.projectId, 'observation');
84
+ entries = entries
85
+ .map((entry) => {
86
+ const state = states.get(String(entry.id));
87
+ if (!state) return entry;
88
+ return {
89
+ ...entry,
90
+ score: (entry.score ?? 0) * feedbackWeightMultiplier(state.weight),
91
+ feedbackWeight: state.weight,
92
+ feedbackStatus: state.status,
93
+ };
94
+ })
95
+ .filter((entry) => entry.feedbackStatus !== 'archived' || options.status === 'all')
96
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
97
+ } catch {
98
+ // Feedback is an enrichment lane. Retrieval remains available if an old
99
+ // local database has not yet run the 1.8 schema migration.
100
+ }
101
+ }
77
102
  let formatted = formatIndexTable(entries, searchOptions.query, !options.projectId, surface);
78
103
 
79
104
  if (entries.length === 0 && options.projectId) {
@@ -31,6 +31,8 @@ let dotenvProjectRoot: string | null = null;
31
31
 
32
32
  /** Track which .env files were loaded (for diagnostics) */
33
33
  const loadedEnvFiles: string[] = [];
34
+ const loadedEnvKeys = new Set<string>();
35
+ const loadedEnvValues = new Map<string, string>();
34
36
  /** Track keys injected by .env so project switches can cleanly restore process.env */
35
37
  const injectedKeys = new Set<string>();
36
38
 
@@ -40,6 +42,8 @@ function loadEnvFile(filePath: string): void {
40
42
  const parsed = parse(readFileSync(filePath, 'utf-8'));
41
43
  for (const [key, value] of Object.entries(parsed)) {
42
44
  if (!(key in process.env)) {
45
+ loadedEnvKeys.add(key);
46
+ loadedEnvValues.set(key, value);
43
47
  process.env[key] = value;
44
48
  injectedKeys.add(key);
45
49
  }
@@ -68,6 +72,8 @@ export function loadDotenv(projectRoot?: string, options: DotenvLoadOptions = {}
68
72
  }
69
73
 
70
74
  loadedEnvFiles.length = 0;
75
+ loadedEnvKeys.clear();
76
+ loadedEnvValues.clear();
71
77
 
72
78
  // Loading order = priority order (with override: false, first value wins).
73
79
  // System env vars already exist in process.env, so they always win.
@@ -96,6 +102,7 @@ export function resetDotenv(): void {
96
102
  dotenvLoaded = false;
97
103
  dotenvProjectRoot = null;
98
104
  loadedEnvFiles.length = 0;
105
+ loadedEnvKeys.clear();
99
106
  }
100
107
 
101
108
  /**
@@ -105,6 +112,16 @@ export function getLoadedEnvFiles(): readonly string[] {
105
112
  return loadedEnvFiles;
106
113
  }
107
114
 
115
+ /** Get names found in loaded .env files without exposing their values. */
116
+ export function getLoadedEnvKeys(): ReadonlySet<string> {
117
+ return loadedEnvKeys;
118
+ }
119
+
120
+ /** Get the value injected by .env, if the current process still has that value. */
121
+ export function getLoadedEnvValue(key: string): string | undefined {
122
+ return loadedEnvValues.get(key);
123
+ }
124
+
108
125
  // ─── Supported .env variables ───
109
126
  // These are the ONLY variables Memorix reads from .env.
110
127
  // All are secrets or endpoint URLs — no behavior config.
@@ -0,0 +1,17 @@
1
+ import { getLoadedEnvValue } from '../config/dotenv-loader.js';
2
+
3
+ export interface DashboardKeySourceOptions {
4
+ value: string;
5
+ envKeys: readonly string[];
6
+ configSource: string;
7
+ }
8
+
9
+ /** Resolve a secret source using the ordered environment fallback candidates. */
10
+ export function resolveDashboardKeySource({ value, envKeys, configSource }: DashboardKeySourceOptions): string {
11
+ for (const envKey of envKeys) {
12
+ const envValue = process.env[envKey];
13
+ if (envValue !== value) continue;
14
+ return getLoadedEnvValue(envKey) === envValue ? '.env' : `env:${envKey}`;
15
+ }
16
+ return configSource;
17
+ }
@@ -0,0 +1,63 @@
1
+ import type { Entity, Relation, Observation } from '../types.js';
2
+
3
+ export interface EvidenceCard {
4
+ id: number;
5
+ title: string;
6
+ type: string;
7
+ entityName: string;
8
+ why: string;
9
+ source: string;
10
+ sessionId?: string;
11
+ createdAt: string;
12
+ updatedAt?: string;
13
+ status: string;
14
+ verification: 'unverified' | 'verified' | 'conflicted';
15
+ files: string[];
16
+ relatedEntities: string[];
17
+ correctionHistory: string[];
18
+ }
19
+
20
+ function relationNeighbors(entityName: string, relations: Relation[]): string[] {
21
+ return [...new Set(relations.flatMap((relation) => {
22
+ if (relation.from === entityName) return [relation.to];
23
+ if (relation.to === entityName) return [relation.from];
24
+ return [];
25
+ }))].slice(0, 8);
26
+ }
27
+
28
+ /** Build bounded, operator-readable evidence cards from persisted records. */
29
+ export function buildEvidenceCards(
30
+ observations: Observation[],
31
+ graph: { entities: Entity[]; relations: Relation[] },
32
+ query = '',
33
+ limit = 20,
34
+ ): EvidenceCard[] {
35
+ const needle = query.trim().toLowerCase();
36
+ return observations
37
+ .filter((observation) => !needle || [observation.title, observation.narrative, observation.entityName, ...(observation.concepts ?? [])]
38
+ .join(' ').toLowerCase().includes(needle))
39
+ .sort((a, b) => b.id - a.id)
40
+ .slice(0, Math.max(1, Math.min(100, limit)))
41
+ .map((observation) => {
42
+ const neighbors = relationNeighbors(observation.entityName, graph.relations);
43
+ const entity = graph.entities.find((candidate) => candidate.name === observation.entityName);
44
+ const verification = observation.status === 'resolved' ? 'verified' :
45
+ observation.status === 'archived' ? 'conflicted' : 'unverified';
46
+ return {
47
+ id: observation.id,
48
+ title: observation.title,
49
+ type: observation.type,
50
+ entityName: observation.entityName,
51
+ why: entity ? 'Matched persisted entity and project-scoped evidence.' : 'Matched project-scoped memory record.',
52
+ source: observation.sourceDetail || observation.source || 'unknown',
53
+ ...(observation.sessionId ? { sessionId: observation.sessionId } : {}),
54
+ createdAt: observation.createdAt,
55
+ ...(observation.updatedAt ? { updatedAt: observation.updatedAt } : {}),
56
+ status: observation.status || 'active',
57
+ verification,
58
+ files: observation.filesModified || [],
59
+ relatedEntities: neighbors,
60
+ correctionHistory: [],
61
+ };
62
+ });
63
+ }