total-recall-brain 3.19.0 → 3.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-B6AfuKv9.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-D0XeeXhG.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-B4RASWbV.css">
22
22
  </head>
23
23
  <body>
@@ -189,6 +189,22 @@ export default function SkillsPage({ activeBrainId }: { activeBrainId?: string }
189
189
  const [installingPkg, setInstallingPkg] = useState<string | null>(null);
190
190
  const [quarantineAlert, setQuarantineAlert] = useState<{ pkg: string; reason: string } | null>(null);
191
191
 
192
+ // ─── Active brain → single deployable repo target ──────────────────────────
193
+ // activeBrainId is 'global', 'project:<name>', or a comma-joined multi-select
194
+ // (see BrainSelector). Only a single, non-global selection maps to one concrete
195
+ // project repo we can deploy a global skill into.
196
+ const activeRepoName = useMemo(() => {
197
+ if (!activeBrainId) return null;
198
+ const ids = activeBrainId.split(',').map((s) => s.trim()).filter(Boolean);
199
+ if (ids.length !== 1 || ids[0] === 'global') return null;
200
+ const id = ids[0];
201
+ return id.startsWith('project:') ? id.slice('project:'.length) : id;
202
+ }, [activeBrainId]);
203
+ const activeRepoGroup = useMemo(
204
+ () => (activeRepoName ? repoGroups.find((g) => g.repo === activeRepoName) || null : null),
205
+ [activeRepoName, repoGroups],
206
+ );
207
+
192
208
  // ─── Lifecycle tab state ────────────────────────────────────────────────────
193
209
  const lifecycleVersion = useMemo(() => selectedSkill && skillContent ? extractVersion(skillContent) : '0.0.0', [selectedSkill, skillContent]);
194
210
  const changelogEntries = useMemo(() => selectedSkill && skillContent ? parseChangelog(skillContent) : [], [selectedSkill, skillContent]);
@@ -269,14 +285,23 @@ export default function SkillsPage({ activeBrainId }: { activeBrainId?: string }
269
285
  groups = (data.repos || []) as RepoSkillsGroup[];
270
286
  }
271
287
 
272
- if (activeBrainId && activeBrainId !== 'global') {
273
- const targetRepoId = activeBrainId.startsWith('project:') ? activeBrainId.replace('project:', '') : activeBrainId;
288
+ // activeBrainId may be a comma-joined multi-select (see BrainSelector) — split it
289
+ // rather than treating the whole string as a single repo id, else selecting more
290
+ // than one brain collapsed the list down to only the "Global" group.
291
+ const selectedBrainIds = (activeBrainId || '').split(',').map((s) => s.trim()).filter(Boolean);
292
+ if (selectedBrainIds.length > 0 && !selectedBrainIds.includes('global')) {
293
+ const targetRepoIds = selectedBrainIds.map((id) =>
294
+ id.startsWith('project:') ? id.slice('project:'.length) : id,
295
+ );
274
296
  groups = groups.filter(
275
- g => g.repo === 'Global' ||
276
- g.repo === targetRepoId ||
277
- targetRepoId.endsWith(`/${g.repo}`) ||
278
- g.repo.endsWith(`/${targetRepoId}`) ||
279
- activeBrainId === `project:${g.repo}`
297
+ g => g.repo === 'Global' ||
298
+ targetRepoIds.some(
299
+ (targetRepoId) =>
300
+ g.repo === targetRepoId ||
301
+ targetRepoId.endsWith(`/${g.repo}`) ||
302
+ g.repo.endsWith(`/${targetRepoId}`),
303
+ ) ||
304
+ selectedBrainIds.includes(`project:${g.repo}`)
280
305
  );
281
306
  list = groups.flatMap(g => g.skills || []);
282
307
  }
@@ -713,24 +738,26 @@ Configure triggers, options, and prompts inside this rules sheet to hot-recompil
713
738
  {skill.name}
714
739
  </span>
715
740
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
716
- {activeBrainId && activeBrainId !== 'Global' && group.repo === 'Global' && (
741
+ {activeRepoGroup?.path && group.repo === 'Global' && (
717
742
  <input
718
743
  type="checkbox"
719
- checked={!!repoGroups.find(g => g.repo === activeBrainId)?.skills?.some(s => s.name === skill.name)}
744
+ checked={!!activeRepoGroup?.skills?.some(s => s.name === skill.name)}
720
745
  onClick={(e) => e.stopPropagation()}
721
746
  onChange={async (e) => {
722
747
  e.stopPropagation();
723
748
  const checked = e.target.checked;
749
+ const targetRepoPath = activeRepoGroup?.path;
750
+ if (!targetRepoPath) return;
724
751
  if (checked) {
725
752
  // Open preview modal instead of instantly deploying
726
753
  setPreviewModalOpen(true);
727
754
  setPreviewLoading(true);
728
755
  setPreviewData(null);
729
756
  try {
730
- const res = await previewSkillRepo(skill.name, activeBrainId);
757
+ const res = await previewSkillRepo(skill.name, targetRepoPath);
731
758
  setPreviewData({
732
759
  skillName: skill.name,
733
- targetRepo: activeBrainId,
760
+ targetRepo: targetRepoPath,
734
761
  original: res.original,
735
762
  preview: res.preview,
736
763
  signals: res.signals
@@ -744,7 +771,7 @@ Configure triggers, options, and prompts inside this rules sheet to hot-recompil
744
771
  } else {
745
772
  // Uncheck (disable) happens immediately
746
773
  try {
747
- await toggleSkillRepo(skill.name, activeBrainId, false);
774
+ await toggleSkillRepo(skill.name, targetRepoPath, false);
748
775
  void fetchSkillsList();
749
776
  } catch (err) {
750
777
  setError((err as Error).message);
@@ -831,7 +858,7 @@ Configure triggers, options, and prompts inside this rules sheet to hot-recompil
831
858
  <h2 style={{ marginTop: 0, marginBottom: 12 }}>Skill Adaptation Preview</h2>
832
859
  {previewLoading ? (
833
860
  <div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
834
- <p>Generating adaptive preview for {activeBrainId}...</p>
861
+ <p>Generating adaptive preview for {activeRepoName || activeBrainId}...</p>
835
862
  </div>
836
863
  ) : previewData ? (
837
864
  <>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.19.0",
3
+ "version": "3.19.1",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -215,8 +215,7 @@ async function getGoogleEmbedding(text, model) {
215
215
  model: `models/${resolved}`,
216
216
  content: { parts: [{ text }] },
217
217
  }),
218
- signal: AbortSignal.timeout(30000),
219
- });
218
+ }, 30000);
220
219
 
221
220
  if (!res.ok) {
222
221
  const body = await res.text().catch(() => '');
@@ -246,7 +245,6 @@ async function getOpenRouterEmbedding(text, model = 'openai/text-embedding-3-sma
246
245
  // Request 768 dims to match the existing vault vss0(embedding(768)) schema
247
246
  // and stay comparable against previously-stored Google embeddings.
248
247
  body: JSON.stringify({ model, input: text, dimensions: 768 }),
249
- signal: AbortSignal.timeout(30000),
250
248
  }, 20000);
251
249
 
252
250
  if (!res.ok) {
@@ -275,8 +273,7 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
275
273
  'Authorization': `Bearer ${openaiKey}`,
276
274
  },
277
275
  body: JSON.stringify({ model, input: text }),
278
- signal: AbortSignal.timeout(30000),
279
- });
276
+ }, 30000);
280
277
 
281
278
  if (!res.ok) {
282
279
  const body = await res.text().catch(() => '');
@@ -980,7 +980,7 @@ export function checkSourceAvailability(config) {
980
980
  config.braveApiKey || config.tavilyApiKey || config.exaApiKey || config.serperApiKey;
981
981
  if (!config.searxUrl && !hasPaidSearch) {
982
982
  warnings.push(
983
- 'No SearX URL and no paid web-search key. Set SEARX_URL (preferred) or Brave/Tavily/Exa/Serper — otherwise only DuckDuckGo Instant Answer.',
983
+ 'No SearX URL and no paid web-search key. Set SEARX_URL (preferred), or BRAVE_SEARCH_API_KEY / TAVILY_API_KEY / EXA_API_KEY / SERPER_API_KEY — otherwise only DuckDuckGo Instant Answer.',
984
984
  );
985
985
  } else if (config.searxUrl) {
986
986
  warnings.push(
@@ -231,22 +231,52 @@ function appendAuditLog(entry) {
231
231
  }
232
232
  }
233
233
 
234
+ /**
235
+ * Persisting an audit event goes through the full SSSS kernel (schema
236
+ * registry build + validation), which is CPU-heavy enough to starve the
237
+ * event loop for seconds at a time. Firing it inline — even un-awaited —
238
+ * competes with concurrent I/O (like the body-stream read of the very
239
+ * request being audited) for event-loop turns. Queue + flush on a deferred
240
+ * macrotask so in-flight I/O always gets priority.
241
+ */
242
+ const policyEventQueue = [];
243
+ let policyEventFlushScheduled = false;
244
+
245
+ function schedulePolicyEventFlush() {
246
+ if (policyEventFlushScheduled) return;
247
+ policyEventFlushScheduled = true;
248
+ setImmediate(async () => {
249
+ policyEventFlushScheduled = false;
250
+ const batch = policyEventQueue.splice(0, policyEventQueue.length);
251
+ if (batch.length === 0) return;
252
+ try {
253
+ const [{ processViaPackageKernel }, crypto, { brainDir }, path] = await Promise.all([
254
+ import('./ssss-kernel-bridge.mjs'),
255
+ import('node:crypto'),
256
+ import('./config.mjs'),
257
+ import('node:path'),
258
+ ]);
259
+ const vaultRoot = path.join(brainDir, 'memory-vault');
260
+ for (const eventContent of batch) {
261
+ const envelope = {
262
+ type: 'event',
263
+ idempotency_key: crypto.randomUUID(),
264
+ path: 'system/network-policy.md',
265
+ workspace_id: 'default',
266
+ actor: { role: 'system' },
267
+ content: JSON.stringify(eventContent),
268
+ };
269
+ await processViaPackageKernel(envelope, vaultRoot, { agentRole: 'system' }).catch(() => {});
270
+ }
271
+ } catch {
272
+ // Best-effort audit trail; never let it affect request handling.
273
+ }
274
+ });
275
+ }
276
+
234
277
  async function emitPolicyEvent(eventContent) {
235
- import('./ssss-kernel-bridge.mjs').then(async ({ processViaPackageKernel }) => {
236
- const crypto = await import('node:crypto');
237
- const { brainDir } = await import('./config.mjs');
238
- const path = await import('node:path');
239
- const vaultRoot = path.join(brainDir, 'memory-vault');
240
- const envelope = {
241
- type: 'event',
242
- idempotency_key: crypto.randomUUID(),
243
- path: 'system/network-policy.md',
244
- workspace_id: 'default',
245
- actor: { role: 'system' },
246
- content: JSON.stringify(eventContent)
247
- };
248
- await processViaPackageKernel(envelope, vaultRoot, { agentRole: 'system' }).catch(() => {});
249
- }).catch(() => {});
278
+ policyEventQueue.push(eventContent);
279
+ schedulePolicyEventFlush();
250
280
  }
251
281
 
252
282
  // ─── Rate Limiting (minIntervalMs) ──────────────────────────────────────────
@@ -397,15 +427,15 @@ async function executeFetch(url, options, timeoutMs, domain, queueWaitMs = 0, ra
397
427
  rate_wait_ms: rateWaitMs,
398
428
  });
399
429
 
400
- emitPolicyEvent({
401
- domain,
402
- url,
403
- status: errorMsg ? 'error' : finalStatus,
404
- error: errorMsg,
405
- duration_ms: durationMs,
406
- queue_wait_ms: queueWaitMs,
407
- rate_wait_ms: rateWaitMs,
408
- });
430
+ // NOTE: audit events used to also be persisted via emitPolicyEvent()
431
+ // (a full SSSS kernel write per request). Measured at 30s+ per call —
432
+ // processViaPackageKernel/getTotalRecallEngine is catastrophically slow
433
+ // on this vault, and even deferred via setImmediate it still blocks the
434
+ // single-threaded event loop for its full duration once it runs,
435
+ // stalling concurrent I/O (e.g. the very response body being read).
436
+ // In-memory appendAuditLog() above (queryable via getAuditLog()) is the
437
+ // audit trail for network activity now. Re-enable emitPolicyEvent()
438
+ // only after the kernel commit path itself is fixed to be fast.
409
439
  }
410
440
  }
411
441
  }
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
- import { getBothBrains } from '../../cli/agent-dir.mjs';
3
2
  import { getNodes } from '../../core/vault-cache.mjs';
4
3
  import { logger } from '../../core/logger.mjs';
4
+ import { resolveAllVaultsFromQuery, VAULT_DIR } from './_shared.mjs';
5
5
 
6
6
  import path from 'node:path';
7
7
 
@@ -10,21 +10,6 @@ export const rulesRouter = express.Router();
10
10
  /** Rule categories that surface on the Agent Rules page / instruction compiler. */
11
11
  const RULE_CATEGORIES = new Set(['invariants', 'preferences', 'anti-patterns', 'corrections']);
12
12
 
13
- /**
14
- * Resolve the vault root for a brain layer.
15
- * getBothBrains() returns `brainDir` (not `brainRoot`).
16
- * Project layer also carries `agentDir` — fall back to skills/total-recall under it.
17
- */
18
- function resolveBrainDir(layer) {
19
- if (!layer) return null;
20
- if (typeof layer.brainDir === 'string' && layer.brainDir) return layer.brainDir;
21
- if (typeof layer.brainRoot === 'string' && layer.brainRoot) return layer.brainRoot; // legacy alias
22
- if (typeof layer.agentDir === 'string' && layer.agentDir) {
23
- return path.join(layer.agentDir, 'skills', 'total-recall');
24
- }
25
- return null;
26
- }
27
-
28
13
  function serializeRule(node, scope) {
29
14
  const importanceRaw = Number(node.importance);
30
15
  const importance = Number.isFinite(importanceRaw)
@@ -49,16 +34,17 @@ function serializeRule(node, scope) {
49
34
 
50
35
  rulesRouter.get('/api/rules', (req, res) => {
51
36
  try {
52
- const { global: globalBrain, project: projectBrain } = getBothBrains();
37
+ // Scoped by req.query.brain / x-total-recall-brain header, same as memory/graph —
38
+ // this used to always merge the global vault with getBothBrains()'s cwd-detected
39
+ // project brain, ignoring whichever brain the user had selected in the UI.
40
+ const vaultDirs = resolveAllVaultsFromQuery(req);
41
+ const globalVaultKey = path.resolve(VAULT_DIR);
53
42
  const rules = [];
54
43
  const seenVaults = new Set();
55
44
 
56
- const addRules = (brainDir, scope) => {
57
- if (!brainDir) return;
58
- const vaultDir = path.join(brainDir, 'memory-vault');
45
+ for (const vaultDir of vaultDirs) {
59
46
  const vaultKey = path.resolve(vaultDir);
60
- // Avoid double-listing when project brainDir incorrectly points at global.
61
- if (seenVaults.has(vaultKey)) return;
47
+ if (seenVaults.has(vaultKey)) continue;
62
48
  seenVaults.add(vaultKey);
63
49
 
64
50
  let nodes = [];
@@ -66,18 +52,16 @@ rulesRouter.get('/api/rules', (req, res) => {
66
52
  nodes = getNodes(vaultDir) || [];
67
53
  } catch (err) {
68
54
  logger.warn('rules', 'Failed to load vault nodes', { vaultDir, error: err.message });
69
- return;
55
+ continue;
70
56
  }
71
57
 
58
+ const scope = vaultKey === globalVaultKey ? 'global' : 'project';
72
59
  for (const n of nodes) {
73
60
  if (!RULE_CATEGORIES.has(n.category)) continue;
74
61
  // Prefer active rules first in natural order; archived still included for restore UI
75
62
  rules.push(serializeRule(n, scope));
76
63
  }
77
- };
78
-
79
- addRules(resolveBrainDir(globalBrain), 'global');
80
- addRules(resolveBrainDir(projectBrain), 'project');
64
+ }
81
65
 
82
66
  res.json({ rules, count: rules.length });
83
67
  } catch (err) {
@@ -303,6 +303,9 @@ export function collectSkillsByRepo() {
303
303
 
304
304
  function resolveRegisteredProject(repoName) {
305
305
  if (!repoName) return null;
306
+ // "Global" is the synthetic repo collectSkillsByRepo() unshifts for os.homedir() —
307
+ // it isn't a real project-registry.json entry, so resolve it directly here too.
308
+ if (repoName === 'Global') return { name: 'Global', path: os.homedir() };
306
309
  const registry = loadProjectRegistry();
307
310
  const entry = registry.find(
308
311
  (p) => p.name === repoName || path.basename(p.path || p.project_root || '') === repoName,