memorix 1.7.2 → 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 (90) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/assets/star-history-dark.svg +4 -4
  5. package/assets/star-history-light.svg +4 -4
  6. package/assets/star-history.json +47 -3
  7. package/dist/cli/index.js +1801 -254
  8. package/dist/dashboard/static/app.js +88 -4
  9. package/dist/dashboard/static/index.html +10 -0
  10. package/dist/dashboard/static/style.css +86 -0
  11. package/dist/index.js +1241 -224
  12. package/dist/maintenance-runner.js +972 -126
  13. package/dist/media-audio-runner.js +967 -121
  14. package/dist/media-video-runner.js +972 -126
  15. package/dist/memcode-runtime/CHANGELOG.md +59 -1
  16. package/dist/sdk.js +1240 -223
  17. package/dist/types.d.ts +8 -0
  18. package/dist/vector-backfill-runner.js +972 -126
  19. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  20. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +75 -0
  21. package/docs/API_REFERENCE.md +37 -1
  22. package/docs/ARCHITECTURE.md +1 -1
  23. package/docs/CONFIGURATION.md +57 -1
  24. package/docs/DASHBOARD.md +10 -0
  25. package/docs/DEVELOPMENT.md +3 -2
  26. package/docs/INTEGRATIONS.md +1 -1
  27. package/docs/PERFORMANCE.md +3 -1
  28. package/docs/README.md +11 -1
  29. package/docs/SETUP.md +2 -1
  30. package/package.json +1 -1
  31. package/plugins/antigravity/memorix/rules/memorix.md +1 -1
  32. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  33. package/plugins/claude/memorix/SKILL.md +1 -1
  34. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +2 -2
  35. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  36. package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +2 -2
  37. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  38. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +2 -2
  39. package/plugins/copilot/memorix/plugin.json +1 -1
  40. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +2 -2
  41. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +2 -2
  42. package/plugins/gemini/memorix/GEMINI.md +1 -1
  43. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  44. package/plugins/omp/memorix/package.json +1 -1
  45. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  46. package/plugins/pi/memorix/package.json +1 -1
  47. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +2 -2
  48. package/server.json +2 -2
  49. package/src/cli/capability-map.ts +2 -0
  50. package/src/cli/commands/config-migrate.ts +7 -0
  51. package/src/cli/commands/context.ts +11 -0
  52. package/src/cli/commands/doctor.ts +5 -1
  53. package/src/cli/commands/evidence.ts +56 -0
  54. package/src/cli/commands/feedback.ts +58 -0
  55. package/src/cli/commands/init.ts +17 -0
  56. package/src/cli/commands/resume.ts +2 -0
  57. package/src/cli/commands/serve-http.ts +201 -12
  58. package/src/cli/commands/status.ts +2 -0
  59. package/src/cli/fallback-budget.ts +59 -0
  60. package/src/cli/index.ts +5 -1
  61. package/src/compact/engine.ts +26 -1
  62. package/src/config/dotenv-loader.ts +17 -0
  63. package/src/config/resolved-config.ts +110 -12
  64. package/src/config/toml-loader.ts +8 -0
  65. package/src/config/yaml-loader.ts +9 -0
  66. package/src/dashboard/config-provenance.ts +17 -0
  67. package/src/dashboard/evidence.ts +63 -0
  68. package/src/dashboard/server.ts +93 -68
  69. package/src/dashboard/static/index.html +10 -0
  70. package/src/dashboard/static/style.css +86 -0
  71. package/src/hooks/installers/index.ts +1 -1
  72. package/src/hooks/official-skills.ts +3 -3
  73. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  74. package/src/llm/provider.ts +33 -7
  75. package/src/memory/feedback-model.ts +4 -0
  76. package/src/memory/feedback.ts +220 -0
  77. package/src/memory/observations.ts +19 -0
  78. package/src/memory/retention.ts +38 -5
  79. package/src/multimodal/image-loader.ts +6 -3
  80. package/src/rerank/http-provider.ts +125 -0
  81. package/src/rerank/index.ts +81 -0
  82. package/src/rerank/policy.ts +88 -0
  83. package/src/server/mcp-binding-store.ts +79 -0
  84. package/src/server/mcp-diagnostics.ts +40 -0
  85. package/src/server/tool-profile.ts +4 -0
  86. package/src/server.ts +121 -1
  87. package/src/store/evidence-store.ts +261 -0
  88. package/src/store/orama-store.ts +79 -53
  89. package/src/store/sqlite-db.ts +104 -0
  90. package/src/types.ts +8 -0
@@ -92,6 +92,15 @@
92
92
  </svg>
93
93
  <span class="nav-label">Observations</span>
94
94
  </button>
95
+ <button class="nav-btn" data-page="evidence" title="Evidence" aria-label="Evidence">
96
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
97
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
98
+ <polyline points="14 2 14 8 20 8"/>
99
+ <line x1="8" y1="13" x2="16" y2="13"/>
100
+ <line x1="8" y1="17" x2="14" y2="17"/>
101
+ </svg>
102
+ <span class="nav-label">Evidence</span>
103
+ </button>
95
104
  </div>
96
105
  <div class="sidebar-section-label" data-section="health">HEALTH</div>
97
106
  <div class="sidebar-nav">
