memorix 1.7.3 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/dist/cli/index.js +1488 -270
  5. package/dist/dashboard/static/app.js +125 -27
  6. package/dist/dashboard/static/index.html +11 -1
  7. package/dist/dashboard/static/style.css +86 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +927 -240
  10. package/dist/maintenance-runner.js +607 -68
  11. package/dist/media-audio-runner.js +602 -63
  12. package/dist/media-video-runner.js +607 -68
  13. package/dist/memcode-runtime/CHANGELOG.md +41 -0
  14. package/dist/sdk.js +926 -239
  15. package/dist/types.d.ts +8 -0
  16. package/dist/vector-backfill-runner.js +607 -68
  17. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  18. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +75 -0
  19. package/docs/API_REFERENCE.md +39 -1
  20. package/docs/ARCHITECTURE.md +3 -1
  21. package/docs/DASHBOARD.md +16 -0
  22. package/docs/DEVELOPMENT.md +3 -2
  23. package/docs/INTEGRATIONS.md +1 -1
  24. package/docs/README.md +15 -2
  25. package/package.json +1 -1
  26. package/plugins/antigravity/memorix/rules/memorix.md +1 -1
  27. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude/memorix/SKILL.md +1 -1
  29. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +2 -2
  30. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  31. package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +2 -2
  32. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  33. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +2 -2
  34. package/plugins/copilot/memorix/plugin.json +1 -1
  35. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +2 -2
  36. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +2 -2
  37. package/plugins/gemini/memorix/GEMINI.md +1 -1
  38. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  39. package/plugins/omp/memorix/package.json +1 -1
  40. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  41. package/plugins/pi/memorix/package.json +1 -1
  42. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +2 -2
  43. package/server.json +2 -2
  44. package/src/cli/capability-map.ts +2 -0
  45. package/src/cli/commands/context.ts +11 -0
  46. package/src/cli/commands/dashboard.ts +1 -1
  47. package/src/cli/commands/evidence.ts +56 -0
  48. package/src/cli/commands/feedback.ts +58 -0
  49. package/src/cli/commands/resume.ts +2 -0
  50. package/src/cli/commands/serve-http.ts +229 -13
  51. package/src/cli/fallback-budget.ts +59 -0
  52. package/src/cli/index.ts +5 -1
  53. package/src/compact/engine.ts +26 -1
  54. package/src/config/dotenv-loader.ts +17 -0
  55. package/src/dashboard/config-provenance.ts +17 -0
  56. package/src/dashboard/evidence.ts +63 -0
  57. package/src/dashboard/server.ts +178 -154
  58. package/src/dashboard/static/index.html +11 -1
  59. package/src/dashboard/static/style.css +86 -0
  60. package/src/hooks/installers/index.ts +1 -1
  61. package/src/hooks/official-skills.ts +3 -3
  62. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  63. package/src/memory/feedback-model.ts +4 -0
  64. package/src/memory/feedback.ts +220 -0
  65. package/src/memory/observations.ts +19 -0
  66. package/src/memory/retention.ts +38 -5
  67. package/src/server/mcp-binding-store.ts +79 -0
  68. package/src/server/mcp-diagnostics.ts +40 -0
  69. package/src/server/tool-profile.ts +4 -0
  70. package/src/server.ts +125 -5
  71. package/src/store/evidence-store.ts +261 -0
  72. package/src/store/sqlite-db.ts +104 -0
  73. package/src/types.ts +8 -0
