total-recall-brain 3.18.0 → 3.18.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/bin/total-recall.mjs +2 -0
- package/package.json +1 -1
- package/src/cli/update.mjs +115 -0
- package/src/core/crons.mjs +14 -0
- package/src/core/daemon-loop.mjs +14 -0
- package/src/core/embeddings.mjs +111 -38
- package/src/core/package-auto-update.mjs +401 -0
- package/src/core/sandbox.mjs +37 -22
- package/src/server/route-manifest.json +32 -0
- package/src/server/routes/update.mjs +87 -21
package/bin/total-recall.mjs
CHANGED
|
@@ -51,6 +51,7 @@ const COMMANDS = {
|
|
|
51
51
|
daemon: 'daemon.mjs',
|
|
52
52
|
restore: 'restore.mjs',
|
|
53
53
|
upgrade: 'upgrade.mjs',
|
|
54
|
+
update: 'update.mjs',
|
|
54
55
|
friction: 'friction.mjs',
|
|
55
56
|
chat: 'chat.mjs',
|
|
56
57
|
status: 'status.mjs',
|
|
@@ -109,6 +110,7 @@ function printHelp() {
|
|
|
109
110
|
status / doctor Health and diagnostics
|
|
110
111
|
|
|
111
112
|
Optional:
|
|
113
|
+
update [--apply] Auto-download total-recall-brain for registered repos
|
|
112
114
|
research, share, ingest, import, export, relay, setup, deploy,
|
|
113
115
|
start, backup, restore, config, map, generate-pat, lint, sync, …
|
|
114
116
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* total-recall update — check / install total-recall-brain on registered projects.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx total-recall update # check only
|
|
6
|
+
* npx total-recall update --apply # install latest where needed
|
|
7
|
+
* npx total-recall update --apply --force
|
|
8
|
+
* npx total-recall update --repo /path/to/app
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
runPackageAutoUpdate,
|
|
13
|
+
fetchLatestNpmVersion,
|
|
14
|
+
isPackageAutoUpdateEnabled,
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
} from '../core/package-auto-update.mjs';
|
|
17
|
+
import { brainDir } from '../core/config.mjs';
|
|
18
|
+
|
|
19
|
+
function parseArgs(args) {
|
|
20
|
+
const opts = {
|
|
21
|
+
apply: false,
|
|
22
|
+
force: false,
|
|
23
|
+
dryRun: false,
|
|
24
|
+
help: false,
|
|
25
|
+
repos: [],
|
|
26
|
+
};
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i];
|
|
29
|
+
if (a === '--help' || a === '-h') opts.help = true;
|
|
30
|
+
else if (a === '--apply' || a === '--install') opts.apply = true;
|
|
31
|
+
else if (a === '--force') opts.force = true;
|
|
32
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
33
|
+
else if (a === '--repo' && args[i + 1]) opts.repos.push(args[++i]);
|
|
34
|
+
}
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printHelp() {
|
|
39
|
+
console.log(`
|
|
40
|
+
total-recall update — Auto-download ${PACKAGE_NAME} for registered projects
|
|
41
|
+
|
|
42
|
+
Roots: project-registry + TR_SYNC_REPOS + --repo (never hardcodes product paths)
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
total-recall update Report status (dry)
|
|
46
|
+
total-recall update --apply npm install latest where behind
|
|
47
|
+
total-recall update --apply --force Ignore throttle / disabled flag
|
|
48
|
+
total-recall update --repo <path> Include extra root (repeatable)
|
|
49
|
+
|
|
50
|
+
Env:
|
|
51
|
+
TR_AUTO_UPDATE_PACKAGE=0 Disable daemon cron auto-update (default: on)
|
|
52
|
+
TR_SYNC_REPOS=/a:/b Extra project roots
|
|
53
|
+
|
|
54
|
+
Source monorepos named ${PACKAGE_NAME} are skipped (use git, not npm into self).
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function update(args = []) {
|
|
59
|
+
const opts = parseArgs(args);
|
|
60
|
+
if (opts.help) {
|
|
61
|
+
printHelp();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const latest = fetchLatestNpmVersion();
|
|
66
|
+
console.log(`\n Package: ${PACKAGE_NAME}`);
|
|
67
|
+
console.log(` Latest on npm: ${latest || '(unavailable)'}`);
|
|
68
|
+
console.log(` Auto-update (daemon): ${isPackageAutoUpdateEnabled() ? 'enabled' : 'disabled'}`);
|
|
69
|
+
|
|
70
|
+
const dryRun = !opts.apply || opts.dryRun;
|
|
71
|
+
const summary = await runPackageAutoUpdate({
|
|
72
|
+
brainDir,
|
|
73
|
+
roots: opts.repos.length ? opts.repos : undefined,
|
|
74
|
+
dryRun,
|
|
75
|
+
force: opts.force || opts.apply,
|
|
76
|
+
skipThrottle: opts.apply || opts.force,
|
|
77
|
+
save: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (summary.skipped) {
|
|
81
|
+
console.log(`\n Skipped: ${summary.reason}`);
|
|
82
|
+
if (summary.next_check_in_ms) {
|
|
83
|
+
console.log(` Next check in ~${Math.ceil(summary.next_check_in_ms / 60000)} min`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results = summary.results || [];
|
|
88
|
+
if (!results.length) {
|
|
89
|
+
console.log('\n No project roots found. Track repos with:');
|
|
90
|
+
console.log(' npx total-recall skill track /path/to/repo');
|
|
91
|
+
console.log(' # or set TR_SYNC_REPOS\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`\n Projects (${results.length}):`);
|
|
96
|
+
for (const r of results) {
|
|
97
|
+
const inst = r.installed || '—';
|
|
98
|
+
const mark =
|
|
99
|
+
r.status === 'updated' || r.status === 'would_update'
|
|
100
|
+
? '↑'
|
|
101
|
+
: r.status === 'up_to_date'
|
|
102
|
+
? '✓'
|
|
103
|
+
: r.status === 'failed'
|
|
104
|
+
? '✗'
|
|
105
|
+
: '·';
|
|
106
|
+
console.log(` ${mark} ${r.name}: ${inst} → ${r.latest || latest} [${r.status}]`);
|
|
107
|
+
if (r.error) console.log(` error: ${r.error}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (dryRun && results.some((r) => r.status === 'would_update')) {
|
|
111
|
+
console.log('\n Run with --apply to install updates.\n');
|
|
112
|
+
} else {
|
|
113
|
+
console.log('');
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/core/crons.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { logger } from './logger.mjs';
|
|
|
2
2
|
import { runGitHubSync, getGitHubSyncStatus } from './github-sync.mjs';
|
|
3
3
|
import { watchObsidianDirectory } from './obsidian-sync.mjs';
|
|
4
4
|
import { getSecret } from './secrets-store.mjs';
|
|
5
|
+
import { runPackageAutoUpdate } from './package-auto-update.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* runCrons — scheduled background jobs.
|
|
@@ -27,6 +28,19 @@ export async function runCrons(options) {
|
|
|
27
28
|
} catch {
|
|
28
29
|
// Obsidian not configured
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
// 3. npm package auto-update for registered projects (total-recall-brain)
|
|
33
|
+
try {
|
|
34
|
+
const pkg = await runPackageAutoUpdate({ brainDir });
|
|
35
|
+
if (!pkg.skipped && (pkg.updated > 0 || pkg.failed > 0)) {
|
|
36
|
+
logger.info({
|
|
37
|
+
subsystem: 'cron',
|
|
38
|
+
message: `package-auto-update: latest=${pkg.latest} updated=${pkg.updated} failed=${pkg.failed}`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
logger.warn({ subsystem: 'cron', message: `package-auto-update failed: ${err.message}` });
|
|
43
|
+
}
|
|
30
44
|
} catch (err) {
|
|
31
45
|
logger.error({ subsystem: 'cron', message: `Cron execution failed: ${err.message}` });
|
|
32
46
|
}
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -239,6 +239,20 @@ async function main() {
|
|
|
239
239
|
logger.info({ subsystem: 'daemon-loop', message: `Mesh node patch failed: ${err.message}` });
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// npm total-recall-brain auto-update for registered projects (throttled thereafter via crons)
|
|
243
|
+
try {
|
|
244
|
+
const { runPackageAutoUpdate } = await import('./package-auto-update.mjs');
|
|
245
|
+
const pkg = await runPackageAutoUpdate({ brainDir: BRAIN_DIR, skipThrottle: true });
|
|
246
|
+
if (!pkg.skipped) {
|
|
247
|
+
logger.info({
|
|
248
|
+
subsystem: 'daemon-loop',
|
|
249
|
+
message: `Boot package-auto-update: latest=${pkg.latest} updated=${pkg.updated || 0} failed=${pkg.failed || 0}`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logger.info({ subsystem: 'daemon-loop', message: `Boot package-auto-update failed: ${err.message}` });
|
|
254
|
+
}
|
|
255
|
+
|
|
242
256
|
// Deterministic election (lowest mesh IP) — tryAcquire/renew are no-op shims
|
|
243
257
|
// that re-evaluate isLeader(); no node-local lease documents are written.
|
|
244
258
|
const { tryAcquireLease, isLeader, getLeaderInfo, renewLease } = await import('./leader-election.mjs');
|
package/src/core/embeddings.mjs
CHANGED
|
@@ -15,20 +15,56 @@ import path from 'node:path';
|
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import { throttledFetch } from './throttled-fetch.mjs';
|
|
17
17
|
import Database from 'better-sqlite3';
|
|
18
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
getVectorLoadablePath,
|
|
20
|
+
getVssLoadablePath,
|
|
21
|
+
} from 'sqlite-vss';
|
|
19
22
|
|
|
20
23
|
import { googleApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
|
|
21
24
|
import { logger } from './logger.mjs';
|
|
22
25
|
|
|
23
26
|
const DEFAULT_EMBED_MODEL = embedModel;
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
|
|
28
|
+
/** Resolve API keys at call time so tests can set env after import. */
|
|
29
|
+
function resolveGoogleApiKey() {
|
|
30
|
+
return googleApiKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveOpenAiApiKey() {
|
|
34
|
+
return process.env.OPENAI_API_KEY || null;
|
|
35
|
+
}
|
|
26
36
|
|
|
27
37
|
let dbInstance = null;
|
|
28
38
|
let currentDbPath = null;
|
|
39
|
+
/** @type {WeakMap<object, boolean>} */
|
|
40
|
+
const dbVssEnabled = new WeakMap();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* better-sqlite3 on Linux appends ".so" when the path already ends in ".so",
|
|
44
|
+
* producing "vss0.so.so". Strip known extensions before loadExtension.
|
|
45
|
+
*/
|
|
46
|
+
function loadExtensionBare(db, absolutePath) {
|
|
47
|
+
const bare = String(absolutePath).replace(/\.(so|dylib|dll)$/i, '');
|
|
48
|
+
try {
|
|
49
|
+
db.loadExtension(bare);
|
|
50
|
+
} catch {
|
|
51
|
+
db.loadExtension(absolutePath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tryLoadSqliteVss(db) {
|
|
56
|
+
try {
|
|
57
|
+
loadExtensionBare(db, getVectorLoadablePath());
|
|
58
|
+
loadExtensionBare(db, getVssLoadablePath());
|
|
59
|
+
return true;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logger.warn('embeddings', `sqlite-vss unavailable; using JSON-only store: ${err.message}`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
29
65
|
|
|
30
66
|
/**
|
|
31
|
-
* Get or initialize the SQLite database connection with sqlite-vss loaded.
|
|
67
|
+
* Get or initialize the SQLite database connection with sqlite-vss loaded when available.
|
|
32
68
|
*/
|
|
33
69
|
function getDb(derivedDir) {
|
|
34
70
|
const dbPath = path.join(derivedDir, 'embeddings.db');
|
|
@@ -43,9 +79,8 @@ function getDb(derivedDir) {
|
|
|
43
79
|
fs.mkdirSync(derivedDir, { recursive: true });
|
|
44
80
|
|
|
45
81
|
const db = new Database(dbPath);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
sqliteVss.load(db);
|
|
82
|
+
const vssOk = tryLoadSqliteVss(db);
|
|
83
|
+
dbVssEnabled.set(db, vssOk);
|
|
49
84
|
|
|
50
85
|
// Initialize schema
|
|
51
86
|
db.exec(`
|
|
@@ -67,22 +102,58 @@ function getDb(derivedDir) {
|
|
|
67
102
|
generated_at TEXT,
|
|
68
103
|
embedding_json TEXT
|
|
69
104
|
);
|
|
70
|
-
|
|
71
|
-
-- Virtual tables for VSS
|
|
72
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS vss_vault_embeddings USING vss0(
|
|
73
|
-
embedding(768)
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS vss_session_embeddings USING vss0(
|
|
77
|
-
embedding(768)
|
|
78
|
-
);
|
|
79
105
|
`);
|
|
80
106
|
|
|
107
|
+
if (vssOk) {
|
|
108
|
+
db.exec(`
|
|
109
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vss_vault_embeddings USING vss0(
|
|
110
|
+
embedding(768)
|
|
111
|
+
);
|
|
112
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS vss_session_embeddings USING vss0(
|
|
113
|
+
embedding(768)
|
|
114
|
+
);
|
|
115
|
+
`);
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
dbInstance = db;
|
|
82
119
|
currentDbPath = dbPath;
|
|
83
120
|
return db;
|
|
84
121
|
}
|
|
85
122
|
|
|
123
|
+
function hasVss(db) {
|
|
124
|
+
return dbVssEnabled.get(db) === true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function vssReplaceVault(db, rowId, embeddingJson) {
|
|
128
|
+
if (!hasVss(db)) return;
|
|
129
|
+
db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
|
|
130
|
+
db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function vssDeleteVault(db, rowId) {
|
|
134
|
+
if (!hasVss(db)) return;
|
|
135
|
+
db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function vssReplaceSession(db, rowId, embeddingJson) {
|
|
139
|
+
if (!hasVss(db)) return;
|
|
140
|
+
db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
|
|
141
|
+
db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function vssDeleteSession(db, rowId) {
|
|
145
|
+
if (!hasVss(db)) return;
|
|
146
|
+
db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function vssClearAll(db) {
|
|
150
|
+
if (!hasVss(db)) {
|
|
151
|
+
db.exec('DELETE FROM vault_embeddings; DELETE FROM session_embeddings;');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
db.exec('DELETE FROM vss_vault_embeddings; DELETE FROM vault_embeddings; DELETE FROM vss_session_embeddings; DELETE FROM session_embeddings;');
|
|
155
|
+
}
|
|
156
|
+
|
|
86
157
|
function sha256(text) {
|
|
87
158
|
return crypto.createHash('sha256').update(text).digest('hex');
|
|
88
159
|
}
|
|
@@ -94,12 +165,13 @@ async function resolveEmbeddingModel(selectedModel = embedModel) {
|
|
|
94
165
|
return selectedModel;
|
|
95
166
|
}
|
|
96
167
|
|
|
97
|
-
|
|
168
|
+
const googleKey = resolveGoogleApiKey();
|
|
169
|
+
if (!googleKey) {
|
|
98
170
|
return 'gemini-embedding-2';
|
|
99
171
|
}
|
|
100
172
|
|
|
101
173
|
try {
|
|
102
|
-
const res = await throttledFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${
|
|
174
|
+
const res = await throttledFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${googleKey}`, {
|
|
103
175
|
signal: AbortSignal.timeout(5000)
|
|
104
176
|
});
|
|
105
177
|
if (res.ok) {
|
|
@@ -124,12 +196,13 @@ async function resolveEmbeddingModel(selectedModel = embedModel) {
|
|
|
124
196
|
}
|
|
125
197
|
|
|
126
198
|
async function getGoogleEmbedding(text, model) {
|
|
127
|
-
|
|
199
|
+
const googleKey = resolveGoogleApiKey();
|
|
200
|
+
if (!googleKey) {
|
|
128
201
|
throw new Error('GOOGLE_API_KEY not set. Cannot generate embeddings.');
|
|
129
202
|
}
|
|
130
203
|
|
|
131
204
|
const resolved = await resolveEmbeddingModel(model);
|
|
132
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${
|
|
205
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${googleKey}`;
|
|
133
206
|
|
|
134
207
|
const res = await throttledFetch(url, {
|
|
135
208
|
method: 'POST',
|
|
@@ -155,7 +228,8 @@ async function getGoogleEmbedding(text, model) {
|
|
|
155
228
|
}
|
|
156
229
|
|
|
157
230
|
async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
158
|
-
|
|
231
|
+
const openaiKey = resolveOpenAiApiKey();
|
|
232
|
+
if (!openaiKey) {
|
|
159
233
|
throw new Error('Neither GOOGLE_API_KEY nor OPENAI_API_KEY is set. Cannot generate embeddings.');
|
|
160
234
|
}
|
|
161
235
|
|
|
@@ -163,7 +237,7 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
|
163
237
|
method: 'POST',
|
|
164
238
|
headers: {
|
|
165
239
|
'Content-Type': 'application/json',
|
|
166
|
-
'Authorization': `Bearer ${
|
|
240
|
+
'Authorization': `Bearer ${openaiKey}`,
|
|
167
241
|
},
|
|
168
242
|
body: JSON.stringify({ model, input: text }),
|
|
169
243
|
signal: AbortSignal.timeout(30000),
|
|
@@ -184,9 +258,11 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
|
|
|
184
258
|
|
|
185
259
|
export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
186
260
|
const input = String(text).slice(0, 8000);
|
|
261
|
+
const googleKey = resolveGoogleApiKey();
|
|
262
|
+
const openaiKey = resolveOpenAiApiKey();
|
|
187
263
|
|
|
188
264
|
let embedding;
|
|
189
|
-
if (
|
|
265
|
+
if (googleKey) {
|
|
190
266
|
try {
|
|
191
267
|
embedding = await getGoogleEmbedding(input, model);
|
|
192
268
|
} catch (err) {
|
|
@@ -194,7 +270,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
|
|
|
194
270
|
}
|
|
195
271
|
}
|
|
196
272
|
|
|
197
|
-
if (!embedding &&
|
|
273
|
+
if (!embedding && openaiKey) {
|
|
198
274
|
try {
|
|
199
275
|
embedding = await getOpenAIEmbedding(input);
|
|
200
276
|
} catch (err) {
|
|
@@ -254,10 +330,8 @@ export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAUL
|
|
|
254
330
|
embedding_json=excluded.embedding_json
|
|
255
331
|
`).run(slug, model, now, embeddingJson);
|
|
256
332
|
|
|
257
|
-
// Add to vss index
|
|
258
333
|
const rowId = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug).rowid;
|
|
259
|
-
db
|
|
260
|
-
db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
|
|
334
|
+
vssReplaceVault(db, rowId, embeddingJson);
|
|
261
335
|
})();
|
|
262
336
|
}
|
|
263
337
|
|
|
@@ -266,7 +340,7 @@ export function removeEmbeddingFromIndex(derivedDir, slug) {
|
|
|
266
340
|
db.transaction(() => {
|
|
267
341
|
const row = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug);
|
|
268
342
|
if (row) {
|
|
269
|
-
db
|
|
343
|
+
vssDeleteVault(db, row.rowid);
|
|
270
344
|
db.prepare('DELETE FROM vault_embeddings WHERE slug = ?').run(slug);
|
|
271
345
|
}
|
|
272
346
|
})();
|
|
@@ -295,7 +369,8 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
|
|
|
295
369
|
const db = getDb(derivedDir);
|
|
296
370
|
|
|
297
371
|
if (force) {
|
|
298
|
-
db.exec('DELETE FROM vss_vault_embeddings;
|
|
372
|
+
if (hasVss(db)) db.exec('DELETE FROM vss_vault_embeddings;');
|
|
373
|
+
db.exec('DELETE FROM vault_embeddings;');
|
|
299
374
|
}
|
|
300
375
|
|
|
301
376
|
const existing = force ? {} : loadEmbeddingsIndex(derivedDir);
|
|
@@ -312,7 +387,7 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
|
|
|
312
387
|
for (const slug of toDelete) {
|
|
313
388
|
const row = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug);
|
|
314
389
|
if (row) {
|
|
315
|
-
db
|
|
390
|
+
vssDeleteVault(db, row.rowid);
|
|
316
391
|
db.prepare('DELETE FROM vault_embeddings WHERE slug = ?').run(slug);
|
|
317
392
|
}
|
|
318
393
|
}
|
|
@@ -350,8 +425,7 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
|
|
|
350
425
|
`).run(node.slug, model, now, contentHash, embeddingJson);
|
|
351
426
|
|
|
352
427
|
const rowId = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(node.slug).rowid;
|
|
353
|
-
db
|
|
354
|
-
db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
|
|
428
|
+
vssReplaceVault(db, rowId, embeddingJson);
|
|
355
429
|
})();
|
|
356
430
|
|
|
357
431
|
built++;
|
|
@@ -405,8 +479,7 @@ export function saveSessionEmbeddingToIndex(derivedDir, key, snippet, embedding,
|
|
|
405
479
|
`).run(key, session_id, chunk, 1, snippet.slice(0, 300), model, now, embeddingJson);
|
|
406
480
|
|
|
407
481
|
const rowId = db.prepare('SELECT rowid FROM session_embeddings WHERE key = ?').get(key).rowid;
|
|
408
|
-
db
|
|
409
|
-
db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
|
|
482
|
+
vssReplaceSession(db, rowId, embeddingJson);
|
|
410
483
|
})();
|
|
411
484
|
}
|
|
412
485
|
|
|
@@ -415,7 +488,7 @@ export function removeSessionEmbeddingFromIndex(derivedDir, sessionId) {
|
|
|
415
488
|
db.transaction(() => {
|
|
416
489
|
const rows = db.prepare('SELECT rowid, key FROM session_embeddings WHERE session_id = ?').all(sessionId);
|
|
417
490
|
for (const row of rows) {
|
|
418
|
-
db
|
|
491
|
+
vssDeleteSession(db, row.rowid);
|
|
419
492
|
db.prepare('DELETE FROM session_embeddings WHERE key = ?').run(row.key);
|
|
420
493
|
}
|
|
421
494
|
})();
|
|
@@ -462,7 +535,8 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
|
|
|
462
535
|
if (files.length === 0) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
|
|
463
536
|
|
|
464
537
|
if (force) {
|
|
465
|
-
db.exec('DELETE FROM vss_session_embeddings;
|
|
538
|
+
if (hasVss(db)) db.exec('DELETE FROM vss_session_embeddings;');
|
|
539
|
+
db.exec('DELETE FROM session_embeddings;');
|
|
466
540
|
}
|
|
467
541
|
|
|
468
542
|
const existing = force ? {} : loadSessionEmbeddingsIndex(derivedDir);
|
|
@@ -497,8 +571,7 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
|
|
|
497
571
|
`).run(key, sessionId, i, chunks.length, snippet, model, new Date().toISOString(), JSON.stringify(embedding));
|
|
498
572
|
|
|
499
573
|
const rowId = db.prepare('SELECT rowid FROM session_embeddings WHERE key = ?').get(key).rowid;
|
|
500
|
-
db
|
|
501
|
-
db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, JSON.stringify(embedding));
|
|
574
|
+
vssReplaceSession(db, rowId, JSON.stringify(embedding));
|
|
502
575
|
})();
|
|
503
576
|
|
|
504
577
|
totalChunks++;
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-download total-recall-brain updates into registered project roots.
|
|
3
|
+
*
|
|
4
|
+
* Roots come only from:
|
|
5
|
+
* - global/project project-registry.json
|
|
6
|
+
* - TR_SYNC_REPOS env (path separators : or ,)
|
|
7
|
+
* - optional explicit roots[] (CLI / API)
|
|
8
|
+
*
|
|
9
|
+
* Never hardcodes product repo paths. Opt-out: TR_AUTO_UPDATE_PACKAGE=0
|
|
10
|
+
* Source monorepos (package name total-recall-brain with src/server) are skipped
|
|
11
|
+
* for npm install — they track git, not the published tarball.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { logger } from './logger.mjs';
|
|
19
|
+
|
|
20
|
+
export const PACKAGE_NAME = 'total-recall-brain';
|
|
21
|
+
export const DEFAULT_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isPackageAutoUpdateEnabled() {
|
|
27
|
+
const v = process.env.TR_AUTO_UPDATE_PACKAGE ?? process.env.TR_AUTO_UPDATE;
|
|
28
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
29
|
+
// Default on so consumer installs stay current once the daemon runs.
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} [brainDir]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function packageUpdateStatePath(brainDir) {
|
|
38
|
+
const base = brainDir || path.join(os.homedir(), '.agent', 'skills', 'total-recall');
|
|
39
|
+
return path.join(base, 'memory-derived', 'package-auto-update.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} statePath
|
|
44
|
+
* @returns {{ last_check_at?: string, last_latest?: string, last_results?: object[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function loadUpdateState(statePath) {
|
|
47
|
+
try {
|
|
48
|
+
if (!fs.existsSync(statePath)) return {};
|
|
49
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf8')) || {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} statePath
|
|
57
|
+
* @param {object} state
|
|
58
|
+
*/
|
|
59
|
+
export function saveUpdateState(statePath, state) {
|
|
60
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve registry JSON paths (global brain + optional brainDir config).
|
|
66
|
+
* @param {{ brainDir?: string }} [opts]
|
|
67
|
+
* @returns {string[]}
|
|
68
|
+
*/
|
|
69
|
+
export function resolveRegistryFiles(opts = {}) {
|
|
70
|
+
const files = [];
|
|
71
|
+
const globalReg = path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'config', 'project-registry.json');
|
|
72
|
+
if (fs.existsSync(globalReg)) files.push(globalReg);
|
|
73
|
+
if (opts.brainDir) {
|
|
74
|
+
const local = path.join(opts.brainDir, 'config', 'project-registry.json');
|
|
75
|
+
if (fs.existsSync(local) && local !== globalReg) files.push(local);
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} registryPath
|
|
82
|
+
* @returns {Array<{ name?: string, path?: string }>}
|
|
83
|
+
*/
|
|
84
|
+
export function readRegistryEntries(registryPath) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
87
|
+
return Array.isArray(raw) ? raw : [];
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function rootsFromEnv() {
|
|
97
|
+
const raw = process.env.TR_SYNC_REPOS || '';
|
|
98
|
+
if (!raw.trim()) return [];
|
|
99
|
+
return raw
|
|
100
|
+
.split(/[:|,]/)
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((p) => path.resolve(p));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Unique absolute project roots from registry + env + explicit.
|
|
108
|
+
* @param {{ brainDir?: string, roots?: string[] }} [opts]
|
|
109
|
+
* @returns {Array<{ root: string, name: string, source: string }>}
|
|
110
|
+
*/
|
|
111
|
+
export function listUpdateRoots(opts = {}) {
|
|
112
|
+
const byRoot = new Map();
|
|
113
|
+
|
|
114
|
+
function add(root, name, source) {
|
|
115
|
+
if (!root || typeof root !== 'string') return;
|
|
116
|
+
const abs = path.resolve(root);
|
|
117
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return;
|
|
118
|
+
if (!byRoot.has(abs)) {
|
|
119
|
+
byRoot.set(abs, { root: abs, name: name || path.basename(abs), source });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const file of resolveRegistryFiles(opts)) {
|
|
124
|
+
for (const entry of readRegistryEntries(file)) {
|
|
125
|
+
add(entry.path, entry.name, 'project-registry');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const r of rootsFromEnv()) {
|
|
129
|
+
add(r, path.basename(r), 'TR_SYNC_REPOS');
|
|
130
|
+
}
|
|
131
|
+
for (const r of opts.roots || []) {
|
|
132
|
+
add(r, path.basename(path.resolve(r)), 'explicit');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return [...byRoot.values()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {string} projectRoot
|
|
140
|
+
* @returns {string|null}
|
|
141
|
+
*/
|
|
142
|
+
export function readPackageJsonVersionField(projectRoot, field = 'version') {
|
|
143
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
144
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
145
|
+
try {
|
|
146
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
147
|
+
return pkg?.[field] ?? null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} projectRoot
|
|
155
|
+
* @returns {{ declared: string|null, installed: string|null, isSourceTree: boolean, packageName: string|null }}
|
|
156
|
+
*/
|
|
157
|
+
export function inspectProjectPackage(projectRoot) {
|
|
158
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
159
|
+
if (!fs.existsSync(pkgPath)) {
|
|
160
|
+
return { declared: null, installed: null, isSourceTree: false, packageName: null };
|
|
161
|
+
}
|
|
162
|
+
let pkg = {};
|
|
163
|
+
try {
|
|
164
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) || {};
|
|
165
|
+
} catch {
|
|
166
|
+
return { declared: null, installed: null, isSourceTree: false, packageName: null };
|
|
167
|
+
}
|
|
168
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
169
|
+
const declared = deps[PACKAGE_NAME] || null;
|
|
170
|
+
let installed = null;
|
|
171
|
+
const nm = path.join(projectRoot, 'node_modules', PACKAGE_NAME, 'package.json');
|
|
172
|
+
if (fs.existsSync(nm)) {
|
|
173
|
+
try {
|
|
174
|
+
installed = JSON.parse(fs.readFileSync(nm, 'utf8')).version || null;
|
|
175
|
+
} catch {
|
|
176
|
+
installed = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const isSourceTree =
|
|
180
|
+
pkg.name === PACKAGE_NAME &&
|
|
181
|
+
fs.existsSync(path.join(projectRoot, 'src', 'server', 'index.mjs'));
|
|
182
|
+
return { declared, installed, isSourceTree, packageName: pkg.name || null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
187
|
+
* @returns {string|null}
|
|
188
|
+
*/
|
|
189
|
+
export function fetchLatestNpmVersion(opts = {}) {
|
|
190
|
+
const timeout = opts.timeoutMs ?? 20_000;
|
|
191
|
+
const result = spawnSync('npm', ['view', PACKAGE_NAME, 'version'], {
|
|
192
|
+
encoding: 'utf8',
|
|
193
|
+
timeout,
|
|
194
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
+
});
|
|
196
|
+
if (!result || result.status !== 0) return null;
|
|
197
|
+
const v = (result.stdout || '').trim();
|
|
198
|
+
return v || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Semver-ish compare: true if installed is missing or older than latest.
|
|
203
|
+
* Handles simple x.y.z only (npm publish tags).
|
|
204
|
+
* @param {string|null} installed
|
|
205
|
+
* @param {string} latest
|
|
206
|
+
*/
|
|
207
|
+
export function needsUpdate(installed, latest) {
|
|
208
|
+
if (!latest) return false;
|
|
209
|
+
if (!installed) return true;
|
|
210
|
+
if (installed === latest) return false;
|
|
211
|
+
const a = installed.split('.').map((n) => parseInt(n, 10) || 0);
|
|
212
|
+
const b = latest.split('.').map((n) => parseInt(n, 10) || 0);
|
|
213
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
214
|
+
const x = a[i] || 0;
|
|
215
|
+
const y = b[i] || 0;
|
|
216
|
+
if (x < y) return true;
|
|
217
|
+
if (x > y) return false;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} projectRoot
|
|
224
|
+
* @param {string} latest
|
|
225
|
+
* @param {{ dryRun?: boolean, save?: boolean }} [opts]
|
|
226
|
+
* @returns {{ ok: boolean, action: string, error?: string, code?: number|null }}
|
|
227
|
+
*/
|
|
228
|
+
export function installPackageInProject(projectRoot, latest, opts = {}) {
|
|
229
|
+
const dryRun = Boolean(opts.dryRun);
|
|
230
|
+
const save = opts.save !== false;
|
|
231
|
+
const spec = `${PACKAGE_NAME}@${latest}`;
|
|
232
|
+
if (dryRun) {
|
|
233
|
+
return { ok: true, action: `dry-run: npm install ${spec}${save ? ' --save' : ' --no-save'}` };
|
|
234
|
+
}
|
|
235
|
+
const args = ['install', spec];
|
|
236
|
+
if (save) args.push('--save');
|
|
237
|
+
else args.push('--no-save');
|
|
238
|
+
// Prefer clean installs in CI-like envs
|
|
239
|
+
if (process.env.CI === 'true') args.push('--no-fund', '--no-audit');
|
|
240
|
+
const result = spawnSync('npm', args, {
|
|
241
|
+
cwd: projectRoot,
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
timeout: 10 * 60 * 1000,
|
|
244
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
245
|
+
});
|
|
246
|
+
if (result.status !== 0) {
|
|
247
|
+
const err = (result.stderr || result.stdout || 'npm install failed').trim().slice(0, 500);
|
|
248
|
+
return { ok: false, action: `npm install ${spec}`, error: err, code: result.status };
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, action: `installed ${spec}`, code: 0 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Scan roots and optionally install updates.
|
|
255
|
+
*
|
|
256
|
+
* @param {{
|
|
257
|
+
* brainDir?: string,
|
|
258
|
+
* roots?: string[],
|
|
259
|
+
* dryRun?: boolean,
|
|
260
|
+
* force?: boolean,
|
|
261
|
+
* skipThrottle?: boolean,
|
|
262
|
+
* intervalMs?: number,
|
|
263
|
+
* save?: boolean,
|
|
264
|
+
* }} [opts]
|
|
265
|
+
* @returns {Promise<object>}
|
|
266
|
+
*/
|
|
267
|
+
export async function runPackageAutoUpdate(opts = {}) {
|
|
268
|
+
const enabled = opts.force || isPackageAutoUpdateEnabled();
|
|
269
|
+
if (!enabled) {
|
|
270
|
+
return { skipped: true, reason: 'disabled', results: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const brainDir = opts.brainDir || path.join(os.homedir(), '.agent', 'skills', 'total-recall');
|
|
274
|
+
const statePath = packageUpdateStatePath(brainDir);
|
|
275
|
+
const state = loadUpdateState(statePath);
|
|
276
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
|
|
277
|
+
|
|
278
|
+
if (!opts.skipThrottle && !opts.force && state.last_check_at) {
|
|
279
|
+
const elapsed = Date.now() - new Date(state.last_check_at).getTime();
|
|
280
|
+
if (Number.isFinite(elapsed) && elapsed < intervalMs) {
|
|
281
|
+
return {
|
|
282
|
+
skipped: true,
|
|
283
|
+
reason: 'throttled',
|
|
284
|
+
next_check_in_ms: intervalMs - elapsed,
|
|
285
|
+
last_check_at: state.last_check_at,
|
|
286
|
+
last_latest: state.last_latest,
|
|
287
|
+
results: state.last_results || [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const latest = fetchLatestNpmVersion();
|
|
293
|
+
if (!latest) {
|
|
294
|
+
logger.warn('package-auto-update', `Could not resolve npm version for ${PACKAGE_NAME}`);
|
|
295
|
+
return { skipped: true, reason: 'npm-view-failed', results: [] };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const roots = listUpdateRoots({ brainDir, roots: opts.roots });
|
|
299
|
+
const results = [];
|
|
300
|
+
|
|
301
|
+
for (const { root, name, source } of roots) {
|
|
302
|
+
const info = inspectProjectPackage(root);
|
|
303
|
+
if (info.isSourceTree) {
|
|
304
|
+
results.push({
|
|
305
|
+
name,
|
|
306
|
+
root,
|
|
307
|
+
source,
|
|
308
|
+
status: 'skipped_source_tree',
|
|
309
|
+
declared: info.declared,
|
|
310
|
+
installed: info.installed || readPackageJsonVersionField(root),
|
|
311
|
+
latest,
|
|
312
|
+
message: 'Source monorepo — use git pull / release tags, not npm tarball into self',
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!info.declared && !info.installed) {
|
|
317
|
+
// Only touch projects that already depend on or install the package
|
|
318
|
+
results.push({
|
|
319
|
+
name,
|
|
320
|
+
root,
|
|
321
|
+
source,
|
|
322
|
+
status: 'skipped_no_dependency',
|
|
323
|
+
declared: null,
|
|
324
|
+
installed: null,
|
|
325
|
+
latest,
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const installed = info.installed;
|
|
331
|
+
if (!needsUpdate(installed, latest)) {
|
|
332
|
+
results.push({
|
|
333
|
+
name,
|
|
334
|
+
root,
|
|
335
|
+
source,
|
|
336
|
+
status: 'up_to_date',
|
|
337
|
+
declared: info.declared,
|
|
338
|
+
installed,
|
|
339
|
+
latest,
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const install = installPackageInProject(root, latest, {
|
|
345
|
+
dryRun: opts.dryRun,
|
|
346
|
+
save: opts.save !== false,
|
|
347
|
+
});
|
|
348
|
+
results.push({
|
|
349
|
+
name,
|
|
350
|
+
root,
|
|
351
|
+
source,
|
|
352
|
+
status: install.ok ? (opts.dryRun ? 'would_update' : 'updated') : 'failed',
|
|
353
|
+
declared: info.declared,
|
|
354
|
+
installed: install.ok && !opts.dryRun ? latest : installed,
|
|
355
|
+
previous: installed,
|
|
356
|
+
latest,
|
|
357
|
+
action: install.action,
|
|
358
|
+
error: install.error || null,
|
|
359
|
+
});
|
|
360
|
+
if (install.ok && !opts.dryRun) {
|
|
361
|
+
logger.info(
|
|
362
|
+
'package-auto-update',
|
|
363
|
+
`Updated ${name} → ${PACKAGE_NAME}@${latest} (${root})`,
|
|
364
|
+
);
|
|
365
|
+
} else if (!install.ok) {
|
|
366
|
+
logger.warn(
|
|
367
|
+
'package-auto-update',
|
|
368
|
+
`Failed updating ${name}: ${install.error || 'unknown'}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const summary = {
|
|
374
|
+
skipped: false,
|
|
375
|
+
package: PACKAGE_NAME,
|
|
376
|
+
latest,
|
|
377
|
+
checked_at: new Date().toISOString(),
|
|
378
|
+
root_count: roots.length,
|
|
379
|
+
updated: results.filter((r) => r.status === 'updated').length,
|
|
380
|
+
would_update: results.filter((r) => r.status === 'would_update').length,
|
|
381
|
+
failed: results.filter((r) => r.status === 'failed').length,
|
|
382
|
+
up_to_date: results.filter((r) => r.status === 'up_to_date').length,
|
|
383
|
+
results,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (!opts.dryRun) {
|
|
387
|
+
saveUpdateState(statePath, {
|
|
388
|
+
last_check_at: summary.checked_at,
|
|
389
|
+
last_latest: latest,
|
|
390
|
+
last_results: results.map((r) => ({
|
|
391
|
+
name: r.name,
|
|
392
|
+
root: r.root,
|
|
393
|
+
status: r.status,
|
|
394
|
+
installed: r.installed,
|
|
395
|
+
latest: r.latest,
|
|
396
|
+
})),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return summary;
|
|
401
|
+
}
|
package/src/core/sandbox.mjs
CHANGED
|
@@ -90,7 +90,8 @@ export function validateCommand(commandLine) {
|
|
|
90
90
|
* Executes untrusted agent code in a restricted container or OS-level sandbox.
|
|
91
91
|
*/
|
|
92
92
|
export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
|
|
93
|
-
|
|
93
|
+
// Allow short timeouts for tests; clamp upper bound for safety.
|
|
94
|
+
const cappedTimeout = Math.min(Math.max(Number(timeoutMs) || 5000, 200), 30000);
|
|
94
95
|
const allowNetwork = options.allowNetwork === true;
|
|
95
96
|
|
|
96
97
|
// Restrict environment variables
|
|
@@ -104,6 +105,8 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
|
|
|
104
105
|
safeEnv.NODE_OPTIONS = '--experimental-vm-modules';
|
|
105
106
|
|
|
106
107
|
// OS-specific sandboxing wrappers
|
|
108
|
+
// Prefer plain node + hard kill timeout. unshare --pid --fork can orphan infinite
|
|
109
|
+
// loops so the parent wait never ends on Linux CI.
|
|
107
110
|
let cmd = process.execPath;
|
|
108
111
|
let args = ['--no-warnings', scriptPath];
|
|
109
112
|
let tempSbProfilePath = null;
|
|
@@ -119,26 +122,25 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
|
|
|
119
122
|
cmd = 'sandbox-exec';
|
|
120
123
|
args = ['-f', tempSbProfilePath, process.execPath, '--no-warnings', scriptPath];
|
|
121
124
|
}
|
|
122
|
-
} else if (process.platform === 'linux') {
|
|
123
|
-
// Linux namespace isolation check via unshare
|
|
124
|
-
const hasUnshare = fs.existsSync('/usr/bin/unshare') || fs.existsSync('/bin/unshare');
|
|
125
|
-
if (hasUnshare) {
|
|
126
|
-
cmd = 'unshare';
|
|
127
|
-
const unshareArgs = ['--mount', '--ipc', '--pid', '--fork'];
|
|
128
|
-
if (!allowNetwork) {
|
|
129
|
-
unshareArgs.push('--net');
|
|
130
|
-
}
|
|
131
|
-
args = [...unshareArgs, process.execPath, '--no-warnings', scriptPath];
|
|
132
|
-
}
|
|
133
125
|
}
|
|
134
126
|
} catch (err) {
|
|
135
127
|
logger.warn('sandbox', `Failed to configure OS-specific isolation: ${err.message}. Using default environment limits.`);
|
|
136
128
|
}
|
|
137
129
|
|
|
138
130
|
return new Promise((resolve) => {
|
|
131
|
+
let settled = false;
|
|
132
|
+
const finish = (payload) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
if (tempSbProfilePath && fs.existsSync(tempSbProfilePath)) {
|
|
136
|
+
try { fs.unlinkSync(tempSbProfilePath); } catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
resolve(payload);
|
|
139
|
+
};
|
|
140
|
+
|
|
139
141
|
const proc = spawn(cmd, args, {
|
|
140
142
|
env: safeEnv,
|
|
141
|
-
|
|
143
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
144
|
});
|
|
143
145
|
|
|
144
146
|
let stdout = '';
|
|
@@ -147,22 +149,35 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
|
|
|
147
149
|
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
148
150
|
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
149
151
|
|
|
152
|
+
const timer = setTimeout(() => {
|
|
153
|
+
try {
|
|
154
|
+
proc.kill('SIGKILL');
|
|
155
|
+
} catch { /* ignore */ }
|
|
156
|
+
// Also try process group if detached later
|
|
157
|
+
try {
|
|
158
|
+
if (proc.pid) process.kill(proc.pid, 'SIGKILL');
|
|
159
|
+
} catch { /* ignore */ }
|
|
160
|
+
finish({
|
|
161
|
+
success: false,
|
|
162
|
+
output: stderr || stdout || `Sandbox timeout after ${cappedTimeout}ms`,
|
|
163
|
+
code: null,
|
|
164
|
+
signal: 'SIGKILL',
|
|
165
|
+
timedOut: true,
|
|
166
|
+
});
|
|
167
|
+
}, cappedTimeout);
|
|
168
|
+
|
|
150
169
|
proc.on('close', (code, signal) => {
|
|
151
|
-
|
|
152
|
-
try { fs.unlinkSync(tempSbProfilePath); } catch {}
|
|
153
|
-
}
|
|
170
|
+
clearTimeout(timer);
|
|
154
171
|
if (code === 0) {
|
|
155
|
-
|
|
172
|
+
finish({ success: true, output: stdout, code, signal });
|
|
156
173
|
} else {
|
|
157
|
-
|
|
174
|
+
finish({ success: false, output: stderr || stdout, code, signal });
|
|
158
175
|
}
|
|
159
176
|
});
|
|
160
177
|
|
|
161
178
|
proc.on('error', (err) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
resolve({ success: false, output: err.message, code: -1 });
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
finish({ success: false, output: err.message, code: -1 });
|
|
166
181
|
});
|
|
167
182
|
});
|
|
168
183
|
}
|
|
@@ -263,10 +263,38 @@
|
|
|
263
263
|
"method": "GET",
|
|
264
264
|
"path": "/api/memory/stats"
|
|
265
265
|
},
|
|
266
|
+
{
|
|
267
|
+
"method": "GET",
|
|
268
|
+
"path": "/api/mesh/election/history"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"method": "POST",
|
|
272
|
+
"path": "/api/mesh/election/log"
|
|
273
|
+
},
|
|
266
274
|
{
|
|
267
275
|
"method": "POST",
|
|
268
276
|
"path": "/api/mesh/election/refresh"
|
|
269
277
|
},
|
|
278
|
+
{
|
|
279
|
+
"method": "GET",
|
|
280
|
+
"path": "/api/mesh/interfaces"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"method": "GET",
|
|
284
|
+
"path": "/api/mesh/io"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"method": "GET",
|
|
288
|
+
"path": "/api/mesh/lan"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"method": "POST",
|
|
292
|
+
"path": "/api/mesh/lan/register"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"method": "GET",
|
|
296
|
+
"path": "/api/mesh/latency"
|
|
297
|
+
},
|
|
270
298
|
{
|
|
271
299
|
"method": "GET",
|
|
272
300
|
"path": "/api/mesh/leader"
|
|
@@ -683,6 +711,10 @@
|
|
|
683
711
|
"method": "GET",
|
|
684
712
|
"path": "/api/webhooks/events"
|
|
685
713
|
},
|
|
714
|
+
{
|
|
715
|
+
"method": "POST",
|
|
716
|
+
"path": "/api/webhooks/events/:id/redeliver"
|
|
717
|
+
},
|
|
686
718
|
{
|
|
687
719
|
"method": "POST",
|
|
688
720
|
"path": "/api/webhooks/test/:provider"
|
|
@@ -1,39 +1,105 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
-
import { requireAuth } from '../auth.mjs';
|
|
2
|
+
import { requireAuth, requireScope } from '../auth.mjs';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import { ROOT, serverError } from './_shared.mjs';
|
|
6
|
+
import { brainDir as configBrainDir } from '../../core/config.mjs';
|
|
7
|
+
import {
|
|
8
|
+
PACKAGE_NAME,
|
|
9
|
+
fetchLatestNpmVersion,
|
|
10
|
+
inspectProjectPackage,
|
|
11
|
+
listUpdateRoots,
|
|
12
|
+
runPackageAutoUpdate,
|
|
13
|
+
needsUpdate,
|
|
14
|
+
isPackageAutoUpdateEnabled,
|
|
15
|
+
} from '../../core/package-auto-update.mjs';
|
|
5
16
|
|
|
6
17
|
const router = Router();
|
|
7
18
|
|
|
8
|
-
router.get('/api/update/check', requireAuth, async (
|
|
19
|
+
router.get('/api/update/check', requireAuth, async (_req, res) => {
|
|
9
20
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Host package (this install)
|
|
22
|
+
let current = null;
|
|
23
|
+
try {
|
|
24
|
+
const pkg = await import('../../../package.json', { with: { type: 'json' } });
|
|
25
|
+
current = pkg.default.version;
|
|
26
|
+
} catch {
|
|
27
|
+
current = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const latest = fetchLatestNpmVersion() || '';
|
|
31
|
+
const roots = listUpdateRoots({ brainDir: configBrainDir });
|
|
32
|
+
const projects = roots.map(({ root, name, source }) => {
|
|
33
|
+
const info = inspectProjectPackage(root);
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
root,
|
|
37
|
+
source,
|
|
38
|
+
declared: info.declared,
|
|
39
|
+
installed: info.installed,
|
|
40
|
+
is_source_tree: info.isSourceTree,
|
|
41
|
+
update_available: !info.isSourceTree && needsUpdate(info.installed, latest),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const consumersBehind = projects.filter((p) => p.update_available).length;
|
|
46
|
+
|
|
47
|
+
res.json({
|
|
48
|
+
package: PACKAGE_NAME,
|
|
49
|
+
current,
|
|
50
|
+
latest,
|
|
51
|
+
update_available: Boolean(latest && current && current !== latest) || consumersBehind > 0,
|
|
52
|
+
auto_update_enabled: isPackageAutoUpdateEnabled(),
|
|
53
|
+
projects,
|
|
54
|
+
consumers_behind: consumersBehind,
|
|
22
55
|
});
|
|
23
56
|
} catch (err) {
|
|
24
57
|
serverError(res, err);
|
|
25
58
|
}
|
|
26
59
|
});
|
|
27
60
|
|
|
28
|
-
|
|
61
|
+
/**
|
|
62
|
+
* POST /api/update/run
|
|
63
|
+
* Body optional: { dryRun?: boolean, force?: boolean, roots?: string[] }
|
|
64
|
+
* Runs multi-repo npm install for total-recall-brain@latest via package-auto-update.
|
|
65
|
+
*/
|
|
66
|
+
router.post('/api/update/run', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
29
67
|
try {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
68
|
+
const dryRun = Boolean(req.body?.dryRun);
|
|
69
|
+
const force = req.body?.force !== false;
|
|
70
|
+
const roots = Array.isArray(req.body?.roots) ? req.body.roots : undefined;
|
|
71
|
+
|
|
72
|
+
// Fire multi-project updater (awaits — install can take minutes; client should timeout high)
|
|
73
|
+
const summary = await runPackageAutoUpdate({
|
|
74
|
+
brainDir: configBrainDir,
|
|
75
|
+
roots,
|
|
76
|
+
dryRun,
|
|
77
|
+
force,
|
|
78
|
+
skipThrottle: true,
|
|
79
|
+
save: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Also kick a local install in ROOT when this host is a consumer (non-source)
|
|
83
|
+
const hostInfo = inspectProjectPackage(ROOT);
|
|
84
|
+
if (!hostInfo.isSourceTree && (hostInfo.declared || hostInfo.installed) && !dryRun) {
|
|
85
|
+
const latest = summary.latest || fetchLatestNpmVersion();
|
|
86
|
+
if (latest && needsUpdate(hostInfo.installed, latest)) {
|
|
87
|
+
const proc = spawn('npm', ['install', `${PACKAGE_NAME}@${latest}`, '--save'], {
|
|
88
|
+
cwd: ROOT,
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: 'ignore',
|
|
91
|
+
});
|
|
92
|
+
proc.unref();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
res.json({
|
|
97
|
+
updating: !dryRun,
|
|
98
|
+
message: dryRun
|
|
99
|
+
? 'Dry-run complete — see projects list'
|
|
100
|
+
: 'Package auto-update finished for registered projects',
|
|
101
|
+
summary,
|
|
34
102
|
});
|
|
35
|
-
proc.unref();
|
|
36
|
-
res.json({ updating: true, message: 'Update started in background. Daemon will restart automatically.' });
|
|
37
103
|
} catch (err) {
|
|
38
104
|
serverError(res, err);
|
|
39
105
|
}
|