total-recall-brain 3.1.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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- package/templates/total-recall-server.service +20 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { atomicWrite, walkMd, loadNodes, writeNode, safeStringify } from './vault.mjs';
|
|
6
|
+
import { logger } from './logger.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SSSS Patch Conflict Detector
|
|
10
|
+
*
|
|
11
|
+
* Detects two classes of conflict:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Semantic conflicts** — two memory nodes contradict each other
|
|
14
|
+
* (polarity flip on the same target, high similarity with opposing modality).
|
|
15
|
+
*
|
|
16
|
+
* 2. **Patch conflicts** — a patch operation targets a file that has been
|
|
17
|
+
* modified since the patch was prepared (optimistic concurrency).
|
|
18
|
+
*
|
|
19
|
+
* Implements §9 of the SSSS spec (Validation & Repair) and the conflict
|
|
20
|
+
* record primitive (§5.1).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ─── Jaccard Similarity ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute Jaccard similarity between two sets of tokens.
|
|
27
|
+
*/
|
|
28
|
+
function jaccard(setA, setB) {
|
|
29
|
+
if (setA.size === 0 && setB.size === 0) return 1;
|
|
30
|
+
let intersection = 0;
|
|
31
|
+
for (const item of setA) {
|
|
32
|
+
if (setB.has(item)) intersection++;
|
|
33
|
+
}
|
|
34
|
+
const union = setA.size + setB.size - intersection;
|
|
35
|
+
return union === 0 ? 0 : intersection / union;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tokenize a string into a set of lowercase words.
|
|
40
|
+
*/
|
|
41
|
+
function tokenize(text) {
|
|
42
|
+
if (!text) return new Set();
|
|
43
|
+
return new Set(
|
|
44
|
+
text
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^a-z0-9\s_-]/g, ' ')
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.filter(w => w.length > 1)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Polarity Detection ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
const POSITIVE_POLARITIES = new Set(['directive_must', 'descriptive', 'preference']);
|
|
55
|
+
const NEGATIVE_POLARITIES = new Set(['directive_must_not']);
|
|
56
|
+
const POSITIVE_MODALITIES = new Set(['must', 'should']);
|
|
57
|
+
const NEGATIVE_MODALITIES = new Set(['must_not', 'should_not']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Determine if two nodes have opposing polarity.
|
|
61
|
+
*/
|
|
62
|
+
function hasPolarityFlip(nodeA, nodeB) {
|
|
63
|
+
// Check sentiment_polarity
|
|
64
|
+
const polA = POSITIVE_POLARITIES.has(nodeA.sentiment_polarity) ? 'positive' : 'negative';
|
|
65
|
+
const polB = POSITIVE_POLARITIES.has(nodeB.sentiment_polarity) ? 'positive' : 'negative';
|
|
66
|
+
if (polA !== polB) return true;
|
|
67
|
+
|
|
68
|
+
// Check modality
|
|
69
|
+
const modA = POSITIVE_MODALITIES.has(nodeA.modality) ? 'positive' : 'negative';
|
|
70
|
+
const modB = POSITIVE_MODALITIES.has(nodeB.modality) ? 'positive' : 'negative';
|
|
71
|
+
if (modA !== modB) return true;
|
|
72
|
+
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Semantic Conflict Detection ────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Default thresholds for conflict detection.
|
|
80
|
+
*/
|
|
81
|
+
const DEFAULT_THRESHOLDS = {
|
|
82
|
+
/** Minimum Jaccard similarity to consider nodes related. */
|
|
83
|
+
similarityThreshold: 0.35,
|
|
84
|
+
/** Minimum similarity WITH polarity flip to flag a conflict. */
|
|
85
|
+
conflictThreshold: 0.78,
|
|
86
|
+
/** Maximum number of conflicts to return per scan. */
|
|
87
|
+
maxConflicts: 50,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Detect semantic conflicts between a candidate node and existing vault nodes.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} candidate - The new/updated memory node (parsed frontmatter).
|
|
94
|
+
* @param {object[]} existingNodes - Array of existing memory nodes.
|
|
95
|
+
* @param {object} [thresholds] - Override default thresholds.
|
|
96
|
+
* @returns {object[]} Array of conflict records enriched with provenance metadata.
|
|
97
|
+
*/
|
|
98
|
+
export function detectSemanticConflicts(candidate, existingNodes, thresholds = {}) {
|
|
99
|
+
const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
|
|
100
|
+
const conflicts = [];
|
|
101
|
+
|
|
102
|
+
if (candidate.type !== 'memory') return conflicts;
|
|
103
|
+
|
|
104
|
+
const candidateTokens = tokenize(
|
|
105
|
+
[candidate.title, candidate.subject, candidate.predicate, candidate.object,
|
|
106
|
+
candidate.sentiment_target, ...(candidate.tags || [])].join(' ')
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const existing of existingNodes) {
|
|
110
|
+
if (existing.type !== 'memory') continue;
|
|
111
|
+
if (existing.slug === candidate.slug) continue;
|
|
112
|
+
if (existing.status !== 'active') continue;
|
|
113
|
+
|
|
114
|
+
const existingTokens = tokenize(
|
|
115
|
+
[existing.title, existing.subject, existing.predicate, existing.object,
|
|
116
|
+
existing.sentiment_target, ...(existing.tags || [])].join(' ')
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const similarity = jaccard(candidateTokens, existingTokens);
|
|
120
|
+
|
|
121
|
+
if (similarity < t.similarityThreshold) continue;
|
|
122
|
+
|
|
123
|
+
const polarityFlip = hasPolarityFlip(candidate, existing);
|
|
124
|
+
|
|
125
|
+
if (polarityFlip && similarity >= t.conflictThreshold) {
|
|
126
|
+
conflicts.push({
|
|
127
|
+
type: 'conflict',
|
|
128
|
+
conflict_id: `conflict-${new Date().toISOString().split('T')[0]}-${crypto.randomBytes(3).toString('hex')}`,
|
|
129
|
+
status: 'pending',
|
|
130
|
+
new_slug: candidate.slug,
|
|
131
|
+
existing_slug: existing.slug,
|
|
132
|
+
similarity: Math.round(similarity * 1000) / 1000,
|
|
133
|
+
polarity_flip: true,
|
|
134
|
+
detected_at: new Date().toISOString(),
|
|
135
|
+
reason: `Polarity flip on overlapping semantic target with similarity ${similarity.toFixed(3)} ≥ ${t.conflictThreshold}`,
|
|
136
|
+
resolution: null,
|
|
137
|
+
resolved_at: null,
|
|
138
|
+
// Provenance metadata for auto-resolution
|
|
139
|
+
_new_node: candidate,
|
|
140
|
+
_existing_node: existing,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (conflicts.length >= t.maxConflicts) break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return conflicts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Run a full vault scan for semantic conflicts.
|
|
152
|
+
*
|
|
153
|
+
* Compares every active node against every other active node.
|
|
154
|
+
* Use sparingly — O(n²) complexity.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} vaultDir - Path to the memory vault directory.
|
|
157
|
+
* @param {object} [thresholds] - Override default thresholds.
|
|
158
|
+
* @returns {object[]} Array of conflict records (deduplicated by pair).
|
|
159
|
+
*/
|
|
160
|
+
export function scanVaultForConflicts(vaultDir, thresholds = {}) {
|
|
161
|
+
const nodes = loadNodes(vaultDir).filter(n => n.status === 'active');
|
|
162
|
+
const seen = new Set();
|
|
163
|
+
const conflicts = [];
|
|
164
|
+
|
|
165
|
+
for (const candidate of nodes) {
|
|
166
|
+
const others = nodes.filter(n => n.slug !== candidate.slug);
|
|
167
|
+
const found = detectSemanticConflicts(candidate, others, thresholds);
|
|
168
|
+
|
|
169
|
+
for (const conflict of found) {
|
|
170
|
+
// Deduplicate: sort the pair so we don't report A↔B and B↔A
|
|
171
|
+
const pairKey = [conflict.new_slug, conflict.existing_slug].sort().join('::');
|
|
172
|
+
if (!seen.has(pairKey)) {
|
|
173
|
+
seen.add(pairKey);
|
|
174
|
+
conflicts.push(conflict);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return conflicts;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Patch Conflict Detection ───────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Detect if a patch operation conflicts with the current file state.
|
|
186
|
+
*
|
|
187
|
+
* This implements optimistic concurrency: the caller supplies the
|
|
188
|
+
* expected content hash of the file they read before preparing the patch.
|
|
189
|
+
* If the current file has a different hash, someone else modified it.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} vaultRoot - Absolute path to the vault.
|
|
192
|
+
* @param {string} vfsPath - The VFS path being patched.
|
|
193
|
+
* @param {string} expectedHash - SHA-256 hash of the file content the patch was based on.
|
|
194
|
+
* @returns {{ conflicted: boolean, currentHash?: string, error?: string }}
|
|
195
|
+
*/
|
|
196
|
+
export function detectPatchConflict(vaultRoot, vfsPath, expectedHash) {
|
|
197
|
+
const cleaned = vfsPath.replace(/^\/+/, '');
|
|
198
|
+
const absPath = path.resolve(vaultRoot, cleaned);
|
|
199
|
+
|
|
200
|
+
if (!absPath.startsWith(path.resolve(vaultRoot))) {
|
|
201
|
+
return { conflicted: true, error: 'Path traversal detected.' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!fs.existsSync(absPath)) {
|
|
205
|
+
return { conflicted: false }; // New file — no conflict possible
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
209
|
+
const currentHash = crypto.createHash('sha256').update(content).digest('hex');
|
|
210
|
+
|
|
211
|
+
if (currentHash !== expectedHash) {
|
|
212
|
+
return {
|
|
213
|
+
conflicted: true,
|
|
214
|
+
currentHash,
|
|
215
|
+
error: `File '${vfsPath}' was modified since patch was prepared. Expected hash: ${expectedHash.slice(0, 12)}…, current: ${currentHash.slice(0, 12)}…`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { conflicted: false, currentHash };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Compute the content hash of a VFS file for use with detectPatchConflict.
|
|
224
|
+
*/
|
|
225
|
+
export function computeFileHash(vaultRoot, vfsPath) {
|
|
226
|
+
const cleaned = vfsPath.replace(/^\/+/, '');
|
|
227
|
+
const absPath = path.resolve(vaultRoot, cleaned);
|
|
228
|
+
if (!fs.existsSync(absPath)) return null;
|
|
229
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
230
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Auto-Resolution Engine ─────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Determine the source authority of a memory node.
|
|
237
|
+
* Returns 'user' for direct user/chat-created nodes, 'machine' for
|
|
238
|
+
* optimizer/dream-cycle generated nodes, 'unknown' otherwise.
|
|
239
|
+
*/
|
|
240
|
+
function sourceAuthority(node) {
|
|
241
|
+
const src = node.source?.type;
|
|
242
|
+
if (!src) return 'unknown';
|
|
243
|
+
const userSources = new Set(['chat', 'user', 'correction', 'mcp-client', 'steering', 'remember-cli', 'api-client']);
|
|
244
|
+
const machineSources = new Set(['dream-cycle', 'optimizer', 'kernel', 'auto-distill']);
|
|
245
|
+
if (userSources.has(src)) return 'user';
|
|
246
|
+
if (machineSources.has(src)) return 'machine';
|
|
247
|
+
return 'unknown';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check whether a node is a protected invariant (priority: absolute or immutable).
|
|
252
|
+
*/
|
|
253
|
+
function isProtectedInvariant(node) {
|
|
254
|
+
return node.priority === 'absolute' || node.immutable === true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Attempt to automatically resolve a conflict using tiered heuristics.
|
|
259
|
+
*
|
|
260
|
+
* Resolution tiers (checked in order):
|
|
261
|
+
*
|
|
262
|
+
* 1. **Protected invariant clash** — Both nodes are `priority: absolute` or
|
|
263
|
+
* `immutable: true`. Cannot auto-resolve; requires human review.
|
|
264
|
+
*
|
|
265
|
+
* 2. **User supersedes machine** — The newer node is from the user, the older
|
|
266
|
+
* is machine-generated. Auto-supersede (user intent wins).
|
|
267
|
+
*
|
|
268
|
+
* 3. **Machine vs user** — The newer node is machine-generated, the older is
|
|
269
|
+
* from the user. Auto-keep (user intent wins over machine inference).
|
|
270
|
+
*
|
|
271
|
+
* 4. **Recency with clear provenance** — Both nodes are from the same source
|
|
272
|
+
* authority. The newer one supersedes the older one (latest preference wins).
|
|
273
|
+
*
|
|
274
|
+
* 5. **Borderline similarity** — Similarity is between conflictThreshold and
|
|
275
|
+
* 0.90. Accept both, but reduce confidence on the older node.
|
|
276
|
+
*
|
|
277
|
+
* @param {object} conflict - The conflict record with _new_node and _existing_node.
|
|
278
|
+
* @returns {{ resolved: boolean, action: string, reason: string }}
|
|
279
|
+
*/
|
|
280
|
+
export function autoResolveConflict(conflict) {
|
|
281
|
+
const newNode = conflict._new_node;
|
|
282
|
+
const existingNode = conflict._existing_node;
|
|
283
|
+
|
|
284
|
+
if (!newNode || !existingNode) {
|
|
285
|
+
return { resolved: false, action: 'quarantine', reason: 'Missing node provenance metadata.' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const newAuth = sourceAuthority(newNode);
|
|
289
|
+
const existingAuth = sourceAuthority(existingNode);
|
|
290
|
+
const newIsProtected = isProtectedInvariant(newNode);
|
|
291
|
+
const existingIsProtected = isProtectedInvariant(existingNode);
|
|
292
|
+
|
|
293
|
+
// Tier 0: New user-created node always wins and auto-supersedes
|
|
294
|
+
if (newAuth === 'user') {
|
|
295
|
+
return {
|
|
296
|
+
resolved: true,
|
|
297
|
+
action: 'supersede',
|
|
298
|
+
winner: newNode.slug,
|
|
299
|
+
loser: existingNode.slug,
|
|
300
|
+
reason: `User-created candidate node '${newNode.slug}' automatically supersedes existing node '${existingNode.slug}' to keep the developer in the loop within the IDE context.`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Tier 1: Both are protected invariants — require human review
|
|
305
|
+
if (newIsProtected && existingIsProtected) {
|
|
306
|
+
return {
|
|
307
|
+
resolved: false,
|
|
308
|
+
action: 'quarantine',
|
|
309
|
+
reason: 'Both nodes are protected invariants (priority: absolute / immutable). Human review required.',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Tier 2: Protected invariant vs non-protected — the invariant always wins
|
|
314
|
+
if (existingIsProtected && !newIsProtected) {
|
|
315
|
+
return {
|
|
316
|
+
resolved: true,
|
|
317
|
+
action: 'keep',
|
|
318
|
+
winner: existingNode.slug,
|
|
319
|
+
loser: newNode.slug,
|
|
320
|
+
reason: `Existing node '${existingNode.slug}' is a protected invariant. New node auto-rejected.`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (newIsProtected && !existingIsProtected) {
|
|
324
|
+
return {
|
|
325
|
+
resolved: true,
|
|
326
|
+
action: 'supersede',
|
|
327
|
+
winner: newNode.slug,
|
|
328
|
+
loser: existingNode.slug,
|
|
329
|
+
reason: `New node '${newNode.slug}' is a protected invariant. Existing node auto-superseded.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Tier 3: User vs machine — user intent always wins
|
|
334
|
+
if (newAuth === 'user' && existingAuth === 'machine') {
|
|
335
|
+
return {
|
|
336
|
+
resolved: true,
|
|
337
|
+
action: 'supersede',
|
|
338
|
+
winner: newNode.slug,
|
|
339
|
+
loser: existingNode.slug,
|
|
340
|
+
reason: `User-created node supersedes machine-generated node '${existingNode.slug}'.`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (newAuth === 'machine' && existingAuth === 'user') {
|
|
344
|
+
return {
|
|
345
|
+
resolved: true,
|
|
346
|
+
action: 'keep',
|
|
347
|
+
winner: existingNode.slug,
|
|
348
|
+
loser: newNode.slug,
|
|
349
|
+
reason: `Machine-generated node rejected in favor of user-created node '${existingNode.slug}'.`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Tier 4: Same authority — latest preference wins (recency)
|
|
354
|
+
const newDate = new Date(newNode.updated || newNode.created || 0).getTime();
|
|
355
|
+
const existingDate = new Date(existingNode.updated || existingNode.created || 0).getTime();
|
|
356
|
+
|
|
357
|
+
if (newDate > existingDate) {
|
|
358
|
+
return {
|
|
359
|
+
resolved: true,
|
|
360
|
+
action: 'supersede',
|
|
361
|
+
winner: newNode.slug,
|
|
362
|
+
loser: existingNode.slug,
|
|
363
|
+
reason: `Same source authority ('${newAuth}'). Newer node wins by recency.`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (existingDate > newDate) {
|
|
367
|
+
return {
|
|
368
|
+
resolved: true,
|
|
369
|
+
action: 'keep',
|
|
370
|
+
winner: existingNode.slug,
|
|
371
|
+
loser: newNode.slug,
|
|
372
|
+
reason: `Same source authority ('${existingAuth}'). Existing node is more recent.`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Tier 5: Identical timestamps or truly ambiguous — quarantine
|
|
377
|
+
return {
|
|
378
|
+
resolved: false,
|
|
379
|
+
action: 'quarantine',
|
|
380
|
+
reason: 'Could not determine a clear winner. Identical provenance and timestamps.',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Apply the resolution result to the vault and conflict record.
|
|
386
|
+
*
|
|
387
|
+
* When auto-resolved:
|
|
388
|
+
* - The losing node is marked `status: superseded` and gets a `superseded_by` pointer.
|
|
389
|
+
* - The winning node gets a `supersedes` entry pointing to the loser.
|
|
390
|
+
* - The conflict record is marked `status: auto-resolved`.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} conflict - The conflict record.
|
|
393
|
+
* @param {object} resolution - The result from autoResolveConflict().
|
|
394
|
+
* @param {string} vaultDir - Path to the memory vault.
|
|
395
|
+
* @returns {object} Updated conflict record.
|
|
396
|
+
*/
|
|
397
|
+
export function applyAutoResolution(conflict, resolution, vaultDir) {
|
|
398
|
+
if (!resolution.resolved) return conflict;
|
|
399
|
+
|
|
400
|
+
const winner = resolution.winner;
|
|
401
|
+
const loser = resolution.loser;
|
|
402
|
+
|
|
403
|
+
// Update the losing node in the vault
|
|
404
|
+
if (vaultDir && conflict._existing_node && conflict._new_node) {
|
|
405
|
+
const loserNode = resolution.action === 'keep' ? conflict._new_node : conflict._existing_node;
|
|
406
|
+
const winnerNode = resolution.action === 'keep' ? conflict._existing_node : conflict._new_node;
|
|
407
|
+
|
|
408
|
+
// Mark the loser as superseded
|
|
409
|
+
loserNode.status = 'superseded';
|
|
410
|
+
loserNode.superseded_by = winner;
|
|
411
|
+
loserNode.updated = new Date().toISOString();
|
|
412
|
+
|
|
413
|
+
// Add the loser to the winner's supersedes list
|
|
414
|
+
if (!winnerNode.supersedes) winnerNode.supersedes = [];
|
|
415
|
+
if (!winnerNode.supersedes.includes(loser)) {
|
|
416
|
+
winnerNode.supersedes.push(loser);
|
|
417
|
+
}
|
|
418
|
+
winnerNode.updated = new Date().toISOString();
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
writeNode(loserNode, vaultDir);
|
|
422
|
+
writeNode(winnerNode, vaultDir);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
logger.error('conflict-detector', `Failed to apply auto-resolution: ${err.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Update the conflict record itself
|
|
429
|
+
conflict.status = 'auto-resolved';
|
|
430
|
+
conflict.resolution = `${resolution.action}: ${winner}`;
|
|
431
|
+
conflict.resolution_reason = resolution.reason;
|
|
432
|
+
conflict.resolved_at = new Date().toISOString();
|
|
433
|
+
|
|
434
|
+
return conflict;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Detect conflicts and attempt auto-resolution. Only quarantine what
|
|
439
|
+
* cannot be resolved automatically.
|
|
440
|
+
*
|
|
441
|
+
* This is the primary entry point for conflict handling.
|
|
442
|
+
*
|
|
443
|
+
* @param {object} candidate - The new memory node.
|
|
444
|
+
* @param {object[]} existingNodes - Existing vault nodes.
|
|
445
|
+
* @param {object} options - { vaultDir, inboxDir, thresholds }
|
|
446
|
+
* @returns {{ autoResolved: object[], quarantined: object[] }}
|
|
447
|
+
*/
|
|
448
|
+
export function detectAndResolve(candidate, existingNodes, options = {}) {
|
|
449
|
+
const { vaultDir, inboxDir, thresholds } = options;
|
|
450
|
+
const conflicts = detectSemanticConflicts(candidate, existingNodes, thresholds);
|
|
451
|
+
|
|
452
|
+
const autoResolved = [];
|
|
453
|
+
const quarantined = [];
|
|
454
|
+
|
|
455
|
+
for (const conflict of conflicts) {
|
|
456
|
+
const resolution = autoResolveConflict(conflict);
|
|
457
|
+
|
|
458
|
+
if (resolution.resolved) {
|
|
459
|
+
const resolved = applyAutoResolution(conflict, resolution, vaultDir);
|
|
460
|
+
autoResolved.push(resolved);
|
|
461
|
+
logger.info('conflict-detector', `Auto-resolved ${conflict.conflict_id}: ${resolution.reason}`);
|
|
462
|
+
} else {
|
|
463
|
+
quarantined.push(conflict);
|
|
464
|
+
logger.info('conflict-detector', `Quarantined ${conflict.conflict_id}: ${resolution.reason}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Only write quarantined conflicts to the inbox for human review
|
|
469
|
+
if (inboxDir && quarantined.length > 0) {
|
|
470
|
+
writeConflicts(quarantined, inboxDir);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Write auto-resolved records as well (for audit trail)
|
|
474
|
+
if (inboxDir && autoResolved.length > 0) {
|
|
475
|
+
writeConflicts(autoResolved, inboxDir);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return { autoResolved, quarantined };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── Conflict Persistence ───────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Write conflict records to the inbox conflicts directory.
|
|
485
|
+
*
|
|
486
|
+
* @param {object[]} conflicts - Array of conflict records.
|
|
487
|
+
* @param {string} inboxDir - Path to the memory-inbox directory.
|
|
488
|
+
*/
|
|
489
|
+
export function writeConflicts(conflicts, inboxDir) {
|
|
490
|
+
const conflictsDir = path.join(inboxDir, 'conflicts');
|
|
491
|
+
if (!fs.existsSync(conflictsDir)) {
|
|
492
|
+
fs.mkdirSync(conflictsDir, { recursive: true });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const conflict of conflicts) {
|
|
496
|
+
const filePath = path.join(conflictsDir, `${conflict.conflict_id}.md`);
|
|
497
|
+
// Strip internal provenance refs before persisting
|
|
498
|
+
const { _new_node, _existing_node, ...frontmatter } = conflict;
|
|
499
|
+
const body = `Conflict detected between \`${conflict.new_slug}\` and \`${conflict.existing_slug}\`.\n\nReason: ${conflict.reason}`;
|
|
500
|
+
const raw = safeStringify(body, frontmatter);
|
|
501
|
+
atomicWrite(filePath, raw);
|
|
502
|
+
logger.info('conflict-detector', `Conflict written: ${conflict.conflict_id}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Resolve a conflict by choosing which slug wins.
|
|
508
|
+
*
|
|
509
|
+
* @param {string} conflictId - The conflict_id to resolve.
|
|
510
|
+
* @param {string} inboxDir - Path to the memory-inbox directory.
|
|
511
|
+
* @param {'keep' | 'supersede'} action - Whether to keep the existing or let new supersede.
|
|
512
|
+
* @param {string} winnerSlug - The slug that wins.
|
|
513
|
+
* @returns {{ resolved: boolean, error?: string }}
|
|
514
|
+
*/
|
|
515
|
+
export function resolveConflict(conflictId, inboxDir, action, winnerSlug) {
|
|
516
|
+
const conflictPath = path.join(inboxDir, 'conflicts', `${conflictId}.md`);
|
|
517
|
+
if (!fs.existsSync(conflictPath)) {
|
|
518
|
+
return { resolved: false, error: `Conflict '${conflictId}' not found.` };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const raw = fs.readFileSync(conflictPath, 'utf8');
|
|
523
|
+
const { data, content } = matter(raw);
|
|
524
|
+
|
|
525
|
+
data.status = 'resolved';
|
|
526
|
+
data.resolved_at = new Date().toISOString();
|
|
527
|
+
data.resolution = `${action}: ${winnerSlug}`;
|
|
528
|
+
|
|
529
|
+
const updated = safeStringify(content, data);
|
|
530
|
+
atomicWrite(conflictPath, updated);
|
|
531
|
+
|
|
532
|
+
return { resolved: true };
|
|
533
|
+
} catch (err) {
|
|
534
|
+
return { resolved: false, error: err.message };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
import { brainDir } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
const BRAIN_DIR = brainDir;
|
|
11
|
+
const SECRETS_FILE = path.join(BRAIN_DIR, 'config', 'secrets.enc');
|
|
12
|
+
|
|
13
|
+
const scryptAsync = promisify(crypto.scrypt);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derives a 32-byte encryption key from a master password using scrypt
|
|
17
|
+
* (Node built-in — no native dependency required).
|
|
18
|
+
*
|
|
19
|
+
* Parameters chosen for ~64 MB of memory, matching OWASP scrypt guidance.
|
|
20
|
+
*/
|
|
21
|
+
export async function deriveKey(password, salt) {
|
|
22
|
+
return scryptAsync(password, salt, 32, {
|
|
23
|
+
N: 2 ** 16, // CPU/memory cost: 65536
|
|
24
|
+
r: 8,
|
|
25
|
+
p: 1,
|
|
26
|
+
maxmem: 128 * 1024 * 1024
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Encrypts a JSON object into a buffer.
|
|
32
|
+
* Format: [16 bytes salt][12 bytes IV][ciphertext][16 bytes auth tag]
|
|
33
|
+
*/
|
|
34
|
+
export async function encryptSecrets(secretsObj, password) {
|
|
35
|
+
const salt = crypto.randomBytes(16);
|
|
36
|
+
const key = await deriveKey(password, salt);
|
|
37
|
+
const iv = crypto.randomBytes(12);
|
|
38
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
39
|
+
|
|
40
|
+
const plaintext = JSON.stringify(secretsObj);
|
|
41
|
+
let ciphertext = cipher.update(plaintext, 'utf8');
|
|
42
|
+
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
|
|
43
|
+
const authTag = cipher.getAuthTag();
|
|
44
|
+
|
|
45
|
+
return Buffer.concat([salt, iv, ciphertext, authTag]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function decryptSecrets(encryptedBuffer, password) {
|
|
49
|
+
const salt = encryptedBuffer.subarray(0, 16);
|
|
50
|
+
const iv = encryptedBuffer.subarray(16, 28);
|
|
51
|
+
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
|
|
52
|
+
const ciphertext = encryptedBuffer.subarray(28, encryptedBuffer.length - 16);
|
|
53
|
+
|
|
54
|
+
const key = await deriveKey(password, salt);
|
|
55
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
56
|
+
decipher.setAuthTag(authTag);
|
|
57
|
+
|
|
58
|
+
let decrypted = decipher.update(ciphertext);
|
|
59
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
60
|
+
|
|
61
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeSecrets(secretsObj, password) {
|
|
65
|
+
const buf = await encryptSecrets(secretsObj, password);
|
|
66
|
+
fs.mkdirSync(path.dirname(SECRETS_FILE), { recursive: true });
|
|
67
|
+
fs.writeFileSync(SECRETS_FILE, buf, { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function readSecrets(password) {
|
|
71
|
+
if (!fs.existsSync(SECRETS_FILE)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const buf = fs.readFileSync(SECRETS_FILE);
|
|
75
|
+
return await decryptSecrets(buf, password);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generates an Ed25519 keypair for signing releases.
|
|
80
|
+
*/
|
|
81
|
+
export function generateSignatureKeyPair() {
|
|
82
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
83
|
+
return {
|
|
84
|
+
publicKey: publicKey.export({ type: 'spki', format: 'pem' }),
|
|
85
|
+
privateKey: privateKey.export({ type: 'pkcs8', format: 'pem' })
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Signs a payload using an Ed25519 private key.
|
|
91
|
+
*/
|
|
92
|
+
export function signPayload(payload, privateKeyPem) {
|
|
93
|
+
const sign = crypto.createSign('sha512');
|
|
94
|
+
sign.update(payload);
|
|
95
|
+
sign.end();
|
|
96
|
+
return sign.sign(privateKeyPem, 'base64');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verifies a signature using an Ed25519 public key.
|
|
101
|
+
*/
|
|
102
|
+
export function verifySignature(payload, signatureBase64, publicKeyPem) {
|
|
103
|
+
const verify = crypto.createVerify('sha512');
|
|
104
|
+
verify.update(payload);
|
|
105
|
+
verify.end();
|
|
106
|
+
return verify.verify(publicKeyPem, signatureBase64, 'base64');
|
|
107
|
+
}
|