@@ -0,0 +1,56 @@
1
+ import { defineCommand } from 'citty';
2
+ import { getObservationStore } from '../../store/obs-store.js';
3
+ import { EvidenceCardStore } from '../../store/evidence-store.js';
4
+ import { emitError, emitResult, getCliProjectContext, getCliReadContext } from './operator-shared.js';
5
+
6
+ export default defineCommand({
7
+ meta: { name: 'evidence', description: 'Inspect project-scoped evidence cards and provenance' },
8
+ args: {
9
+ action: { type: 'positional', description: 'list, get, sync, stale, or events', required: false },
10
+ id: { type: 'string', description: 'Candidate id, usually an observation id' },
11
+ paths: { type: 'string', description: 'Comma-separated changed paths for stale' },
12
+ reason: { type: 'string', description: 'Why the source became stale' },
13
+ query: { type: 'string', description: 'Search title/source fields' },
14
+ limit: { type: 'string', description: 'Maximum cards/events' },
15
+ json: { type: 'boolean', description: 'Emit JSON' },
16
+ },
17
+ async run({ args }) {
18
+ const action = (args.action as string | undefined) || 'list';
19
+ const asJson = !!args.json;
20
+ try {
21
+ const context = action === 'list' || action === 'get' || action === 'events'
22
+ ? await getCliReadContext()
23
+ : await getCliProjectContext({ searchIndex: false });
24
+ const { project, dataDir } = context;
25
+ const store = new EvidenceCardStore();
26
+ await store.init(dataDir);
27
+ const limit = Math.min(100, Math.max(1, Number(args.limit || 20)));
28
+ if (action === 'sync') {
29
+ const synced = store.syncObservations(await getObservationStore().loadByProject(project.id));
30
+ emitResult({ project, synced: synced.length }, `Synced ${synced.length} evidence card(s).`, asJson);
31
+ return;
32
+ }
33
+ if (action === 'stale') {
34
+ const paths = String(args.paths || '').split(',').map((value) => value.trim()).filter(Boolean);
35
+ const changed = store.markStaleForPaths(project.id, paths, String(args.reason || 'source file changed'));
36
+ emitResult({ project, staleMarked: changed, paths }, `Marked ${changed} evidence card(s) stale.`, asJson);
37
+ return;
38
+ }
39
+ const id = String(args.id || '').trim();
40
+ if (action === 'get' || action === 'events') {
41
+ if (!id) { emitError(`--id is required for evidence ${action}.`, asJson); return; }
42
+ const card = store.get(project.id, 'observation', id);
43
+ if (!card) { emitError(`Evidence card for observation ${id} was not found.`, asJson); return; }
44
+ const result = action === 'events' ? store.listEvents(card.id, limit) : { card, events: store.listEvents(card.id) };
45
+ emitResult({ project, result }, JSON.stringify(result, null, 2), asJson);
46
+ return;
47
+ }
48
+ if (action !== 'list') { emitError('action must be list, get, sync, stale, or events.', asJson); return; }
49
+ const query = String(args.query || '').trim().toLowerCase();
50
+ const cards = store.list(project.id, { limit: 100 }).filter((card) => !query || [card.title, card.summary, card.sourceRef, card.locator || ''].join(' ').toLowerCase().includes(query)).slice(0, limit);
51
+ emitResult({ project, cards }, cards.length === 0 ? 'No evidence cards found.' : cards.map((card) => `- ${card.candidateId} ${card.title} [${card.freshness}] ${card.sourceRef}`).join('\n'), asJson);
52
+ } catch (error) {
53
+ emitError(error instanceof Error ? error.message : String(error), asJson);
54
+ }
55
+ },
56
+ });
@@ -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,62 @@ 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') {
892
+ if (req.method !== 'GET') {
893
+ sendJson({
894
+ status: 'error',
895
+ readOnly: true,
896
+ error: 'Coordination status is read-only',
897
+ errorCode: 'COORDINATION_STATUS_READ_ONLY',
898
+ }, 405);
899
+ return;
900
+ }
731
901
  // Phase 4a: All team state is in SQLite via TeamStore
732
- const { projectId: teamProjectId } = await resolveRequestProject(url);
733
- const ts = sharedTeamStore;
902
+ const { projectId: teamProjectId, dataDir: teamDataDir } = await resolveRequestProject(url);
903
+ const ts = await getTeamStore(teamDataDir);
734
904
  const scope = url.searchParams.get('scope') || 'project';
735
905
 
736
906
  // Determine effective project ID for query