@@ -175,6 +184,7 @@
175
184
  <div id="page-knowledge" class="page"></div>
176
185
  <div id="page-graph" class="page"></div>
177
186
  <div id="page-observations" class="page"></div>
187
+ <div id="page-evidence" class="page"></div>
178
188
  <div id="page-retention" class="page"></div>
179
189
  <div id="page-config" class="page"></div>
180
190
  <div id="page-identity" class="page"></div>
@@ -64,6 +64,92 @@
64
64
  --transition-emphasized: 500ms cubic-bezier(0.2, 0, 0, 1);
65
65
  }
66
66
 
67
+ /* Evidence Console: provenance is readable at a glance and stays bounded on
68
+ narrow screens; long paths and generated titles never widen the viewport. */
69
+ .evidence-console {
70
+ display: grid;
71
+ gap: 12px;
72
+ min-width: 0;
73
+ }
74
+
75
+ .evidence-card {
76
+ border: 1px solid var(--border-color, rgba(148, 163, 184, .18));
77
+ border-radius: 8px;
78
+ background: var(--surface, rgba(15, 23, 42, .45));
79
+ padding: 16px;
80
+ min-width: 0;
81
+ }
82
+
83
+ .evidence-card-header {
84
+ display: flex;
85
+ gap: 12px;
86
+ align-items: flex-start;
87
+ justify-content: space-between;
88
+ min-width: 0;
89
+ }
90
+
91
+ .evidence-card-heading {
92
+ min-width: 0;
93
+ }
94
+
95
+ .evidence-card-heading h2 {
96
+ margin: 4px 0 0;
97
+ font-size: 16px;
98
+ line-height: 1.35;
99
+ overflow-wrap: anywhere;
100
+ }
101
+
102
+ .evidence-card-kind {
103
+ color: var(--text-muted);
104
+ font-size: 11px;
105
+ text-transform: uppercase;
106
+ }
107
+
108
+ .evidence-card-state {
109
+ display: flex;
110
+ gap: 6px;
111
+ flex: 0 0 auto;
112
+ flex-wrap: wrap;
113
+ justify-content: flex-end;
114
+ }
115
+
116
+ .evidence-state {
117
+ border: 1px solid currentColor;
118
+ border-radius: 999px;
119
+ padding: 3px 7px;
120
+ font-size: 11px;
121
+ white-space: nowrap;
122
+ }
123
+
124
+ .evidence-current, .evidence-verified { color: #4ade80; }
125
+ .evidence-stale, .evidence-conflicted { color: #fb923c; }
126
+ .evidence-unverified { color: var(--text-muted); }
127
+
128
+ .evidence-card-summary {
129
+ margin: 12px 0;
130
+ color: var(--text-secondary, var(--text-muted));
131
+ line-height: 1.55;
132
+ overflow-wrap: anywhere;
133
+ }
134
+
135
+ .evidence-card-meta {
136
+ display: grid;
137
+ grid-template-columns: repeat(3, minmax(0, 1fr));
138
+ gap: 10px;
139
+ margin: 0;
140
+ }
141
+
142
+ .evidence-card-meta div { min-width: 0; }
143
+ .evidence-card-meta dt { color: var(--text-muted); font-size: 11px; margin-bottom: 3px; }
144
+ .evidence-card-meta dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
145
+ .evidence-card-warning { margin-top: 10px; color: #fb923c; font-size: 12px; overflow-wrap: anywhere; }
146
+
147
+ @media (max-width: 600px) {
148
+ .evidence-card-header { display: block; }
149
+ .evidence-card-state { justify-content: flex-start; margin-top: 10px; }
150
+ .evidence-card-meta { grid-template-columns: 1fr; }
151
+ }
152
+
67
153
  /* Light Theme - MD3 */
68
154
  [data-theme="light"] {
69
155
  --bg-base: #FFFBFE;
@@ -1259,7 +1259,7 @@ function getAgentRulesContent(agent?: AgentName, scope: 'project' | 'global' = '
1259
1259
  '',
1260
1260
  `Default first step for non-trivial coding work: call \x60memorix_project_context\x60 with the user's actual task before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Memorix will choose a task-lensed brief (bugfix, feature, release, onboarding, refactor, docs, test, or general). When the task is continuing prior work, the same brief also includes a bounded prior-work projection. Treat its "Start here" files as the first ${contextNoun} files to inspect.`,
1261
1261
  '',
1262
- 'If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select \x60memorix_project_context\x60 first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user\'s real task before inspecting files, Git history, progress notes, or guessing: \x60memorix resume "<task>" --brief-json\x60. For a new task, use \x60memorix context "<task>" --brief-json\x60 instead. The absence of \x60.memorix\x60 or visible memory files never proves project memory is empty. Use \x60--json\x60 only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax.',
1262
+ 'If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select \x60memorix_project_context\x60 first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user\'s real task before inspecting files, Git history, progress notes, or guessing: \x60memorix resume "<task>" --fallback --brief-json\x60. For a new task, use \x60memorix context "<task>" --fallback --brief-json\x60 instead. The absence of \x60.memorix\x60 or visible memory files never proves project memory is empty. Use \x60--json\x60 only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax.',
1263
1263
  '',
1264
1264
  'After a successful \x60memorix_project_context\x60 result, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use \x60memorix_context_pack\x60, \x60memorix_search\x60, or \x60memorix_detail\x60 only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in \x60purpose\x60 when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.',
1265
1265
  'If the user asks for read-only work or says not to modify files, do not call \x60memorix_store\x60 just to record an assessment. Store only when the user explicitly asks to preserve it.',
@@ -31,8 +31,8 @@ export const OFFICIAL_MEMORIX_SKILLS: OfficialMemorixSkill[] = [
31
31
  '',
32
32
  '| Situation | Prefer | CLI fallback |',
33
33
  '|---|---|---|',
34
- '| Starting a coding task and needing the best task-lensed project map | `memorix_project_context` with the user\'s task | `memorix context "<task>" --brief-json` |',
35
- '| Continuing prior project work | `memorix_project_context` with the user\'s task | `memorix resume "<task>" --brief-json` |',
34
+ '| Starting a coding task and needing the best task-lensed project map | `memorix_project_context` with the user\'s task | `memorix context "<task>" --fallback --brief-json` |',
35
+ '| Continuing prior project work | `memorix_project_context` with the user\'s task | `memorix resume "<task>" --fallback --brief-json` |',
36
36
  '| Need structured refs/freshness for code-bound memories | `memorix_context_pack` | `memorix codegraph context-pack --task "<topic>"` |',
37
37
  '| Explicit memory graph question | `memorix_graph_context` | `memorix memory graph-context --query "<topic>"` |',
38
38
  '| Specific past decision, bug, file, or change | `memorix_search` | `memorix memory search --query "<topic>"` |',
@@ -45,7 +45,7 @@ export const OFFICIAL_MEMORIX_SKILLS: OfficialMemorixSkill[] = [
45
45
  '',
46
46
  '- Default first step for non-trivial coding work: call `memorix_project_context` before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Pass the user\'s actual task so Memorix can choose a bugfix, feature, release, onboarding, refactor, docs, test, or general brief. Start by reading its suggested files.',
47
47
  '- In Claude Code headless/print-mode, an initial MCP `pending` status is not a failure by itself. Prefer MCP first: use the client\'s tool discovery/dynamic loading to find `memorix_project_context` when it is not in the first visible tool list.',
48
- '- Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run `memorix resume "<task>" --brief-json` exactly once before inspecting files, Git history, progress notes, or guessing. The absence of `.memorix` or visible memory files never proves project memory is empty. For a new task, use `memorix context "<task>" --brief-json` instead. Use `--json` only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally; do not probe help, enumerate commands, or chain broad searches.',
48
+ '- Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run `memorix resume "<task>" --fallback --brief-json` exactly once before inspecting files, Git history, progress notes, or guessing. The absence of `.memorix` or visible memory files never proves project memory is empty. For a new task, use `memorix context "<task>" --fallback --brief-json` instead. Use `--json` only when a diagnostic needs the detailed legacy payload. If that one command fails, report it and proceed normally; do not probe help, enumerate commands, or chain broad searches.',
49
49
  '- After a successful `memorix_project_context`, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use Context Pack, search, or detail only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in `purpose` when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.',
50
50
  '- If the user asks for read-only work or says not to modify files, do not call `memorix_store` just to record an assessment. Store only when the user explicitly asks to preserve it.',
51
51
  '- Use `memorix_search` for a specific decision, bug, file, or prior change that the project context did not answer. Fetch detail only for a result you still need after the brief.',
@@ -7,7 +7,7 @@ You have access to Memorix memory tools. Follow these rules to maintain persiste
7
7
  At the beginning of non-trivial coding work, use Memorix Memory Autopilot before local progress notes or broad file exploration. A session bind is not required for every conversation.
8
8
 
9
9
  1. Default first step for non-trivial coding work: call `memorix_project_context` with the user's actual task before progress files, dev-log reads, ad-hoc file reads, or git archaeology. Memorix will choose a task-lensed brief (bugfix, feature, release, onboarding, refactor, docs, test, or general). Treat its "Start here" files as the first code or docs to inspect.
10
- 2. If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select `memorix_project_context` first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user's real task before inspecting files, Git history, progress notes, or guessing: use `memorix resume "<task>" --brief-json`. For a new task, use `memorix context "<task>" --brief-json` instead. The absence of `.memorix` or visible memory files never proves project memory is empty. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax. Use `--json` only when a diagnostic needs the detailed legacy payload.
10
+ 2. If the MCP tool is not visible yet but the client supports tool discovery or dynamic loading, search/select `memorix_project_context` first. Continuation fallback is mandatory: when the user asks to continue, resume, take over, or explain prior work and MCP cannot be called in this turn, run exactly one CLI brief with the user's real task before inspecting files, Git history, progress notes, or guessing: use `memorix resume "<task>" --fallback --brief-json`. For a new task, use `memorix context "<task>" --fallback --brief-json` instead. The absence of `.memorix` or visible memory files never proves project memory is empty. If that one command fails, report it and proceed normally. Do not probe help, enumerate commands, chain broad searches, wait indefinitely on MCP startup, or hand-write tool-call syntax. Use `--json` only when a diagnostic needs the detailed legacy payload.
11
11
  3. After a successful `memorix_project_context` result, the brief is the default retrieval boundary. Do not call more Memorix retrieval tools after a complete brief. Use `memorix_context_pack`, `memorix_search`, or `memorix_detail` only when the brief lacks a specific reference, freshness field, or fact needed for the task, or when the user explicitly asks for deeper history. In MCP, name that missing fact in `purpose` when intentionally expanding beyond the brief. Do not retrieve the same decision twice just to confirm an already-complete brief.
12
12
  If the user asks for read-only work or says not to modify files, do not call `memorix_store` just to record an assessment. Store only when the user explicitly asks to preserve it.
13
13
  4. Use `memorix_context_pack` when you need structured refs and freshness for code-bound memories.
@@ -31,6 +31,36 @@ const LLM_TIMEOUT_MIN_MS = 1_000;
31
31
  const LLM_TIMEOUT_MAX_MS = 300_000;
32
32
  const MAX_LLM_RESPONSE_BYTES = 2 * 1024 * 1024;
33
33
 
34
+ const OPENAI_API_VERSION_SEGMENT = /^v\d+(?:[a-z][a-z0-9-]*)?$/i;
35
+
36
+ /**
37
+ * Normalize an OpenAI-compatible API root without rewriting an explicit
38
+ * provider version such as `/api/v3` or `/v1beta`.
39
+ *
40
+ * Bare hosts keep the historical convenience of receiving `/v1`. Once a
41
+ * caller supplies a versioned path, that path is authoritative.
42
+ */
43
+ export function normalizeOpenAICompatibleBaseUrl(baseUrl: string): string {
44
+ const base = baseUrl.trim().replace(/\/+$/, '');
45
+ if (!base) return base;
46
+
47
+ let pathname = '';
48
+ try {
49
+ pathname = new URL(base).pathname.replace(/\/+$/, '');
50
+ } catch {
51
+ pathname = base.split(/[?#]/, 1)[0].replace(/\/+$/, '');
52
+ }
53
+
54
+ const lastSegment = pathname.split('/').filter(Boolean).at(-1);
55
+ if (lastSegment && OPENAI_API_VERSION_SEGMENT.test(lastSegment)) {
56
+ return base;
57
+ }
58
+
59
+ const queryOrHash = base.match(/[?#].*$/)?.[0] ?? '';
60
+ const pathWithoutQuery = base.slice(0, base.length - queryOrHash.length).replace(/\/+$/, '');
61
+ return `${pathWithoutQuery}/v1${queryOrHash}`;
62
+ }
63
+
34
64
  /**
35
65
  * Parse and validate MEMORIX_LLM_TIMEOUT_MS environment variable.
36
66
  * - Must be a valid integer in the range 1000–300000ms.
@@ -259,9 +289,7 @@ async function callOpenAICompatible(
259
289
  signal?: AbortSignal,
260
290
  ): Promise<LLMResponse> {
261
291
  const config = currentConfig!;
262
- // Auto-fix: append /v1 if baseUrl doesn't end with it (common user mistake)
263
- let base = config.baseUrl!.replace(/\/+$/, '');
264
- if (!base.endsWith('/v1')) base += '/v1';
292
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
265
293
  const url = `${base}/chat/completions`;
266
294
 
267
295
  const fetchSignal = signal
@@ -387,8 +415,7 @@ async function callOpenAIWithTools(
387
415
  signal?: AbortSignal,
388
416
  ): Promise<LLMToolResponse> {
389
417
  const config = currentConfig!;
390
- let base = config.baseUrl!.replace(/\/+$/, '');
391
- if (!base.endsWith('/v1')) base += '/v1';
418
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
392
419
  const url = `${base}/chat/completions`;
393
420
 
394
421
  // Convert ChatMessage[] to OpenAI format
@@ -603,8 +630,7 @@ async function* callOpenAIWithToolsStream(
603
630
  tools: ToolDefinition[],
604
631
  ): AsyncGenerator<LLMStreamEvent, void, undefined> {
605
632
  const config = currentConfig!;
606
- let base = config.baseUrl!.replace(/\/+$/, '');
607
- if (!base.endsWith('/v1')) base += '/v1';
633
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
608
634
  const url = `${base}/chat/completions`;
609
635
 
610
636
  const openaiMessages: Array<Record<string, unknown>> = messages.map((msg) => {
@@ -0,0 +1,4 @@
1
+ /** Pure feedback math shared by retrieval/retention without opening SQLite. */
2
+ export function feedbackWeightMultiplier(weight: number): number {
3
+ return Math.max(0.35, Math.min(1.4, 0.35 + Math.max(0, Math.min(2, weight)) * 0.525));
4
+ }
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { ObservationStatus } from '../types.js';
3
+ import { getDatabase } from '../store/sqlite-db.js';
4
+ export { feedbackWeightMultiplier } from './feedback-model.js';
5
+
6
+ export type MemoryFeedbackSignal =
7
+ | 'user-correction'
8
+ | 'verification-success'
9
+ | 'verification-failure'
10
+ | 'code-change'
11
+ | 'used'
12
+ | 'not-used'
13
+ | 'code-conflict'
14
+ | 'strengthen'
15
+ | 'weaken'
16
+ | 'revoke';
17
+
18
+ export interface MemoryFeedbackEvent {
19
+ signal: MemoryFeedbackSignal;
20
+ at: string;
21
+ actor?: string;
22
+ note?: string;
23
+ sourceRef?: string;
24
+ targetEventId?: string;
25
+ }
26
+
27
+ export interface FeedbackState {
28
+ weight: number;
29
+ status: ObservationStatus;
30
+ audit: MemoryFeedbackEvent[];
31
+ }
32
+
33
+ export type FeedbackCandidateKind = 'observation' | 'claim' | 'durable-memory' | 'workflow';
34
+
35
+ export interface PersistedFeedbackEvent extends MemoryFeedbackEvent {
36
+ id: string;
37
+ projectId: string;
38
+ candidateKind: FeedbackCandidateKind;
39
+ candidateId: string;
40
+ delta: number;
41
+ }
42
+
43
+ export const FEEDBACK_DELTAS: Record<Exclude<MemoryFeedbackSignal, 'revoke'>, number> = {
44
+ 'user-correction': -0.35,
45
+ 'verification-success': 0.25,
46
+ 'verification-failure': -0.25,
47
+ 'code-change': -0.15,
48
+ used: 0.1,
49
+ 'not-used': -0.05,
50
+ 'code-conflict': -0.4,
51
+ strengthen: 0.2,
52
+ weaken: -0.2,
53
+ };
54
+
55
+ /** Deterministic feedback reducer used by MCP/CLI adapters and tests. */
56
+ export function applyMemoryFeedback(
57
+ current: FeedbackState,
58
+ event: MemoryFeedbackEvent,
59
+ ): FeedbackState {
60
+ const delta = event.signal === 'revoke' ? 0 : FEEDBACK_DELTAS[event.signal];
61
+ const weight = Math.max(0, Math.min(2, current.weight + delta));
62
+ const status: ObservationStatus = (event.signal === 'code-conflict' || event.signal === 'user-correction' || event.signal === 'weaken') && weight < 0.5
63
+ ? 'archived'
64
+ : current.status;
65
+ return { weight, status, audit: [...current.audit, { ...event }] };
66
+ }
67
+
68
+ function rowToEvent(row: any): PersistedFeedbackEvent {
69
+ return {
70
+ id: String(row.id),
71
+ projectId: String(row.project_id),
72
+ candidateKind: row.candidate_kind,
73
+ candidateId: String(row.candidate_id),
74
+ signal: row.signal,
75
+ ...(row.observed_at ? { at: String(row.observed_at) } : { at: new Date().toISOString() }),
76
+ ...(row.actor ? { actor: String(row.actor) } : {}),
77
+ ...(row.note ? { note: String(row.note) } : {}),
78
+ ...(row.source_ref ? { sourceRef: String(row.source_ref) } : {}),
79
+ ...(row.target_event_id ? { targetEventId: String(row.target_event_id) } : {}),
80
+ delta: Number(row.delta ?? 0),
81
+ };
82
+ }
83
+
84
+ function candidateKey(projectId: string, candidateKind: FeedbackCandidateKind, candidateId: string): string {
85
+ return `${projectId}\u0000${candidateKind}\u0000${candidateId}`;
86
+ }
87
+
88
+ /**
89
+ * Durable feedback loop. Events are append-only; the state row is a materialized
90
+ * projection rebuilt from non-revoked events, so undo never destroys audit truth.
91
+ */
92
+ export class MemoryFeedbackStore {
93
+ private db: any = null;
94
+
95
+ async init(dataDir: string): Promise<void> {
96
+ this.db = getDatabase(dataDir);
97
+ }
98
+
99
+ private requireDb(): any {
100
+ if (!this.db) throw new Error('MemoryFeedbackStore is not initialized.');
101
+ return this.db;
102
+ }
103
+
104
+ private loadEvents(projectId: string, candidateKind: FeedbackCandidateKind, candidateId: string): PersistedFeedbackEvent[] {
105
+ return this.requireDb().prepare(`
106
+ SELECT * FROM memory_feedback_events
107
+ WHERE project_id = ? AND candidate_kind = ? AND candidate_id = ?
108
+ ORDER BY observed_at ASC, rowid ASC
109
+ `).all(projectId, candidateKind, candidateId).map(rowToEvent);
110
+ }
111
+
112
+ private rebuild(projectId: string, candidateKind: FeedbackCandidateKind, candidateId: string): FeedbackState {
113
+ const events = this.loadEvents(projectId, candidateKind, candidateId);
114
+ const revoked = new Set(events.filter((event) => event.signal === 'revoke' && event.targetEventId).map((event) => event.targetEventId!));
115
+ let state: FeedbackState = { weight: 1, status: 'active', audit: [] };
116
+ for (const event of events) {
117
+ if (event.signal === 'revoke' || revoked.has(event.id)) {
118
+ state.audit.push(event);
119
+ continue;
120
+ }
121
+ state = applyMemoryFeedback(state, event);
122
+ }
123
+ this.requireDb().prepare(`
124
+ INSERT INTO memory_feedback_states (project_id, candidate_kind, candidate_id, weight, lifecycle_status, updated_at)
125
+ VALUES (?, ?, ?, ?, ?, ?)
126
+ ON CONFLICT(project_id, candidate_kind, candidate_id) DO UPDATE SET
127
+ weight = excluded.weight, lifecycle_status = excluded.lifecycle_status, updated_at = excluded.updated_at
128
+ `).run(projectId, candidateKind, candidateId, state.weight, state.status, new Date().toISOString());
129
+ return state;
130
+ }
131
+
132
+ getState(projectId: string, candidateKind: FeedbackCandidateKind, candidateId: string): FeedbackState {
133
+ const row = this.requireDb().prepare(`
134
+ SELECT weight, lifecycle_status FROM memory_feedback_states
135
+ WHERE project_id = ? AND candidate_kind = ? AND candidate_id = ?
136
+ `).get(projectId, candidateKind, candidateId);
137
+ if (!row) return { weight: 1, status: 'active', audit: [] };
138
+ return {
139
+ weight: Number(row.weight ?? 1),
140
+ status: row.lifecycle_status ?? 'active',
141
+ audit: this.loadEvents(projectId, candidateKind, candidateId),
142
+ };
143
+ }
144
+
145
+ listStates(projectId: string, candidateKind: FeedbackCandidateKind = 'observation'): Array<{ candidateId: string; weight: number; status: ObservationStatus; updatedAt: string }> {
146
+ return this.requireDb().prepare(`
147
+ SELECT candidate_id, weight, lifecycle_status, updated_at
148
+ FROM memory_feedback_states WHERE project_id = ? AND candidate_kind = ?
149
+ ORDER BY updated_at DESC
150
+ `).all(projectId, candidateKind).map((row: any) => ({
151
+ candidateId: String(row.candidate_id),
152
+ weight: Number(row.weight),
153
+ status: row.lifecycle_status,
154
+ updatedAt: String(row.updated_at),
155
+ }));
156
+ }
157
+
158
+ weights(projectId: string, candidateKind: FeedbackCandidateKind = 'observation'): Map<string, FeedbackState> {
159
+ const states = new Map<string, FeedbackState>();
160
+ for (const row of this.listStates(projectId, candidateKind)) {
161
+ states.set(row.candidateId, this.getState(projectId, candidateKind, row.candidateId));
162
+ }
163
+ return states;
164
+ }
165
+
166
+ record(input: {
167
+ projectId: string;
168
+ candidateKind: FeedbackCandidateKind;
169
+ candidateId: string;
170
+ signal: MemoryFeedbackSignal;
171
+ sourceRef: string;
172
+ actor?: string;
173
+ note?: string;
174
+ targetEventId?: string;
175
+ at?: string;
176
+ }): { event: PersistedFeedbackEvent; state: FeedbackState } {
177
+ if (input.signal === 'revoke' && !input.targetEventId) {
178
+ throw new Error('A revoke feedback event requires targetEventId.');
179
+ }
180
+ if (input.signal === 'revoke') {
181
+ const target = this.requireDb().prepare(`
182
+ SELECT 1 FROM memory_feedback_events
183
+ WHERE id = ? AND project_id = ? AND candidate_kind = ? AND candidate_id = ?
184
+ `).get(input.targetEventId, input.projectId, input.candidateKind, input.candidateId);
185
+ if (!target) throw new Error('The feedback event to revoke was not found in the same candidate scope.');
186
+ }
187
+ const event: PersistedFeedbackEvent = {
188
+ id: randomUUID(),
189
+ projectId: input.projectId,
190
+ candidateKind: input.candidateKind,
191
+ candidateId: input.candidateId,
192
+ signal: input.signal,
193
+ sourceRef: input.sourceRef,
194
+ ...(input.actor ? { actor: input.actor } : {}),
195
+ ...(input.note ? { note: input.note.slice(0, 2_000) } : {}),
196
+ ...(input.targetEventId ? { targetEventId: input.targetEventId } : {}),
197
+ at: input.at ?? new Date().toISOString(),
198
+ delta: input.signal === 'revoke' ? 0 : FEEDBACK_DELTAS[input.signal],
199
+ };
200
+ this.requireDb().prepare(`
201
+ INSERT INTO memory_feedback_events (
202
+ id, project_id, candidate_kind, candidate_id, signal, actor, note,
203
+ source_ref, target_event_id, delta, observed_at
204
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
205
+ `).run(
206
+ event.id, event.projectId, event.candidateKind, event.candidateId, event.signal,
207
+ event.actor ?? null, event.note ?? null, event.sourceRef, event.targetEventId ?? null,
208
+ event.delta, event.at,
209
+ );
210
+ return { event, state: this.rebuild(input.projectId, input.candidateKind, input.candidateId) };
211
+ }
212
+
213
+ audit(projectId: string, candidateKind: FeedbackCandidateKind, candidateId: string, limit = 100): PersistedFeedbackEvent[] {
214
+ return this.requireDb().prepare(`
215
+ SELECT * FROM memory_feedback_events
216
+ WHERE project_id = ? AND candidate_kind = ? AND candidate_id = ?
217
+ ORDER BY observed_at DESC, rowid DESC LIMIT ?
218
+ `).all(projectId, candidateKind, candidateId, Math.min(200, Math.max(1, Math.floor(limit)))).map(rowToEvent);
219
+ }
220
+ }
@@ -44,6 +44,7 @@ import { getEmbeddingProvider, isEmbeddingExplicitlyDisabled, validateEmbeddingI
44
44
  import { sanitizeCredentials } from './secret-filter.js';
45
45
  import { enqueueClaimDerivation, enqueueObservationQualification } from '../runtime/lifecycle.js';
46
46
  import { canManageObservation, resolveObservationVisibility } from './visibility.js';
47
+ import { EvidenceCardStore } from '../store/evidence-store.js';
47
48
 
48
49
  /** In-memory observation list (loaded from persistence on init) */
49
50
  let observations: Observation[] = [];
@@ -186,6 +187,20 @@ async function bindObservationCodeRefsBestEffort(observation: Observation): Prom
186
187
  }
187
188
  }
188
189
 
190
+ async function persistEvidenceCard(observation: Observation): Promise<void> {
191
+ if (!projectDir) return;
192
+ try {
193
+ const evidence = new EvidenceCardStore();
194
+ await evidence.init(projectDir);
195
+ evidence.upsertObservation(observation);
196
+ } catch (error) {
197
+ // Evidence is a derived control-plane projection. A failed projection must
198
+ // never roll back the canonical observation, but it must be visible for a
199
+ // later repair/sync pass.
200
+ console.error(`[memorix] Evidence card projection failed for obs-${observation.id}: ${error instanceof Error ? error.message : String(error)}`);
201
+ }
202
+ }
203
+
189
204
  function queueClaimDerivation(observation: Observation): void {
190
205
  const dataDir = projectDir;
191
206
  const isExplicit = observation.sourceDetail === 'explicit';
@@ -692,6 +707,7 @@ export async function storeObservation(input: {
692
707
  if (!persistedCallbackInvoked) await input.onPersisted?.(observation);
693
708
 
694
709
  await bindObservationCodeRefsBestEffort(observation);
710
+ await persistEvidenceCard(observation);
695
711
  queueObservationQualification(observation);
696
712
  if (resolveObservationVisibility(observation) === 'project') {
697
713
  queueClaimDerivation(observation);
@@ -828,6 +844,7 @@ async function upsertObservation(
828
844
  }
829
845
 
830
846
  await bindObservationCodeRefsBestEffort(existing);
847
+ await persistEvidenceCard(existing);
831
848
  queueObservationQualification(existing);
832
849
  if (resolveObservationVisibility(existing) === 'project') {
833
850
  queueClaimDerivation(existing);
@@ -1002,6 +1019,8 @@ export async function resolveObservations(
1002
1019
  });
1003
1020
  }
1004
1021
 
1022
+ for (const observation of changed) await persistEvidenceCard(observation);
1023
+
1005
1024
  return { resolved, notFound };
1006
1025
  }
1007
1026
 
@@ -15,9 +15,10 @@
15
15
  * retention period but are no longer permanently immune — they decay normally.
16
16
  */
17
17
 
18
- import type { MemorixDocument, Observation, ObservationReader } from '../types.js';
18
+ import type { MemorixDocument, Observation, ObservationReader, ObservationStatus } from '../types.js';
19
19
  import { getObservationStore } from '../store/obs-store.js';
20
20
  import { canManageObservation } from './visibility.js';
21
+ import { feedbackWeightMultiplier } from './feedback-model.js';
21
22
 
22
23
  // ── Importance → Retention Period mapping ────────────────────────────
23
24
 
@@ -113,6 +114,7 @@ export function getEffectiveRetentionDays(doc: MemorixDocument): number {
113
114
  export function isImmune(doc: MemorixDocument): boolean {
114
115
  // Probe observations are operational heartbeats -- never immune, regardless of valueCategory or access.
115
116
  if (doc.type === 'probe') return false;
117
+ if (doc.feedbackStatus === 'archived') return false;
116
118
 
117
119
  // A candidate has not earned durable status. Never let an automatic core
118
120
  // classification turn unqualified capture into permanently retained memory.
@@ -164,6 +166,7 @@ export interface RelevanceScore {
164
166
  accessBoost: number;
165
167
  ageDays: number;
166
168
  isImmune: boolean;
169
+ feedbackMultiplier: number;
167
170
  }
168
171
 
169
172
  /**
@@ -202,7 +205,12 @@ export function calculateRelevance(
202
205
  const accessCount = doc.accessCount ?? 0;
203
206
  const accessBoost = Math.min(2.0, 1 + 0.1 * accessCount);
204
207
 
205
- let totalScore = base * decayFactor * accessBoost;
208
+ // Legacy memories have no feedback row. Preserve their historical score
209
+ // exactly; only an explicit feedback projection changes relevance.
210
+ const feedbackMultiplier = doc.feedbackWeight === undefined
211
+ ? 1
212
+ : feedbackWeightMultiplier(doc.feedbackWeight);
213
+ let totalScore = base * decayFactor * accessBoost * feedbackMultiplier;
206
214
 
207
215
  // Immune observations get minimum 0.5 relevance
208
216
  const immune = isImmune(doc);
@@ -218,6 +226,7 @@ export function calculateRelevance(
218
226
  accessBoost,
219
227
  ageDays,
220
228
  isImmune: immune,
229
+ feedbackMultiplier,
221
230
  };
222
231
  }
223
232
 
@@ -248,6 +257,7 @@ export type RetentionZone = 'active' | 'stale' | 'archive-candidate';
248
257
  */
249
258
  export function getRetentionZone(doc: MemorixDocument, referenceTime?: Date): RetentionZone {
250
259
  const now = referenceTime ?? new Date();
260
+ if (doc.feedbackStatus === 'archived') return 'archive-candidate';
251
261
  const importance = getImportanceLevel(doc);
252
262
  const retention = getEffectiveRetentionDays(doc);
253
263
 
@@ -344,6 +354,7 @@ export interface RetentionProjection extends RetentionExplanation {
344
354
  export interface ObservationRetentionProjectionOptions {
345
355
  referenceTime?: Date;
346
356
  accessMap?: ObservationAccessMap;
357
+ feedback?: { weight: number; status: ObservationStatus };
347
358
  }
348
359
 
349
360
  export interface RetentionProjectionSummary {
@@ -415,6 +426,7 @@ export function explainRetention(
415
426
  export function toRetentionDocument(
416
427
  obs: Observation,
417
428
  accessMap?: ObservationAccessMap,
429
+ feedback?: { weight: number; status: ObservationStatus },
418
430
  ): MemorixDocument {
419
431
  const access = accessMap?.get(obs.id);
420
432
  return {
@@ -438,6 +450,7 @@ export function toRetentionDocument(
438
450
  valueCategory: obs.valueCategory ?? '',
439
451
  admissionState: obs.admissionState ?? '',
440
452
  admissionReason: obs.admissionReason ?? '',
453
+ ...(feedback ? { feedbackWeight: feedback.weight, feedbackStatus: feedback.status } : {}),
441
454
  };
442
455
  }
443
456
 
@@ -446,7 +459,7 @@ export function projectObservationRetention(
446
459
  observation: Observation,
447
460
  options: ObservationRetentionProjectionOptions = {},
448
461
  ): RetentionProjection {
449
- const document = toRetentionDocument(observation, options.accessMap);
462
+ const document = toRetentionDocument(observation, options.accessMap, options.feedback);
450
463
  const relevance = calculateRelevance(document, options.referenceTime);
451
464
  const explanation = explainRetention(document, options.referenceTime);
452
465
 
@@ -497,12 +510,25 @@ export interface ArchiveExpiredBatchResult {
497
510
  nextCursor?: number;
498
511
  }
499
512
 
513
+ async function loadFeedbackStore(dataDir: string): Promise<import('./feedback.js').MemoryFeedbackStore | undefined> {
514
+ try {
515
+ const { MemoryFeedbackStore } = await import('./feedback.js');
516
+ const store = new MemoryFeedbackStore();
517
+ await store.init(dataDir);
518
+ return store;
519
+ } catch {
520
+ // Pre-1.8 databases or degraded SQLite installations keep the legacy
521
+ // retention behavior until the feedback projection is available.
522
+ return undefined;
523
+ }
524
+ }
525
+
500
526
  /**
501
527
  * Archive one bounded page of a project's active memories. The caller owns the
502
528
  * cursor, which makes the operation safe to run from a durable maintenance job.
503
529
  */
504
530
  export async function archiveExpiredBatch(
505
- _projectDir: string,
531
+ projectDir: string,
506
532
  options: ArchiveExpiredBatchOptions,
507
533
  ): Promise<ArchiveExpiredBatchResult> {
508
534
  const store = getObservationStore();
@@ -513,6 +539,7 @@ export async function archiveExpiredBatch(
513
539
  afterId,
514
540
  limit: limit + 1,
515
541
  });
542
+ const feedback = await loadFeedbackStore(projectDir);
516
543
  const hasMore = page.length > limit;
517
544
  const scanned = hasMore ? page.slice(0, limit) : page;
518
545
  const candidateIds = scanned
@@ -520,6 +547,7 @@ export async function archiveExpiredBatch(
520
547
  .filter((observation) => projectObservationRetention(observation, {
521
548
  accessMap: options.accessMap,
522
549
  referenceTime: options.referenceTime,
550
+ ...(feedback ? { feedback: feedback.getState(options.projectId, 'observation', String(observation.id)) } : {}),
523
551
  }).zone === 'archive-candidate')
524
552
  .map((observation) => observation.id);
525
553
 
@@ -580,6 +608,7 @@ export async function archiveExpired(
580
608
  return { archived, remaining };
581
609
  }
582
610
 
611
+ const feedback = await loadFeedbackStore(projectDir);
583
612
  return await store.atomic(async (tx) => {
584
613
  const allObs = await tx.loadAll();
585
614
 
@@ -591,7 +620,11 @@ export async function archiveExpired(
591
620
  const archivedIds: number[] = [];
592
621
 
593
622
  for (const obs of activeObs) {
594
- const retention = projectObservationRetention(obs, { accessMap, referenceTime });
623
+ const retention = projectObservationRetention(obs, {
624
+ accessMap,
625
+ referenceTime,
626
+ ...(feedback ? { feedback: feedback.getState(obs.projectId, 'observation', String(obs.id)) } : {}),
627
+ });
595
628
  if (retention.zone === 'archive-candidate') {
596
629
  archivedIds.push(obs.id);
597
630
  }