total-recall-brain 3.13.0 → 3.13.2
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/README.md +91 -108
- package/bin/antigravity.mjs +81 -52
- package/bin/total-recall.mjs +32 -57
- package/extension/background.js +1 -1
- package/extension/options/options.js +9 -3
- package/extension/sidepanel/sidepanel.js +0 -2
- package/frontend/index.html +4 -3
- package/frontend/package.json +1 -0
- package/frontend/src/App.tsx +109 -152
- package/frontend/src/api.ts +605 -23
- package/frontend/src/components/BrainSelector.tsx +211 -199
- package/frontend/src/components/DocumentEditorModal.tsx +57 -0
- package/frontend/src/components/DocumentTable.tsx +47 -0
- package/frontend/src/components/Graph3D.tsx +120 -71
- package/frontend/src/components/UsageChart.tsx +3 -3
- package/frontend/src/components/brand/BrandMark.tsx +95 -0
- package/frontend/src/index.css +286 -86
- package/frontend/src/pages/ApiKeysPage.tsx +1878 -215
- package/frontend/src/pages/ChatPage.tsx +103 -45
- package/frontend/src/pages/DesignDocsPage.tsx +5 -5
- package/frontend/src/pages/FilesPage.tsx +10 -6
- package/frontend/src/pages/GraphPage.tsx +5 -3
- package/frontend/src/pages/HelpPage.tsx +2 -2
- package/frontend/src/pages/InboxPage.tsx +82 -0
- package/frontend/src/pages/InstructionsPage.tsx +1 -1
- package/frontend/src/pages/IntegrationsPage.tsx +21 -10
- package/frontend/src/pages/LoginPage.tsx +95 -77
- package/frontend/src/pages/MemoryPage.tsx +31 -8
- package/frontend/src/pages/OnboardingPage.tsx +690 -0
- package/frontend/src/pages/OpenWikiPage.tsx +412 -323
- package/frontend/src/pages/SettingsPage.tsx +825 -922
- package/frontend/src/pages/SkillsPage.tsx +272 -147
- package/frontend/src/pages/UsagePage.tsx +23 -31
- package/frontend/src/pages/VaultPage.tsx +223 -0
- package/frontend/src/types.ts +13 -0
- package/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/SKILL.md +43 -4
- package/scaffold/.agent/skills/total-recall/modules/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/README.md +5 -0
- package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents → modules/agents}/agents.yml +8 -0
- package/scaffold/.agent/skills/total-recall/modules/skill-deploy/README.md +9 -0
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +7 -0
- package/src/cli/brain.mjs +63 -5
- package/src/cli/connect.mjs +11 -19
- package/src/cli/daemon.mjs +5 -4
- package/src/cli/deploy-ui.mjs +8 -3
- package/src/cli/deploy.mjs +84 -27
- package/src/cli/ingest/index.mjs +1 -1
- package/src/cli/init.mjs +63 -34
- package/src/cli/remember.mjs +3 -3
- package/src/cli/secret.mjs +812 -0
- package/src/cli/setup.mjs +34 -7
- package/src/cli/share.mjs +2 -2
- package/src/cli/skill.mjs +396 -35
- package/src/cli/sync.mjs +4 -4
- package/src/cli/task.mjs +287 -0
- package/src/core/clarity-rewriter.mjs +5 -5
- package/src/core/config.mjs +44 -2
- package/src/core/conflict-detector.mjs +5 -5
- package/src/core/crons.mjs +54 -0
- package/src/core/daemon-loop.mjs +87 -235
- package/src/core/dream.mjs +170 -10
- package/src/core/env-import.mjs +456 -0
- package/src/core/fact-seeker.mjs +14 -14
- package/src/core/import-rules.mjs +20 -7
- package/src/core/inference-engine.mjs +3 -3
- package/src/core/migrate.mjs +14 -8
- package/src/core/okf-adapter.mjs +2 -2
- package/src/core/operation-validator.mjs +39 -331
- package/src/core/optimizer.mjs +7 -0
- package/src/core/post-mortem.mjs +2 -2
- package/src/core/project-brain.mjs +349 -0
- package/src/core/provider-catalog.mjs +296 -0
- package/src/core/quick-capture.mjs +1 -1
- package/src/core/remote-vault-sync.mjs +194 -0
- package/src/core/research-queue.mjs +7 -11
- package/src/core/runtime.mjs +33 -9
- package/src/core/scheduler.mjs +59 -5
- package/src/core/schema.mjs +14 -0
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-export.mjs +323 -0
- package/src/core/secrets-rotate.mjs +245 -0
- package/src/core/secrets-store.mjs +645 -0
- package/src/core/semantic-index.mjs +3 -3
- package/src/core/skills-registry.mjs +1062 -0
- package/src/core/source-adapters.mjs +4 -1
- package/src/core/ssss-host-extension.mjs +94 -0
- package/src/core/ssss-kernel-bridge.mjs +530 -0
- package/src/core/surface.mjs +14 -0
- package/src/core/task-envelope.mjs +298 -0
- package/src/core/task-executors.mjs +600 -0
- package/src/core/validated-write.mjs +124 -56
- package/src/core/vault.mjs +21 -34
- package/src/core/webauthn-store.mjs +272 -0
- package/src/server/api.mjs +26 -16
- package/src/server/auth.mjs +51 -0
- package/src/server/index.mjs +3 -3
- package/src/server/keys.mjs +30 -4
- package/src/server/rest.mjs +198 -25
- package/src/server/route-manifest.json +156 -0
- package/src/server/routes/_shared.mjs +40 -1
- package/src/server/routes/docs.mjs +260 -0
- package/src/server/routes/memory.mjs +145 -32
- package/src/server/routes/research.mjs +30 -0
- package/src/server/routes/secrets.mjs +470 -0
- package/src/server/routes/share.mjs +2 -2
- package/src/server/routes/skills.mjs +452 -54
- package/src/server/routes/sync.mjs +48 -0
- package/src/server/routes/webauthn.mjs +184 -0
- package/src/server/update-route-manifest.mjs +39 -0
- package/templates/onboarding-interview.md +32 -22
- package/templates/openwiki/README.md +10 -0
- package/templates/openwiki/architecture.md +5 -0
- package/templates/openwiki/ide.md +10 -0
- package/templates/openwiki/memory.md +11 -0
- package/templates/openwiki/secrets.md +7 -0
- package/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/SKILL.md +0 -121
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/antigravity.txt +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/claude.txt +0 -58
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/codex.txt +0 -81
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/gemini.txt +0 -44
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/subagents/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-research/SKILL.md +0 -338
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/SKILL.md +0 -282
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/assets/skill-template/SKILL.md +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/evals/evals.json +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-add.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-delete.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/create-skill.sh +0 -65
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/enforce-skill-optimization.mjs +0 -135
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/skill-watcher.mjs +0 -64
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/subagents/audit-all-skills.md +0 -62
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/SKILL.md +0 -298
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/evals/evals.json +0 -17
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +0 -124
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/ssss-spec.md +0 -1325
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/scripts/validate-schema.mjs +0 -232
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/subagents/audit-ssss-node.md +0 -9
- /package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents/evals → modules/skill-deploy/scripts}/.gitkeep +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/find-skills.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/install-skill.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/scan-skill.mjs +0 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets store — portable keys separate from the SSSS memory vault.
|
|
3
|
+
*
|
|
4
|
+
* Default format: JSON object at <brain>/config/secrets.enc with mode 0o600
|
|
5
|
+
* (compatible with existing config loader). Optional AES-GCM via TR_SECRETS_PASSWORD.
|
|
6
|
+
*
|
|
7
|
+
* Never write secret values into vault markdown, openwiki, or compiled surfaces.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import YAML from 'yaml';
|
|
15
|
+
import { encryptSecrets, decryptSecrets } from './crypto.mjs';
|
|
16
|
+
|
|
17
|
+
const META_KEY = '__tr_secrets_meta';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} brainDir
|
|
21
|
+
*/
|
|
22
|
+
export function resolveSecretsPath(brainDir) {
|
|
23
|
+
return path.join(brainDir, 'config', 'secrets.enc');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} brainDir
|
|
28
|
+
*/
|
|
29
|
+
export function resolveAuditPath(brainDir) {
|
|
30
|
+
return path.join(brainDir, 'logs', 'secrets-audit.jsonl');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} brainDir
|
|
35
|
+
*/
|
|
36
|
+
export function resolveUsagePath(brainDir) {
|
|
37
|
+
return path.join(brainDir, 'logs', 'usage.jsonl');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function secretsPassword() {
|
|
41
|
+
return process.env.TR_SECRETS_PASSWORD || process.env.TR_MASTER_PASSWORD || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load raw secrets object (values included). Caller must not log values.
|
|
46
|
+
* @param {string} brainDir
|
|
47
|
+
*/
|
|
48
|
+
export async function loadSecrets(brainDir) {
|
|
49
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
50
|
+
if (!fs.existsSync(filePath)) return {};
|
|
51
|
+
|
|
52
|
+
const buf = fs.readFileSync(filePath);
|
|
53
|
+
const password = secretsPassword();
|
|
54
|
+
|
|
55
|
+
// AES blob: starts with non-JSON (binary). Detect: first byte not `{`
|
|
56
|
+
if (password && buf.length > 44 && buf[0] !== 0x7b /* { */) {
|
|
57
|
+
try {
|
|
58
|
+
return await decryptSecrets(buf, password);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`Failed to decrypt secrets store: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Plain JSON (legacy / default)
|
|
65
|
+
try {
|
|
66
|
+
const text = buf.toString('utf8').trim();
|
|
67
|
+
if (!text) return {};
|
|
68
|
+
return JSON.parse(text) || {};
|
|
69
|
+
} catch {
|
|
70
|
+
if (password) {
|
|
71
|
+
try {
|
|
72
|
+
return await decryptSecrets(buf, password);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`Secrets file is not valid JSON and AES decrypt failed: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw new Error('Secrets file is not valid JSON. Set TR_SECRETS_PASSWORD if it is AES-encrypted.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Persist secrets object.
|
|
83
|
+
* @param {string} brainDir
|
|
84
|
+
* @param {object} secrets
|
|
85
|
+
*/
|
|
86
|
+
export async function saveSecrets(brainDir, secrets) {
|
|
87
|
+
const filePath = resolveSecretsPath(brainDir);
|
|
88
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
89
|
+
const password = secretsPassword();
|
|
90
|
+
|
|
91
|
+
if (password) {
|
|
92
|
+
const buf = await encryptSecrets(secrets, password);
|
|
93
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600 });
|
|
94
|
+
} else {
|
|
95
|
+
fs.writeFileSync(filePath, JSON.stringify(secrets, null, 2) + '\n', {
|
|
96
|
+
encoding: 'utf8',
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureMeta(secrets) {
|
|
103
|
+
if (!secrets[META_KEY] || typeof secrets[META_KEY] !== 'object') {
|
|
104
|
+
secrets[META_KEY] = { keys: {}, version: 1 };
|
|
105
|
+
}
|
|
106
|
+
if (!secrets[META_KEY].keys) secrets[META_KEY].keys = {};
|
|
107
|
+
return secrets;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendAudit(brainDir, event) {
|
|
111
|
+
const auditPath = resolveAuditPath(brainDir);
|
|
112
|
+
fs.mkdirSync(path.dirname(auditPath), { recursive: true });
|
|
113
|
+
const line = JSON.stringify({
|
|
114
|
+
ts: new Date().toISOString(),
|
|
115
|
+
...event,
|
|
116
|
+
// never include values
|
|
117
|
+
});
|
|
118
|
+
fs.appendFileSync(auditPath, line + '\n', { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse repos field. Policy: 0 (developer/tooling) or exactly 1 product repo.
|
|
123
|
+
* Never allow multi-repo binding — each key is unique to one repo or to developer scope.
|
|
124
|
+
*
|
|
125
|
+
* @param {string|string[]|null|undefined} v
|
|
126
|
+
* @param {{ strict?: boolean }} opts - strict throws if >1; false collapses for read-only display
|
|
127
|
+
* @returns {string[]}
|
|
128
|
+
*/
|
|
129
|
+
export function normalizeReposBinding(v, opts = {}) {
|
|
130
|
+
const strict = opts.strict !== false;
|
|
131
|
+
let list = [];
|
|
132
|
+
if (Array.isArray(v)) {
|
|
133
|
+
list = [...new Set(v.map(String).map((s) => s.trim()).filter(Boolean))];
|
|
134
|
+
} else if (typeof v === 'string') {
|
|
135
|
+
list = [
|
|
136
|
+
...new Set(
|
|
137
|
+
v
|
|
138
|
+
.split(/[,;\s]+/)
|
|
139
|
+
.map((s) => s.trim())
|
|
140
|
+
.filter(Boolean),
|
|
141
|
+
),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
if (list.length > 1) {
|
|
145
|
+
if (strict) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Each secret may bind to at most ONE repo (got ${list.length}: ${list.join(', ')}). ` +
|
|
148
|
+
`Pick a single product repo, or leave repos empty for Developer secrets (tooling).`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return list;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* True when meta has multi-repo violation (legacy data).
|
|
157
|
+
*/
|
|
158
|
+
export function isMultiRepoViolation(meta = {}) {
|
|
159
|
+
const repos = Array.isArray(meta.repos) ? meta.repos.filter(Boolean) : [];
|
|
160
|
+
return repos.length > 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Normalize / merge secret metadata (never stores values).
|
|
165
|
+
* @param {object} prev
|
|
166
|
+
* @param {object} patch
|
|
167
|
+
*/
|
|
168
|
+
export function mergeSecretMeta(prev = {}, patch = {}) {
|
|
169
|
+
const next = { ...prev };
|
|
170
|
+
const assign = (field, transform) => {
|
|
171
|
+
if (patch[field] !== undefined) {
|
|
172
|
+
next[field] = transform ? transform(patch[field]) : patch[field];
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
assign('scope', (v) => (v === 'project' ? 'project' : 'global'));
|
|
176
|
+
assign('provider', (v) => (v ? String(v).toLowerCase() : null));
|
|
177
|
+
assign('label', (v) => (v == null ? null : String(v)));
|
|
178
|
+
// Writes only — at most one product repo; empty = Developer secrets
|
|
179
|
+
assign('repos', (v) => normalizeReposBinding(v, { strict: true }));
|
|
180
|
+
assign('subscription_tier', (v) => (v == null || v === '' ? null : String(v)));
|
|
181
|
+
assign('monthly_cost_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
182
|
+
assign('monthly_cap_usd', (v) => (v == null || v === '' ? null : Number(v)));
|
|
183
|
+
assign('api_docs_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
184
|
+
assign('console_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
185
|
+
assign('pricing_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
186
|
+
assign('schema_notes', (v) => (v == null || v === '' ? null : String(v)));
|
|
187
|
+
assign('auth_scheme', (v) => (v == null || v === '' ? null : String(v)));
|
|
188
|
+
assign('rotate_every_days', (v) => {
|
|
189
|
+
if (v == null || v === '' || v === false) return null;
|
|
190
|
+
const n = parseInt(v, 10);
|
|
191
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
192
|
+
});
|
|
193
|
+
assign('auto_rotate', (v) => !!v);
|
|
194
|
+
assign('notes', (v) => (v == null || v === '' ? null : String(v)));
|
|
195
|
+
assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
|
|
196
|
+
if (patch.updated_at) next.updated_at = patch.updated_at;
|
|
197
|
+
if (patch.rotated_at) next.rotated_at = patch.rotated_at;
|
|
198
|
+
if (patch.created_at && !next.created_at) next.created_at = patch.created_at;
|
|
199
|
+
return next;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compute next rotation due ISO date from meta.
|
|
204
|
+
*/
|
|
205
|
+
export function nextRotateDue(meta = {}) {
|
|
206
|
+
if (!meta.rotate_every_days) return null;
|
|
207
|
+
const base = meta.rotated_at || meta.updated_at || meta.created_at;
|
|
208
|
+
if (!base) return null;
|
|
209
|
+
const t = new Date(base).getTime() + meta.rotate_every_days * 86400000;
|
|
210
|
+
if (!Number.isFinite(t)) return null;
|
|
211
|
+
return new Date(t).toISOString();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* List secret keys with rich metadata only (no values).
|
|
216
|
+
*/
|
|
217
|
+
export async function listSecretsMeta(brainDir) {
|
|
218
|
+
const { getProvider, providerForKeyName } = await import('./provider-catalog.mjs');
|
|
219
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
220
|
+
const meta = secrets[META_KEY].keys || {};
|
|
221
|
+
const usageByKey = summarizeUsageByKey(brainDir, { days: 30 });
|
|
222
|
+
const keys = Object.keys(secrets).filter((k) => k !== META_KEY);
|
|
223
|
+
return keys.map((key) => {
|
|
224
|
+
const m = meta[key] || {};
|
|
225
|
+
const val = secrets[key];
|
|
226
|
+
const providerId = m.provider || providerForKeyName(key)?.id || null;
|
|
227
|
+
const catalog = providerId ? getProvider(providerId) : providerForKeyName(key);
|
|
228
|
+
const due = nextRotateDue(m);
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
const rotation_overdue = due ? new Date(due).getTime() < now : false;
|
|
231
|
+
const usage = usageByKey.by_key[key] || {
|
|
232
|
+
events: 0,
|
|
233
|
+
cost_usd: 0,
|
|
234
|
+
input_tokens: 0,
|
|
235
|
+
output_tokens: 0,
|
|
236
|
+
};
|
|
237
|
+
const repos = Array.isArray(m.repos) ? m.repos.filter(Boolean) : [];
|
|
238
|
+
const multi_repo_error = repos.length > 1;
|
|
239
|
+
return {
|
|
240
|
+
key,
|
|
241
|
+
set: val !== undefined && val !== null && val !== '',
|
|
242
|
+
length: typeof val === 'string' ? val.length : val != null ? String(val).length : 0,
|
|
243
|
+
fingerprint:
|
|
244
|
+
typeof val === 'string' && val.length
|
|
245
|
+
? crypto.createHash('sha256').update(val).digest('hex').slice(0, 12)
|
|
246
|
+
: null,
|
|
247
|
+
masked:
|
|
248
|
+
typeof val === 'string' && val.length > 8
|
|
249
|
+
? `${val.slice(0, 4)}…${val.slice(-4)}`
|
|
250
|
+
: val
|
|
251
|
+
? '••••••••'
|
|
252
|
+
: null,
|
|
253
|
+
scope: m.scope || 'global',
|
|
254
|
+
provider: providerId,
|
|
255
|
+
provider_name: catalog?.name || providerId,
|
|
256
|
+
label: m.label || null,
|
|
257
|
+
repos,
|
|
258
|
+
/** Single product repo or null (developer/tooling). Never multi. */
|
|
259
|
+
repo: multi_repo_error ? null : repos[0] || null,
|
|
260
|
+
multi_repo_error,
|
|
261
|
+
binding_error: multi_repo_error
|
|
262
|
+
? `Bound to ${repos.length} repos (${repos.join(', ')}). Fix: bind to exactly one repo, or clear for Developer secrets.`
|
|
263
|
+
: null,
|
|
264
|
+
project_path: m.project_path || null,
|
|
265
|
+
subscription_tier: m.subscription_tier || null,
|
|
266
|
+
monthly_cost_usd: m.monthly_cost_usd ?? null,
|
|
267
|
+
monthly_cap_usd: m.monthly_cap_usd ?? catalog?.default_monthly_cap_usd ?? null,
|
|
268
|
+
api_docs_url: m.api_docs_url || catalog?.docs_url || null,
|
|
269
|
+
console_url: m.console_url || catalog?.console_url || null,
|
|
270
|
+
pricing_url: m.pricing_url || catalog?.pricing_url || null,
|
|
271
|
+
schema: catalog?.schema || null,
|
|
272
|
+
schema_notes: m.schema_notes || catalog?.schema?.notes || null,
|
|
273
|
+
auth_scheme: m.auth_scheme || catalog?.schema?.auth || null,
|
|
274
|
+
rotate_every_days: m.rotate_every_days ?? null,
|
|
275
|
+
auto_rotate: !!m.auto_rotate,
|
|
276
|
+
next_rotate_due: due,
|
|
277
|
+
rotation_overdue,
|
|
278
|
+
notes: m.notes || null,
|
|
279
|
+
created_at: m.created_at || null,
|
|
280
|
+
updated_at: m.updated_at || null,
|
|
281
|
+
rotated_at: m.rotated_at || null,
|
|
282
|
+
usage_30d: usage,
|
|
283
|
+
tiers: catalog?.tiers || [],
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Full catalog payload for UI: keys + providers + usage totals.
|
|
290
|
+
*/
|
|
291
|
+
export async function getSecretsCatalog(brainDir) {
|
|
292
|
+
const { listProviders } = await import('./provider-catalog.mjs');
|
|
293
|
+
const keys = await listSecretsMeta(brainDir);
|
|
294
|
+
const usage30 = summarizeUsage(brainDir, { days: 30 });
|
|
295
|
+
const usage7 = summarizeUsage(brainDir, { days: 7 });
|
|
296
|
+
const budget = loadBudgetConfig(brainDir);
|
|
297
|
+
const monthlyPlanned = keys.reduce((s, k) => s + (Number(k.monthly_cost_usd) || 0), 0);
|
|
298
|
+
const overdue = keys.filter((k) => k.rotation_overdue);
|
|
299
|
+
const multiRepoViolations = keys.filter((k) => k.multi_repo_error);
|
|
300
|
+
const developerKeys = keys.filter((k) => !k.repos?.length);
|
|
301
|
+
const productKeys = keys.filter((k) => k.repos?.length === 1);
|
|
302
|
+
const byProvider = {};
|
|
303
|
+
for (const k of keys) {
|
|
304
|
+
const p = k.provider || 'unknown';
|
|
305
|
+
if (!byProvider[p]) byProvider[p] = { provider: p, keys: 0, cost_30d: 0, monthly_cost: 0 };
|
|
306
|
+
byProvider[p].keys++;
|
|
307
|
+
byProvider[p].cost_30d += k.usage_30d?.cost_usd || 0;
|
|
308
|
+
byProvider[p].monthly_cost += Number(k.monthly_cost_usd) || 0;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
keys,
|
|
312
|
+
providers: listProviders(),
|
|
313
|
+
summary: {
|
|
314
|
+
total_keys: keys.length,
|
|
315
|
+
providers_active: Object.keys(byProvider).length,
|
|
316
|
+
monthly_subscription_usd: monthlyPlanned,
|
|
317
|
+
multi_repo_violations: multiRepoViolations.length,
|
|
318
|
+
developer_keys: developerKeys.length,
|
|
319
|
+
product_keys: productKeys.length,
|
|
320
|
+
usage_7d: usage7,
|
|
321
|
+
usage_30d: usage30,
|
|
322
|
+
rotation_overdue: overdue.length,
|
|
323
|
+
budget: budget.config,
|
|
324
|
+
},
|
|
325
|
+
by_provider: Object.values(byProvider).sort((a, b) => b.cost_30d - a.cost_30d),
|
|
326
|
+
store: resolveSecretsPath(brainDir),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Update metadata only (no value change).
|
|
332
|
+
*/
|
|
333
|
+
export async function updateSecretMeta(brainDir, key, patch = {}, opts = {}) {
|
|
334
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
335
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
336
|
+
throw new Error(`Secret not found: ${key}`);
|
|
337
|
+
}
|
|
338
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
339
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
340
|
+
...patch,
|
|
341
|
+
updated_at: new Date().toISOString(),
|
|
342
|
+
created_at: prev.created_at || new Date().toISOString(),
|
|
343
|
+
});
|
|
344
|
+
await saveSecrets(brainDir, secrets);
|
|
345
|
+
appendAudit(brainDir, {
|
|
346
|
+
action: 'meta',
|
|
347
|
+
key,
|
|
348
|
+
actor: opts.actor || 'cli',
|
|
349
|
+
fields: Object.keys(patch),
|
|
350
|
+
});
|
|
351
|
+
return listSecretsMeta(brainDir).then((rows) => rows.find((r) => r.key === key));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Secrets due for rotation (auto_rotate or overdue).
|
|
356
|
+
*/
|
|
357
|
+
export async function listRotationDue(brainDir, { autoOnly = false } = {}) {
|
|
358
|
+
const keys = await listSecretsMeta(brainDir);
|
|
359
|
+
return keys.filter((k) => {
|
|
360
|
+
if (!k.next_rotate_due) return false;
|
|
361
|
+
if (!k.rotation_overdue) return false;
|
|
362
|
+
if (autoOnly && !k.auto_rotate) return false;
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Set a secret. Value never returned in audit.
|
|
369
|
+
*/
|
|
370
|
+
export async function setSecret(brainDir, key, value, opts = {}) {
|
|
371
|
+
if (!key || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(key)) {
|
|
372
|
+
throw new Error('Invalid secret key. Use letters, numbers, _ . - (start with a letter).');
|
|
373
|
+
}
|
|
374
|
+
if (key === META_KEY) throw new Error('Reserved key name');
|
|
375
|
+
if (value === undefined || value === null || value === '') {
|
|
376
|
+
throw new Error('Secret value must be non-empty');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
380
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
381
|
+
const now = new Date().toISOString();
|
|
382
|
+
secrets[key] = String(value);
|
|
383
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
384
|
+
scope: opts.scope || prev.scope || 'global',
|
|
385
|
+
provider: opts.provider || prev.provider || null,
|
|
386
|
+
repos: opts.repos !== undefined ? opts.repos : prev.repos,
|
|
387
|
+
subscription_tier: opts.subscription_tier !== undefined ? opts.subscription_tier : prev.subscription_tier,
|
|
388
|
+
monthly_cost_usd: opts.monthly_cost_usd !== undefined ? opts.monthly_cost_usd : prev.monthly_cost_usd,
|
|
389
|
+
monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
|
|
390
|
+
api_docs_url: opts.api_docs_url !== undefined ? opts.api_docs_url : prev.api_docs_url,
|
|
391
|
+
rotate_every_days: opts.rotate_every_days !== undefined ? opts.rotate_every_days : prev.rotate_every_days,
|
|
392
|
+
auto_rotate: opts.auto_rotate !== undefined ? opts.auto_rotate : prev.auto_rotate,
|
|
393
|
+
notes: opts.notes !== undefined ? opts.notes : prev.notes,
|
|
394
|
+
project_path: opts.project_path !== undefined ? opts.project_path : prev.project_path,
|
|
395
|
+
label: opts.label !== undefined ? opts.label : prev.label,
|
|
396
|
+
created_at: prev.created_at || now,
|
|
397
|
+
updated_at: now,
|
|
398
|
+
});
|
|
399
|
+
await saveSecrets(brainDir, secrets);
|
|
400
|
+
appendAudit(brainDir, {
|
|
401
|
+
action: 'set',
|
|
402
|
+
key,
|
|
403
|
+
scope: secrets[META_KEY].keys[key].scope,
|
|
404
|
+
provider: secrets[META_KEY].keys[key].provider,
|
|
405
|
+
actor: opts.actor || 'cli',
|
|
406
|
+
});
|
|
407
|
+
return { key, set: true };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get a secret value (CLI only — do not inject into surfaces).
|
|
412
|
+
*/
|
|
413
|
+
export async function getSecret(brainDir, key, opts = {}) {
|
|
414
|
+
const secrets = await loadSecrets(brainDir);
|
|
415
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
416
|
+
return { found: false, key };
|
|
417
|
+
}
|
|
418
|
+
appendAudit(brainDir, {
|
|
419
|
+
action: opts.action || 'get',
|
|
420
|
+
key,
|
|
421
|
+
actor: opts.actor || 'cli',
|
|
422
|
+
});
|
|
423
|
+
return { found: true, key, value: secrets[key] };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Delete a secret.
|
|
428
|
+
*/
|
|
429
|
+
export async function deleteSecret(brainDir, key, opts = {}) {
|
|
430
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
431
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
432
|
+
return { found: false, key };
|
|
433
|
+
}
|
|
434
|
+
delete secrets[key];
|
|
435
|
+
if (secrets[META_KEY].keys) delete secrets[META_KEY].keys[key];
|
|
436
|
+
await saveSecrets(brainDir, secrets);
|
|
437
|
+
appendAudit(brainDir, { action: 'delete', key, actor: opts.actor || 'cli' });
|
|
438
|
+
return { found: true, key, deleted: true };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Rotate: set new value, mark rotated_at.
|
|
443
|
+
*/
|
|
444
|
+
export async function rotateSecret(brainDir, key, newValue, opts = {}) {
|
|
445
|
+
if (!newValue) throw new Error('New value required for rotate');
|
|
446
|
+
const secrets = ensureMeta(await loadSecrets(brainDir));
|
|
447
|
+
if (!(key in secrets) || key === META_KEY) {
|
|
448
|
+
throw new Error(`Secret not found: ${key}`);
|
|
449
|
+
}
|
|
450
|
+
secrets[key] = String(newValue);
|
|
451
|
+
const prev = secrets[META_KEY].keys[key] || {};
|
|
452
|
+
const now = new Date().toISOString();
|
|
453
|
+
secrets[META_KEY].keys[key] = mergeSecretMeta(prev, {
|
|
454
|
+
scope: opts.scope || prev.scope || 'global',
|
|
455
|
+
provider: opts.provider || prev.provider || null,
|
|
456
|
+
updated_at: now,
|
|
457
|
+
rotated_at: now,
|
|
458
|
+
});
|
|
459
|
+
await saveSecrets(brainDir, secrets);
|
|
460
|
+
appendAudit(brainDir, { action: 'rotate', key, actor: opts.actor || 'cli' });
|
|
461
|
+
return { key, rotated: true, next_rotate_due: nextRotateDue(secrets[META_KEY].keys[key]) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read last N audit lines (no values).
|
|
466
|
+
*/
|
|
467
|
+
export function readSecretAudit(brainDir, { limit = 50 } = {}) {
|
|
468
|
+
const auditPath = resolveAuditPath(brainDir);
|
|
469
|
+
if (!fs.existsSync(auditPath)) return [];
|
|
470
|
+
const lines = fs.readFileSync(auditPath, 'utf8').split('\n').filter(Boolean);
|
|
471
|
+
return lines
|
|
472
|
+
.slice(-limit)
|
|
473
|
+
.map((l) => {
|
|
474
|
+
try {
|
|
475
|
+
return JSON.parse(l);
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
.filter(Boolean);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Record a usage/cost event (no secrets).
|
|
485
|
+
*/
|
|
486
|
+
export function recordUsage(brainDir, event) {
|
|
487
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
488
|
+
fs.mkdirSync(path.dirname(usagePath), { recursive: true });
|
|
489
|
+
const row = {
|
|
490
|
+
ts: new Date().toISOString(),
|
|
491
|
+
provider: event.provider || 'unknown',
|
|
492
|
+
model: event.model || null,
|
|
493
|
+
input_tokens: event.input_tokens || 0,
|
|
494
|
+
output_tokens: event.output_tokens || 0,
|
|
495
|
+
cost_usd: event.cost_usd ?? null,
|
|
496
|
+
source: event.source || 'cli',
|
|
497
|
+
key_ref: event.key_ref || null, // secret *name* only, never value
|
|
498
|
+
};
|
|
499
|
+
fs.appendFileSync(usagePath, JSON.stringify(row) + '\n', { mode: 0o600 });
|
|
500
|
+
return row;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Load budget.yml if present.
|
|
505
|
+
*/
|
|
506
|
+
export function loadBudgetConfig(brainDir) {
|
|
507
|
+
const candidates = [
|
|
508
|
+
path.join(brainDir, 'config', 'budget.yml'),
|
|
509
|
+
path.join(process.cwd(), 'config', 'budget.yml'),
|
|
510
|
+
];
|
|
511
|
+
for (const p of candidates) {
|
|
512
|
+
if (!fs.existsSync(p)) continue;
|
|
513
|
+
try {
|
|
514
|
+
return { path: p, config: YAML.parse(fs.readFileSync(p, 'utf8')) || {} };
|
|
515
|
+
} catch {
|
|
516
|
+
return { path: p, config: {}, error: 'parse failed' };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
path: null,
|
|
521
|
+
config: { daily_cap_usd: 5, weekly_cap_usd: 25 },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @deprecated alias */
|
|
526
|
+
export const loadBudget = loadBudgetConfig;
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Sum usage.jsonl costs for last N days.
|
|
530
|
+
*/
|
|
531
|
+
export function summarizeUsage(brainDir, { days = 1 } = {}) {
|
|
532
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
533
|
+
if (!fs.existsSync(usagePath)) {
|
|
534
|
+
return { events: 0, cost_usd: 0, input_tokens: 0, output_tokens: 0, days };
|
|
535
|
+
}
|
|
536
|
+
const cutoff = Date.now() - days * 86400000;
|
|
537
|
+
let events = 0;
|
|
538
|
+
let cost = 0;
|
|
539
|
+
let input = 0;
|
|
540
|
+
let output = 0;
|
|
541
|
+
for (const line of fs.readFileSync(usagePath, 'utf8').split('\n')) {
|
|
542
|
+
if (!line.trim()) continue;
|
|
543
|
+
try {
|
|
544
|
+
const row = JSON.parse(line);
|
|
545
|
+
const t = new Date(row.ts).getTime();
|
|
546
|
+
if (t < cutoff) continue;
|
|
547
|
+
events++;
|
|
548
|
+
cost += Number(row.cost_usd) || 0;
|
|
549
|
+
input += Number(row.input_tokens) || 0;
|
|
550
|
+
output += Number(row.output_tokens) || 0;
|
|
551
|
+
} catch {
|
|
552
|
+
// skip
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { events, cost_usd: cost, input_tokens: input, output_tokens: output, days };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Usage rollup by key_ref and provider for last N days.
|
|
560
|
+
*/
|
|
561
|
+
export function summarizeUsageByKey(brainDir, { days = 30 } = {}) {
|
|
562
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
563
|
+
const by_key = {};
|
|
564
|
+
const by_provider = {};
|
|
565
|
+
if (!fs.existsSync(usagePath)) {
|
|
566
|
+
return { by_key, by_provider, days };
|
|
567
|
+
}
|
|
568
|
+
const cutoff = Date.now() - days * 86400000;
|
|
569
|
+
for (const line of fs.readFileSync(usagePath, 'utf8').split('\n')) {
|
|
570
|
+
if (!line.trim()) continue;
|
|
571
|
+
try {
|
|
572
|
+
const row = JSON.parse(line);
|
|
573
|
+
const t = new Date(row.ts).getTime();
|
|
574
|
+
if (t < cutoff) continue;
|
|
575
|
+
const kref = row.key_ref || '_unattributed';
|
|
576
|
+
const prov = row.provider || 'unknown';
|
|
577
|
+
if (!by_key[kref]) {
|
|
578
|
+
by_key[kref] = { events: 0, cost_usd: 0, input_tokens: 0, output_tokens: 0 };
|
|
579
|
+
}
|
|
580
|
+
by_key[kref].events++;
|
|
581
|
+
by_key[kref].cost_usd += Number(row.cost_usd) || 0;
|
|
582
|
+
by_key[kref].input_tokens += Number(row.input_tokens) || 0;
|
|
583
|
+
by_key[kref].output_tokens += Number(row.output_tokens) || 0;
|
|
584
|
+
if (!by_provider[prov]) {
|
|
585
|
+
by_provider[prov] = { events: 0, cost_usd: 0, input_tokens: 0, output_tokens: 0 };
|
|
586
|
+
}
|
|
587
|
+
by_provider[prov].events++;
|
|
588
|
+
by_provider[prov].cost_usd += Number(row.cost_usd) || 0;
|
|
589
|
+
by_provider[prov].input_tokens += Number(row.input_tokens) || 0;
|
|
590
|
+
by_provider[prov].output_tokens += Number(row.output_tokens) || 0;
|
|
591
|
+
} catch {
|
|
592
|
+
// skip
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { by_key, by_provider, days };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Recent usage rows (no secrets), newest last.
|
|
600
|
+
*/
|
|
601
|
+
export function listUsageEvents(brainDir, { limit = 100, key_ref = null, days = 30 } = {}) {
|
|
602
|
+
const usagePath = resolveUsagePath(brainDir);
|
|
603
|
+
if (!fs.existsSync(usagePath)) return [];
|
|
604
|
+
const cutoff = Date.now() - days * 86400000;
|
|
605
|
+
const rows = [];
|
|
606
|
+
for (const line of fs.readFileSync(usagePath, 'utf8').split('\n')) {
|
|
607
|
+
if (!line.trim()) continue;
|
|
608
|
+
try {
|
|
609
|
+
const row = JSON.parse(line);
|
|
610
|
+
const t = new Date(row.ts).getTime();
|
|
611
|
+
if (t < cutoff) continue;
|
|
612
|
+
if (key_ref && row.key_ref !== key_ref) continue;
|
|
613
|
+
rows.push(row);
|
|
614
|
+
} catch {
|
|
615
|
+
// skip
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return rows.slice(-limit);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* True if any secret value appears in text (for surface/openwiki conformance).
|
|
623
|
+
*/
|
|
624
|
+
export async function textContainsSecrets(brainDir, text) {
|
|
625
|
+
if (!text) return { leak: false, keys: [] };
|
|
626
|
+
const secrets = await loadSecrets(brainDir);
|
|
627
|
+
const leaked = [];
|
|
628
|
+
for (const [key, val] of Object.entries(secrets)) {
|
|
629
|
+
if (key === META_KEY) continue;
|
|
630
|
+
if (typeof val === 'string' && val.length >= 8 && text.includes(val)) {
|
|
631
|
+
leaked.push(key);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { leak: leaked.length > 0, keys: leaked };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Resolve brain dir for secrets (global or project).
|
|
639
|
+
*/
|
|
640
|
+
export function defaultBrainDir() {
|
|
641
|
+
return (
|
|
642
|
+
process.env._TR_TEST_BRAIN_DIR ||
|
|
643
|
+
path.join(os.homedir(), '.agent', 'skills', 'total-recall')
|
|
644
|
+
);
|
|
645
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Semantic Index — file-native vector search alongside tf-idf
|
|
3
3
|
*
|
|
4
|
-
* Generates embeddings via a local
|
|
4
|
+
* Generates embeddings via a local embedding model and persists them
|
|
5
5
|
* in memory-derived/embeddings.jsonl — a plain JSONL file, no database.
|
|
6
6
|
*
|
|
7
|
-
* Embedding model:
|
|
7
|
+
* Embedding model: text-embedding-004 (default) or any embedding model.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* - `buildSemanticIndex(nodes, derivedDir, opts)` — called by compileSurface
|
|
@@ -134,7 +134,7 @@ async function throttledMap(items, limit, fn) {
|
|
|
134
134
|
/**
|
|
135
135
|
* Build (or incrementally update) the embeddings index from SSSS nodes.
|
|
136
136
|
* Re-embeds nodes if they are missing from the index OR if their content hash has changed.
|
|
137
|
-
* Skips silently if
|
|
137
|
+
* Skips silently if the embedding service is unreachable.
|
|
138
138
|
*
|
|
139
139
|
* @param {object[]} nodes SSSS nodes with slug, title, body/content fields
|
|
140
140
|
* @param {string} derivedDir
|