total-recall-brain 3.18.3 → 3.19.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/frontend/dist/assets/{index-CGqvdwHT.js → index-B6AfuKv9.js} +115 -115
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/update.ts +29 -4
- package/frontend/src/pages/HealthPage.tsx +12 -16
- package/frontend/src/pages/SettingsPage.tsx +22 -1
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +15 -1
- package/src/cli/chat.mjs +1 -1
- package/src/cli/daemon.mjs +2 -1
- package/src/cli/dream.mjs +14 -10
- package/src/cli/forget.mjs +14 -2
- package/src/cli/friction.mjs +1 -1
- package/src/cli/import-rules.mjs +8 -3
- package/src/cli/ingest.mjs +3 -2
- package/src/cli/lint.mjs +9 -6
- package/src/cli/rebuild.mjs +9 -4
- package/src/cli/remember.mjs +21 -15
- package/src/cli/secret.mjs +4 -1
- package/src/cli/share.mjs +5 -3
- package/src/cli/skill-projection.mjs +21 -2
- package/src/cli/snapshot.mjs +10 -8
- package/src/cli/update.mjs +2 -1
- package/src/core/clarity-rewriter.mjs +31 -12
- package/src/core/config.mjs +10 -4
- package/src/core/daemon-loop.mjs +2 -1
- package/src/core/embeddings.mjs +51 -3
- package/src/core/memory-title.mjs +70 -0
- package/src/core/runtime.mjs +11 -1
- package/src/core/secrets-store.mjs +7 -1
- package/src/core/skills-registry.mjs +12 -0
- package/src/core/surface.mjs +4 -4
- package/src/core/throttled-fetch.mjs +5 -0
- package/src/core/vault.mjs +7 -1
- package/src/server/index.mjs +56 -0
- package/src/server/route-manifest.json +16 -0
- package/src/server/routes/_shared.mjs +33 -1
- package/src/server/routes/context.mjs +14 -9
- package/src/server/routes/dashboard.mjs +39 -18
- package/src/server/routes/docs.mjs +27 -13
- package/src/server/routes/dream.mjs +10 -11
- package/src/server/routes/export.mjs +43 -12
- package/src/server/routes/field.mjs +11 -9
- package/src/server/routes/graph.mjs +31 -13
- package/src/server/routes/import.mjs +35 -16
- package/src/server/routes/instructions.mjs +41 -5
- package/src/server/routes/memory.mjs +61 -21
- package/src/server/routes/share.mjs +2 -1
- package/src/server/routes/ssss.mjs +11 -3
- package/src/server/routes/update.mjs +22 -3
- package/src/server/routes/vault.mjs +53 -28
|
@@ -567,24 +567,42 @@ export async function runCutoffAudit({ vaultDir, queueDir, runtimeConfig }) {
|
|
|
567
567
|
return { audited: batch.length, flagged, critical };
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
/** Resolve vault dir from node file path (…/memory-vault/category/slug.md). */
|
|
571
|
+
function vaultDirFromNodeFile(filePath) {
|
|
572
|
+
if (!filePath) return null;
|
|
573
|
+
let cur = path.dirname(filePath);
|
|
574
|
+
while (cur && cur !== path.dirname(cur)) {
|
|
575
|
+
if (path.basename(cur) === 'memory-vault') return cur;
|
|
576
|
+
cur = path.dirname(cur);
|
|
577
|
+
}
|
|
578
|
+
// Fallback: parent of category dir
|
|
579
|
+
return path.dirname(path.dirname(filePath));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function nodeFilePath(node) {
|
|
583
|
+
return node?._filepath || node?._filePath || null;
|
|
584
|
+
}
|
|
585
|
+
|
|
570
586
|
async function stampVerified(node) {
|
|
571
|
-
|
|
587
|
+
const fp = nodeFilePath(node);
|
|
588
|
+
if (!fp) return;
|
|
572
589
|
try {
|
|
573
|
-
const raw = fs.readFileSync(
|
|
590
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
574
591
|
const { data, content } = matter(raw);
|
|
575
592
|
data.x_cutoff_verified_at = new Date().toISOString();
|
|
576
593
|
data.x_cutoff_risk = 'none';
|
|
577
|
-
atomicWrite(
|
|
578
|
-
invalidate(
|
|
594
|
+
atomicWrite(fp, safeStringify(content, data));
|
|
595
|
+
invalidate(vaultDirFromNodeFile(fp));
|
|
579
596
|
} catch (err) {
|
|
580
597
|
logger.debug('clarity-rewriter: stampCutoffRiskNone failed', { err: err.message });
|
|
581
598
|
}
|
|
582
599
|
}
|
|
583
600
|
|
|
584
601
|
async function stampCutoffRisk(node, riskLevel, claimCount) {
|
|
585
|
-
|
|
602
|
+
const fp = nodeFilePath(node);
|
|
603
|
+
if (!fp) return;
|
|
586
604
|
try {
|
|
587
|
-
const raw = fs.readFileSync(
|
|
605
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
588
606
|
const { data, content } = matter(raw);
|
|
589
607
|
data.x_cutoff_risk = riskLevel;
|
|
590
608
|
data.x_cutoff_claim_count = claimCount;
|
|
@@ -592,8 +610,8 @@ async function stampCutoffRisk(node, riskLevel, claimCount) {
|
|
|
592
610
|
// Lower confidence to reflect that claims are unverified
|
|
593
611
|
const confidencePenalty = riskLevel === 'critical' ? 0.3 : riskLevel === 'high' ? 0.2 : 0.1;
|
|
594
612
|
data.confidence = Math.max(0.1, (data.confidence || 0.5) - confidencePenalty);
|
|
595
|
-
atomicWrite(
|
|
596
|
-
invalidate(
|
|
613
|
+
atomicWrite(fp, safeStringify(content, data));
|
|
614
|
+
invalidate(vaultDirFromNodeFile(fp));
|
|
597
615
|
} catch (err) {
|
|
598
616
|
logger.debug('clarity-rewriter: stampCutoffRisk failed', { err: err.message });
|
|
599
617
|
}
|
|
@@ -756,9 +774,10 @@ export async function writeCorrection(originalSlug, researchEvidence, { vaultDir
|
|
|
756
774
|
}
|
|
757
775
|
|
|
758
776
|
async function stampNodeForCorrection(node, correctionSlug, disposition) {
|
|
759
|
-
|
|
777
|
+
const fp = nodeFilePath(node);
|
|
778
|
+
if (!fp) return;
|
|
760
779
|
try {
|
|
761
|
-
const raw = fs.readFileSync(
|
|
780
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
762
781
|
const { data, content } = matter(raw);
|
|
763
782
|
data.status = disposition === 'deleted' ? 'archived' : 'superseded';
|
|
764
783
|
data.superseded_by = correctionSlug;
|
|
@@ -766,8 +785,8 @@ async function stampNodeForCorrection(node, correctionSlug, disposition) {
|
|
|
766
785
|
// Lower confidence significantly — this fact has been proven wrong
|
|
767
786
|
data.confidence = Math.max(0.05, (data.confidence || 0.5) * 0.3);
|
|
768
787
|
data.updated = new Date().toISOString();
|
|
769
|
-
atomicWrite(
|
|
770
|
-
invalidate(
|
|
788
|
+
atomicWrite(fp, safeStringify(content, data));
|
|
789
|
+
invalidate(vaultDirFromNodeFile(fp));
|
|
771
790
|
} catch (err) {
|
|
772
791
|
logger.debug('clarity-rewriter: stampNodeForCorrection failed', { err: err.message });
|
|
773
792
|
}
|
package/src/core/config.mjs
CHANGED
|
@@ -48,6 +48,7 @@ const configSchema = z.object({
|
|
|
48
48
|
cliModel: z.string().optional(),
|
|
49
49
|
cliTimeout: z.preprocess((val) => val === undefined || val === '' ? 300 : parseInt(String(val), 10), z.number().int().default(300)),
|
|
50
50
|
googleApiKey: z.string().optional(),
|
|
51
|
+
openRouterApiKey: z.string().optional(),
|
|
51
52
|
embedModel: z.string().default('gemini-embedding-2'),
|
|
52
53
|
searxngBaseUrl: z.string().optional(),
|
|
53
54
|
braveApiKey: z.string().optional(),
|
|
@@ -151,10 +152,13 @@ try {
|
|
|
151
152
|
const isConfigPath = p.includes(path.join('config', 'secrets.enc'));
|
|
152
153
|
const brainDir = isConfigPath ? path.dirname(path.dirname(p)) : path.dirname(p);
|
|
153
154
|
const parsed = loadSecretsSync(brainDir);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
// Merge (don't stop at the first non-empty file): an earlier, sparser
|
|
156
|
+
// store (e.g. the global ~/.agent/secrets.enc root) was previously
|
|
157
|
+
// short-circuiting the search and silently hiding real secrets that
|
|
158
|
+
// only existed in a later, richer store (e.g. the total-recall
|
|
159
|
+
// skill's own config/secrets.enc). Earlier candidates still win on
|
|
160
|
+
// key collisions — this only fills in keys the earlier ones lack.
|
|
161
|
+
secrets = { ...parsed, ...secrets };
|
|
158
162
|
} catch {}
|
|
159
163
|
}
|
|
160
164
|
}
|
|
@@ -178,6 +182,7 @@ const rawConfig = {
|
|
|
178
182
|
cliModel: process.env.TR_CLI_MODEL,
|
|
179
183
|
cliTimeout: process.env.TR_CLI_TIMEOUT,
|
|
180
184
|
googleApiKey: process.env.GOOGLE_API_KEY || secrets.google_api_key,
|
|
185
|
+
openRouterApiKey: process.env.OPENROUTER_API_KEY || secrets.openrouter_api_key || secrets.OPENROUTER_API_KEY,
|
|
181
186
|
embedModel: process.env.TR_EMBED_MODEL,
|
|
182
187
|
searxngBaseUrl: process.env.SEARXNG_BASE_URL,
|
|
183
188
|
braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key,
|
|
@@ -225,6 +230,7 @@ export const {
|
|
|
225
230
|
cliModel,
|
|
226
231
|
cliTimeout,
|
|
227
232
|
googleApiKey,
|
|
233
|
+
openRouterApiKey,
|
|
228
234
|
embedModel,
|
|
229
235
|
searxngBaseUrl,
|
|
230
236
|
braveApiKey,
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -31,7 +31,8 @@ const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
|
|
|
31
31
|
const CONFLICTS_DIR = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
|
|
32
32
|
const SESSIONS_DIR = path.join(BRAIN_DIR, 'sessions');
|
|
33
33
|
const QUEUE_DIR = path.join(BRAIN_DIR, 'scheduler', 'queue');
|
|
34
|
-
|
|
34
|
+
// Surface shims resolve from the agent root (.agent/), not brainDir (…/skills/total-recall/)
|
|
35
|
+
const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
|
|
35
36
|
|
|
36
37
|
// How often to recompile surfaces (every N task ticks)
|
|
37
38
|
const RECOMPILE_EVERY_N_TASKS = 20;
|
package/src/core/embeddings.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getVssLoadablePath,
|
|
21
21
|
} from 'sqlite-vss';
|
|
22
22
|
|
|
23
|
-
import { googleApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
|
|
23
|
+
import { googleApiKey, openRouterApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
|
|
24
24
|
import { logger } from './logger.mjs';
|
|
25
25
|
|
|
26
26
|
const DEFAULT_EMBED_MODEL = embedModel;
|
|
@@ -34,6 +34,10 @@ function resolveOpenAiApiKey() {
|
|
|
34
34
|
return process.env.OPENAI_API_KEY || null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function resolveOpenRouterApiKey() {
|
|
38
|
+
return openRouterApiKey || process.env.OPENROUTER_API_KEY || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
let dbInstance = null;
|
|
38
42
|
let currentDbPath = null;
|
|
39
43
|
/** @type {WeakMap<object, boolean>} */
|
|
@@ -227,6 +231,37 @@ async function getGoogleEmbedding(text, model) {
|
|
|
227
231
|
return data.embedding.values;
|
|
228
232
|
}
|
|
229
233
|
|
|
234
|
+
async function getOpenRouterEmbedding(text, model = 'openai/text-embedding-3-small') {
|
|
235
|
+
const openRouterKey = resolveOpenRouterApiKey();
|
|
236
|
+
if (!openRouterKey) {
|
|
237
|
+
throw new Error('OPENROUTER_API_KEY not set. Cannot generate embeddings.');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const res = await throttledFetch('https://openrouter.ai/api/v1/embeddings', {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: {
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
'Authorization': `Bearer ${openRouterKey}`,
|
|
245
|
+
},
|
|
246
|
+
// Request 768 dims to match the existing vault vss0(embedding(768)) schema
|
|
247
|
+
// and stay comparable against previously-stored Google embeddings.
|
|
248
|
+
body: JSON.stringify({ model, input: text, dimensions: 768 }),
|
|
249
|
+
signal: AbortSignal.timeout(30000),
|
|
250
|
+
}, 20000);
|
|
251
|
+
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
const body = await res.text().catch(() => '');
|
|
254
|
+
throw new Error(`OpenRouter embedding API error ${res.status}: ${body || 'no response body'}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const data = await res.json();
|
|
258
|
+
if (!data.data?.[0]?.embedding) {
|
|
259
|
+
throw new Error('OpenRouter returned no embedding vector');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return data.data[0].embedding;
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
231
266
|
const openaiKey = resolveOpenAiApiKey();
|
|
232
267
|
if (!openaiKey) {
|
|
@@ -258,11 +293,24 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
|
258
293
|
|
|
259
294
|
export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
260
295
|
const input = String(text).slice(0, 8000);
|
|
296
|
+
const openRouterKey = resolveOpenRouterApiKey();
|
|
261
297
|
const googleKey = resolveGoogleApiKey();
|
|
262
298
|
const openaiKey = resolveOpenAiApiKey();
|
|
263
299
|
|
|
264
300
|
let embedding;
|
|
265
|
-
|
|
301
|
+
|
|
302
|
+
// OpenRouter first: standing preference (Gemini's budget is exhausted, so
|
|
303
|
+
// Google is a near-guaranteed slow failure right now — trying it first just
|
|
304
|
+
// taxes every call with a ~15-20s timeout before falling through).
|
|
305
|
+
if (openRouterKey) {
|
|
306
|
+
try {
|
|
307
|
+
embedding = await getOpenRouterEmbedding(input);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
logger.debug('embeddings: OpenRouter failed, trying Google fallback', { err: err.message });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!embedding && googleKey) {
|
|
266
314
|
try {
|
|
267
315
|
embedding = await getGoogleEmbedding(input, model);
|
|
268
316
|
} catch (err) {
|
|
@@ -280,7 +328,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
|
280
328
|
}
|
|
281
329
|
|
|
282
330
|
if (!embedding) {
|
|
283
|
-
throw new Error('No embedding provider available. Set GOOGLE_API_KEY or OPENAI_API_KEY.');
|
|
331
|
+
throw new Error('No embedding provider available. Set OPENROUTER_API_KEY, GOOGLE_API_KEY, or OPENAI_API_KEY.');
|
|
284
332
|
}
|
|
285
333
|
|
|
286
334
|
return embedding;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory title helpers — provenance belongs in source/tags, not a title prefix.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const SELF_CAPTURED_PREFIX_RE = /^Self-captured memory:\s*/i;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Strip legacy auto-title prefix if present.
|
|
9
|
+
* @param {string} title
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function stripSelfCapturedTitlePrefix(title) {
|
|
13
|
+
return String(title || '').replace(SELF_CAPTURED_PREFIX_RE, '').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True when title is the old auto-generated echo of the body.
|
|
18
|
+
* @param {string} title
|
|
19
|
+
* @param {string} [body]
|
|
20
|
+
*/
|
|
21
|
+
export function isSelfCapturedEchoTitle(title, body = '') {
|
|
22
|
+
const raw = String(title || '').trim();
|
|
23
|
+
if (SELF_CAPTURED_PREFIX_RE.test(raw)) return true;
|
|
24
|
+
const stripped = stripSelfCapturedTitlePrefix(raw);
|
|
25
|
+
const text = String(body || '').replace(/\s+/g, ' ').trim();
|
|
26
|
+
if (!stripped || !text) return false;
|
|
27
|
+
const head = stripped.replace(/\.\.\.$/, '').trim().toLowerCase();
|
|
28
|
+
if (head.length < 12) return false;
|
|
29
|
+
return text.toLowerCase().startsWith(head.slice(0, Math.min(40, head.length)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a short human title from body content (no provenance prefix).
|
|
34
|
+
* Uses first sentence when reasonable, else first ~100 chars.
|
|
35
|
+
* @param {string} body
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function defaultTitleFromBody(body) {
|
|
39
|
+
const oneLine = String(body || '')
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim();
|
|
42
|
+
if (!oneLine) return 'Untitled memory';
|
|
43
|
+
|
|
44
|
+
// Prefer first sentence
|
|
45
|
+
const m = oneLine.match(/^(.{12,}?[.!?])(\s|$)/);
|
|
46
|
+
let candidate = m ? m[1].trim() : oneLine;
|
|
47
|
+
|
|
48
|
+
// Avoid absurdly long single-sentence titles
|
|
49
|
+
if (candidate.length > 100) {
|
|
50
|
+
candidate = candidate.slice(0, 97).replace(/\s+\S*$/, '').trim() + '...';
|
|
51
|
+
}
|
|
52
|
+
return candidate || oneLine.slice(0, 100);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize an existing title that may carry the legacy prefix.
|
|
57
|
+
* If after strip the title is empty or still an echo of body, rebuild from body.
|
|
58
|
+
* @param {string} title
|
|
59
|
+
* @param {string} body
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeMemoryTitle(title, body) {
|
|
63
|
+
const stripped = stripSelfCapturedTitlePrefix(title);
|
|
64
|
+
if (!stripped) return defaultTitleFromBody(body);
|
|
65
|
+
if (isSelfCapturedEchoTitle(title, body) || isSelfCapturedEchoTitle(stripped, body)) {
|
|
66
|
+
// Prefer full first sentence from body over truncated echo
|
|
67
|
+
return defaultTitleFromBody(body);
|
|
68
|
+
}
|
|
69
|
+
return stripped;
|
|
70
|
+
}
|
package/src/core/runtime.mjs
CHANGED
|
@@ -139,12 +139,22 @@ export function loadRuntimeConfig(_configPath) {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
// Tier D: Check SSSS Compiled Memory Preference Surface
|
|
142
|
+
// Surfaces live at agent root / repo root — not under brainDir (…/skills/total-recall/)
|
|
142
143
|
if (!preferredAgent) {
|
|
143
144
|
try {
|
|
144
145
|
const checkPaths = [
|
|
146
|
+
path.join(agentDir, 'INSTRUCTIONS.md'),
|
|
147
|
+
path.join(agentDir, 'AGENTS.md'),
|
|
148
|
+
path.join(agentDir, 'GEMINI.md'),
|
|
149
|
+
path.join(path.dirname(agentDir), 'INSTRUCTIONS.md'),
|
|
150
|
+
path.join(path.dirname(agentDir), 'AGENTS.md'),
|
|
151
|
+
path.join(process.cwd(), 'INSTRUCTIONS.md'),
|
|
152
|
+
path.join(process.cwd(), 'AGENTS.md'),
|
|
153
|
+
path.join(process.cwd(), 'Claude.md'),
|
|
154
|
+
// legacy mistaken brainDir locations (kept as last resort)
|
|
145
155
|
path.join(brainDir, 'INSTRUCTIONS.md'),
|
|
146
156
|
path.join(brainDir, 'GEMINI.md'),
|
|
147
|
-
path.join(brainDir, 'AGENTS.md')
|
|
157
|
+
path.join(brainDir, 'AGENTS.md'),
|
|
148
158
|
];
|
|
149
159
|
for (const p of checkPaths) {
|
|
150
160
|
if (fs.existsSync(p)) {
|
|
@@ -760,7 +760,13 @@ export async function setSecret(brainDir, key, value, opts = {}) {
|
|
|
760
760
|
});
|
|
761
761
|
|
|
762
762
|
// First-time set only: optionally queue product-level API research (not raw key-name scraping).
|
|
763
|
-
|
|
763
|
+
// Skip under Vitest / NODE_ENV=test — enqueue can hang on network and blow 5s timeouts.
|
|
764
|
+
// Force research in tests with skip_integration_research: false.
|
|
765
|
+
const inTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
|
|
766
|
+
const skipResearch =
|
|
767
|
+
opts.skip_integration_research === true ||
|
|
768
|
+
(inTest && opts.skip_integration_research !== false);
|
|
769
|
+
if (!prev.created_at && !skipResearch) {
|
|
764
770
|
try {
|
|
765
771
|
const { maybeEnqueueIntegrationResearch } = await import('./secret-integration-research.mjs');
|
|
766
772
|
const result = await maybeEnqueueIntegrationResearch(brainDir, key, {
|
|
@@ -552,6 +552,18 @@ export function isRepoScopedSkill(brainDir, skillId) {
|
|
|
552
552
|
entry?.source_path,
|
|
553
553
|
...registry.installs.filter((install) => install.skill_id === skillId).map((install) => install.path),
|
|
554
554
|
].filter(Boolean);
|
|
555
|
+
// Also examine copies not yet tracked in the registry. collectSkillLocations()
|
|
556
|
+
// (what syncSkillTwoWay actually acts on) additionally scans every known repo
|
|
557
|
+
// for live-but-undiscovered copies of this skill id. Without doing the same
|
|
558
|
+
// here, the very first sync that ever touches a newly-known repo only sees
|
|
559
|
+
// the catalog source as a candidate, never notices that repo's same-named
|
|
560
|
+
// skill diverges, and lets an unrelated same-named skill silently clobber it
|
|
561
|
+
// before either copy is ever registered.
|
|
562
|
+
for (const repo of loadKnownRepoRoots(brainDir)) {
|
|
563
|
+
for (const hit of discoverSkillsInRepo(repo)) {
|
|
564
|
+
if (hit.id === skillId) candidates.push(hit.path);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
555
567
|
const seen = new Set();
|
|
556
568
|
const liveRepos = new Set();
|
|
557
569
|
const liveHashes = new Set();
|
package/src/core/surface.mjs
CHANGED
|
@@ -186,16 +186,16 @@ function modalityMarker(node) {
|
|
|
186
186
|
*/
|
|
187
187
|
export function heuristicCompact(node) {
|
|
188
188
|
const rawTitle = (node.title || '').trim();
|
|
189
|
+
// Legacy prefix only — provenance is source.type / tags, not title text
|
|
189
190
|
const title = rawTitle.replace(/^Self-captured memory:\s*/i, '').trim();
|
|
190
191
|
const text = (node.body || node.content || '').trim();
|
|
191
192
|
const marker = modalityMarker(node);
|
|
192
193
|
const prefix = marker ? `${marker} ` : '';
|
|
193
194
|
|
|
194
195
|
// Determine the best display text, avoiding title/body duplication.
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
(text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().slice(0, 20)));
|
|
196
|
+
// Echo titles (legacy "Self-captured…" or title that is just a body prefix) use body only.
|
|
197
|
+
const titleIsEcho = /^Self-captured memory:/i.test(rawTitle) ||
|
|
198
|
+
(text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().replace(/\.\.\.$/, '').slice(0, 20)));
|
|
199
199
|
|
|
200
200
|
if (node.category && ['invariants', 'preferences', 'anti-patterns'].includes(node.category)) {
|
|
201
201
|
if (text) {
|
|
@@ -140,6 +140,9 @@ export async function loadFirewallPolicy(brainDir) {
|
|
|
140
140
|
policyWatcher = fs.watch(policyPath, (eventType) => {
|
|
141
141
|
if (eventType === 'change') applyPolicy();
|
|
142
142
|
});
|
|
143
|
+
// Unref so this watcher does not prevent one-shot CLI commands (e.g.
|
|
144
|
+
// `recall`) from exiting cleanly once their work is done.
|
|
145
|
+
policyWatcher.unref();
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
// Also watch the parent directory so we pick up the policy file being
|
|
@@ -153,8 +156,10 @@ export async function loadFirewallPolicy(brainDir) {
|
|
|
153
156
|
policyWatcher = fs.watch(policyPath, (evt) => {
|
|
154
157
|
if (evt === 'change') applyPolicy();
|
|
155
158
|
});
|
|
159
|
+
policyWatcher.unref();
|
|
156
160
|
}
|
|
157
161
|
});
|
|
162
|
+
parentWatcher.unref();
|
|
158
163
|
}
|
|
159
164
|
} catch (err) {
|
|
160
165
|
logger.error('throttled-fetch', `Failed to load network policy: ${err.message}`);
|
package/src/core/vault.mjs
CHANGED
|
@@ -76,7 +76,13 @@ export function loadNodes(vaultDir) {
|
|
|
76
76
|
const raw = fs.readFileSync(file, 'utf8');
|
|
77
77
|
const { data, content } = matter(raw);
|
|
78
78
|
if (data.type === 'memory') {
|
|
79
|
-
|
|
79
|
+
// Attach absolute path for stampers/rewriters (both spellings used in codebase)
|
|
80
|
+
nodes.push({
|
|
81
|
+
...data,
|
|
82
|
+
body: content.trim(),
|
|
83
|
+
_filePath: file,
|
|
84
|
+
_filepath: file,
|
|
85
|
+
});
|
|
80
86
|
}
|
|
81
87
|
} catch (err) {
|
|
82
88
|
logger.warn('vault', `Failed to parse ${file}`, { err: err.message });
|
package/src/server/index.mjs
CHANGED
|
@@ -43,6 +43,51 @@ const PACKAGE_VERSION = (() => {
|
|
|
43
43
|
}
|
|
44
44
|
})();
|
|
45
45
|
|
|
46
|
+
// ─── PID Lock ───────────────────────────────────────────────────────────────────
|
|
47
|
+
// A second instance that loses the listen() race for PORT has historically kept
|
|
48
|
+
// running indefinitely (no bound port, no work, never exiting) because nothing
|
|
49
|
+
// handled the EADDRINUSE 'error' event. Fail fast instead, mirroring the daemon
|
|
50
|
+
// loop's acquirePidLock/releasePidLock pattern.
|
|
51
|
+
const SERVER_PID_FILE = path.join(configBrainDir, 'server.pid');
|
|
52
|
+
|
|
53
|
+
function acquireServerPidLock() {
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(SERVER_PID_FILE)) {
|
|
56
|
+
const existingPid = parseInt(fs.readFileSync(SERVER_PID_FILE, 'utf8').trim(), 10);
|
|
57
|
+
if (existingPid && !isNaN(existingPid)) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(existingPid, 0); // signal 0 = liveness check only
|
|
60
|
+
logger.error('server', `Another server instance is already running (PID: ${existingPid}). Exiting.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
} catch {
|
|
63
|
+
logger.info('server', `Stale server PID file found (PID: ${existingPid} is dead). Overwriting.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// PID file unreadable — proceed
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
fs.mkdirSync(path.dirname(SERVER_PID_FILE), { recursive: true });
|
|
72
|
+
fs.writeFileSync(SERVER_PID_FILE, String(process.pid), { mode: 0o644 });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn('server', `Could not write server PID lockfile: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function releaseServerPidLock() {
|
|
79
|
+
try {
|
|
80
|
+
if (fs.existsSync(SERVER_PID_FILE)) {
|
|
81
|
+
const storedPid = parseInt(fs.readFileSync(SERVER_PID_FILE, 'utf8').trim(), 10);
|
|
82
|
+
if (storedPid === process.pid) fs.unlinkSync(SERVER_PID_FILE);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// best-effort cleanup
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
acquireServerPidLock();
|
|
90
|
+
|
|
46
91
|
// ─── Watchdog ───────────────────────────────────────────────────────────────────
|
|
47
92
|
// Attach circuit-breaker log monitor before any subsystem can emit events.
|
|
48
93
|
import { attachLogMonitor } from '../core/watchdog.mjs';
|
|
@@ -699,6 +744,11 @@ const server = app.listen(PORT, HOST, () => {
|
|
|
699
744
|
}
|
|
700
745
|
logger.info("server", `Total Recall Brain v3.0.0 is listening on http://${HOST}:${PORT}`);
|
|
701
746
|
});
|
|
747
|
+
server.on('error', (err) => {
|
|
748
|
+
logger.error('server', `Failed to bind ${HOST}:${PORT}: ${err.message}. Exiting.`);
|
|
749
|
+
releaseServerPidLock();
|
|
750
|
+
process.exit(1);
|
|
751
|
+
});
|
|
702
752
|
|
|
703
753
|
// If bound to a mesh IP, also bind to 127.0.0.1 so local CLI/Dashboard works
|
|
704
754
|
if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
|
|
@@ -706,6 +756,11 @@ if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
|
|
|
706
756
|
setupUpgradeHandler(localServer);
|
|
707
757
|
logger.info("server", `Total Recall Brain v3.0.0 is ALSO listening on http://127.0.0.1:${PORT}`);
|
|
708
758
|
});
|
|
759
|
+
localServer.on('error', (err) => {
|
|
760
|
+
logger.error('server', `Failed to bind 127.0.0.1:${PORT}: ${err.message}. Exiting.`);
|
|
761
|
+
releaseServerPidLock();
|
|
762
|
+
process.exit(1);
|
|
763
|
+
});
|
|
709
764
|
}
|
|
710
765
|
|
|
711
766
|
logger.info("server", "┌─────────────────────────────────────────────┐");
|
|
@@ -940,6 +995,7 @@ async function handleShutdown(signal) {
|
|
|
940
995
|
}
|
|
941
996
|
|
|
942
997
|
clearTimeout(forceExitTimeout);
|
|
998
|
+
releaseServerPidLock();
|
|
943
999
|
logger.info('server', 'Graceful shutdown complete. Exiting.');
|
|
944
1000
|
process.exit(0);
|
|
945
1001
|
});
|
|
@@ -415,6 +415,10 @@
|
|
|
415
415
|
"method": "PATCH",
|
|
416
416
|
"path": "/api/secrets/:key"
|
|
417
417
|
},
|
|
418
|
+
{
|
|
419
|
+
"method": "POST",
|
|
420
|
+
"path": "/api/secrets/:key/account-sync"
|
|
421
|
+
},
|
|
418
422
|
{
|
|
419
423
|
"method": "POST",
|
|
420
424
|
"path": "/api/secrets/:key/reveal"
|
|
@@ -423,6 +427,10 @@
|
|
|
423
427
|
"method": "POST",
|
|
424
428
|
"path": "/api/secrets/:key/rotate"
|
|
425
429
|
},
|
|
430
|
+
{
|
|
431
|
+
"method": "POST",
|
|
432
|
+
"path": "/api/secrets/account-sync"
|
|
433
|
+
},
|
|
426
434
|
{
|
|
427
435
|
"method": "GET",
|
|
428
436
|
"path": "/api/secrets/checksum"
|
|
@@ -467,6 +475,10 @@
|
|
|
467
475
|
"method": "GET",
|
|
468
476
|
"path": "/api/secrets/scan-env"
|
|
469
477
|
},
|
|
478
|
+
{
|
|
479
|
+
"method": "GET",
|
|
480
|
+
"path": "/api/secrets/shared-values"
|
|
481
|
+
},
|
|
470
482
|
{
|
|
471
483
|
"method": "GET",
|
|
472
484
|
"path": "/api/secrets/sync"
|
|
@@ -483,6 +495,10 @@
|
|
|
483
495
|
"method": "POST",
|
|
484
496
|
"path": "/api/secrets/sync/trigger-pull"
|
|
485
497
|
},
|
|
498
|
+
{
|
|
499
|
+
"method": "GET",
|
|
500
|
+
"path": "/api/secrets/tracking-health"
|
|
501
|
+
},
|
|
486
502
|
{
|
|
487
503
|
"method": "GET",
|
|
488
504
|
"path": "/api/secrets/usage"
|
|
@@ -113,6 +113,29 @@ export function resolveAllVaultsFromQuery(req) {
|
|
|
113
113
|
return uniqueVaults.length > 0 ? uniqueVaults : [VAULT_DIR];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Derive brain-local paths from a vault directory.
|
|
118
|
+
* vaultDir is always `…/<brain>/memory-vault`.
|
|
119
|
+
* Ensures project brains use their own derived indexes / surfaces, not global.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} vaultDir
|
|
122
|
+
* @returns {{ brainDir: string, derivedDir: string, skillsDir: string, instructionsFile: string, sessionsDir: string, inboxDir: string }}
|
|
123
|
+
*/
|
|
124
|
+
export function pathsForVault(vaultDir) {
|
|
125
|
+
const brainDir = path.dirname(vaultDir);
|
|
126
|
+
// brainDir = …/.agent/skills/total-recall → agentDir = …/.agent
|
|
127
|
+
const skillsParent = path.dirname(brainDir); // …/skills
|
|
128
|
+
const agentDir = path.dirname(skillsParent); // …/.agent
|
|
129
|
+
return {
|
|
130
|
+
brainDir,
|
|
131
|
+
derivedDir: path.join(brainDir, 'memory-derived'),
|
|
132
|
+
skillsDir: path.join(agentDir, 'skills'),
|
|
133
|
+
instructionsFile: path.join(agentDir, 'INSTRUCTIONS.md'),
|
|
134
|
+
sessionsDir: path.join(brainDir, 'sessions'),
|
|
135
|
+
inboxDir: path.join(brainDir, 'memory-inbox'),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
export function notFound(res, msg) {
|
|
117
140
|
return res.status(404).json({ error: msg || 'Not found' });
|
|
118
141
|
}
|
|
@@ -127,6 +150,15 @@ export function serverError(res, err) {
|
|
|
127
150
|
}
|
|
128
151
|
|
|
129
152
|
/** Strip the internal `body` field from a vault node into `content`. */
|
|
130
|
-
export function sanitizeNode(
|
|
153
|
+
export function sanitizeNode(node) {
|
|
154
|
+
if (!node || typeof node !== 'object') return node;
|
|
155
|
+
// Strip internal fields — never expose absolute paths or vault internals over the API
|
|
156
|
+
const {
|
|
157
|
+
body,
|
|
158
|
+
_filePath,
|
|
159
|
+
_filepath,
|
|
160
|
+
_layer,
|
|
161
|
+
...rest
|
|
162
|
+
} = node;
|
|
131
163
|
return { ...rest, content: body };
|
|
132
164
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
2
|
import { requireAuth, requireScope } from '../auth.mjs';
|
|
6
|
-
import {
|
|
7
|
-
|
|
3
|
+
import {
|
|
4
|
+
serverError,
|
|
5
|
+
badRequest,
|
|
6
|
+
resolveVaultFromQuery,
|
|
7
|
+
pathsForVault,
|
|
8
|
+
} from './_shared.mjs';
|
|
8
9
|
|
|
9
10
|
const router = Router();
|
|
10
11
|
|
|
@@ -12,10 +13,12 @@ router.post('/api/context', requireAuth, requireScope('memory:read'), async (req
|
|
|
12
13
|
try {
|
|
13
14
|
const { compileContext } = await import('../../core/context-compiler.mjs');
|
|
14
15
|
const { query, budget, momentum_slugs } = req.body || {};
|
|
16
|
+
const vaultDir = resolveVaultFromQuery(req);
|
|
17
|
+
const { derivedDir } = pathsForVault(vaultDir);
|
|
15
18
|
const result = await compileContext({
|
|
16
19
|
query: query || '',
|
|
17
|
-
vaultDir
|
|
18
|
-
derivedDir
|
|
20
|
+
vaultDir,
|
|
21
|
+
derivedDir,
|
|
19
22
|
budget: budget || {},
|
|
20
23
|
consumer: 'api',
|
|
21
24
|
momentumSlugs: momentum_slugs || [],
|
|
@@ -29,7 +32,8 @@ router.post('/api/context', requireAuth, requireScope('memory:read'), async (req
|
|
|
29
32
|
router.get('/api/context/preview', requireAuth, requireScope('memory:read'), async (req, res) => {
|
|
30
33
|
try {
|
|
31
34
|
const { previewContext } = await import('../../core/context-compiler.mjs');
|
|
32
|
-
const
|
|
35
|
+
const vaultDir = resolveVaultFromQuery(req);
|
|
36
|
+
const result = previewContext({ vaultDir });
|
|
33
37
|
res.json(result);
|
|
34
38
|
} catch (err) {
|
|
35
39
|
serverError(res, err);
|
|
@@ -41,9 +45,10 @@ router.post('/api/context/stream', requireAuth, requireScope('memory:read'), asy
|
|
|
41
45
|
const { streamParallelContext } = await import('../../core/parallel-context.mjs');
|
|
42
46
|
const { query, budget_tokens, batch_size, concurrency, min_score } = req.body || {};
|
|
43
47
|
if (!query) return badRequest(res, 'query is required');
|
|
48
|
+
const vaultDir = resolveVaultFromQuery(req);
|
|
44
49
|
const result = await streamParallelContext({
|
|
45
50
|
query,
|
|
46
|
-
vaultDir
|
|
51
|
+
vaultDir,
|
|
47
52
|
budgetTokens: budget_tokens,
|
|
48
53
|
batchSize: batch_size,
|
|
49
54
|
concurrency,
|