@@ -806,8 +976,9 @@ export default defineCommand({
806
976
  const openHandoffs = handoffs.filter((handoff: any) => handoff.status !== 'completed').length;
807
977
 
808
978
  sendJson({
979
+ status: 'ok',
809
980
  mode: 'control-plane',
810
- readOnly: false,
981
+ readOnly: true,
811
982
  scope,
812
983
  agents: withTier,
813
984
  // Primary headline number
@@ -847,6 +1018,7 @@ export default defineCommand({
847
1018
  const graph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
848
1019
 
849
1020
  const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active');
1021
+ const feedback = await loadProjectFeedback(statsDataDir, statsProjectId);
850
1022
  const statsStore = await getDashboardObservationStore(statsDataDir);
851
1023
  const nextId = await statsStore.loadIdCounter();
852
1024
  const typeCounts: Record<string, number> = {};
@@ -887,7 +1059,10 @@ export default defineCommand({
887
1059
  const retention = summarizeRetentionProjections(
888
1060
  observations
889
1061
  .filter((observation) => observation.type !== 'probe')
890
- .map((observation) => projectObservationRetention(observation, { referenceTime: new Date(now) })),
1062
+ .map((observation) => projectObservationRetention(observation, {
1063
+ referenceTime: new Date(now),
1064
+ feedback: feedback.get(String(observation.id)),
1065
+ })),
891
1066
  );
892
1067
  const retentionSummary = {
893
1068
  active: retention.active,
@@ -1006,6 +1181,23 @@ export default defineCommand({
1006
1181
  return;
1007
1182
  }
1008
1183
 
1184
+ if (apiPath === '/evidence') {
1185
+ const { projectId: evidenceProjectId, dataDir: evidenceDataDir } = await resolveRequestProject(url);
1186
+ const { EvidenceCardStore } = await import('../../store/evidence-store.js');
1187
+ const evidenceStore = new EvidenceCardStore();
1188
+ await evidenceStore.init(evidenceDataDir);
1189
+ const observations = await loadDashboardProjectObservations(evidenceDataDir, evidenceProjectId, 'active');
1190
+ evidenceStore.syncObservations(observations);
1191
+ const query = (url.searchParams.get('q') || '').trim().toLowerCase();
1192
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') || 20)));
1193
+ const cards = evidenceStore.list(evidenceProjectId, { limit: 100 })
1194
+ .filter((card) => !query || [card.title, card.summary, card.sourceRef, card.locator ?? ''].join(' ').toLowerCase().includes(query))
1195
+ .slice(0, limit)
1196
+ .map((card) => ({ ...card, events: evidenceStore.listEvents(card.id, 8) }));
1197
+ sendJson({ projectId: evidenceProjectId, cards });
1198
+ return;
1199
+ }
1200
+
1009
1201
  if (apiPath === '/graph') {
1010
1202
  const { projectId: graphProjectId, dataDir: graphDataDir } = await resolveRequestProject(url);
1011
1203
  const { initGraphStore, getGraphStore } = await import('../../store/graph-store.js');
@@ -1030,12 +1222,13 @@ export default defineCommand({
1030
1222
  if (apiPath === '/retention') {
1031
1223
  const { projectId: retProjectId, dataDir: retDataDir } = await resolveRequestProject(url);
1032
1224
  const observations = await loadDashboardProjectObservations(retDataDir, retProjectId, 'active');
1225
+ const feedback = await loadProjectFeedback(retDataDir, retProjectId);
1033
1226
  const referenceTime = new Date();
1034
1227
  const rows = observations
1035
1228
  .filter((observation) => observation.type !== 'probe')
1036
1229
  .map((observation) => ({
1037
1230
  observation,
1038
- retention: projectObservationRetention(observation, { referenceTime }),
1231
+ retention: projectObservationRetention(observation, { referenceTime, feedback: feedback.get(String(observation.id)) }),
1039
1232
  }))
1040
1233
  .sort((a, b) => b.retention.displayScore - a.retention.displayScore);
1041
1234
  const summary = summarizeRetentionProjections(rows.map((row) => row.retention));
@@ -1467,6 +1660,23 @@ export default defineCommand({
1467
1660
 
1468
1661
  sendJson({ error: 'Not found' }, 404);
1469
1662
  } catch (err) {
1663
+ if (apiPath === '/team') {
1664
+ const scope = url.searchParams.get('scope') === 'global' ? 'global' : 'project';
1665
+ console.error('[memorix] coordination snapshot unavailable', { status: 'error', scope });
1666
+ sendJson({
1667
+ status: 'error',
1668
+ mode: 'control-plane',
1669
+ readOnly: true,
1670
+ scope,
1671
+ agents: [],
1672
+ locks: [],
1673
+ tasks: [],
1674
+ handoffs: [],
1675
+ error: 'Coordination state unavailable',
1676
+ errorCode: 'COORDINATION_STATE_UNAVAILABLE',
1677
+ }, 503);
1678
+ return;
1679
+ }
1470
1680
  sendJson({ error: err instanceof Error ? err.message : 'Unknown error' }, 500);
1471
1681
  }
1472
1682
  }
@@ -1499,6 +1709,12 @@ export default defineCommand({
1499
1709
  return;
1500
1710
  }
1501
1711
 
1712
+ if (url.pathname === '/protocol-diagnostics') {
1713
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' });
1714
+ res.end(JSON.stringify(getMcpDiagnostics()));
1715
+ return;
1716
+ }
1717
+
1502
1718
  // MCP endpoint
1503
1719
  if (url.pathname === '/mcp') {
1504
1720
  // 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) {