total-agent-memory 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -12
- package/bin/cli.js +16 -8
- package/lib/commands.js +102 -0
- package/lib/doctor.js +62 -1
- package/lib/index.js +12 -0
- package/lib/install.js +26 -5
- package/lib/services.js +621 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,13 +4,20 @@
|
|
|
4
4
|
> Local-first. Hits **#5 on LoCoMo** and **96.2% R@5 on LongMemEval**.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npx -y total-agent-memory
|
|
7
|
+
npx -y total-agent-memory connect claude-code
|
|
8
|
+
# → server installed in ~/.tam, IDE wired, dashboard live at
|
|
9
|
+
# http://127.0.0.1:37737
|
|
8
10
|
```
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
One command bootstraps a Python virtual env in `~/.tam/`, installs the
|
|
13
|
+
MCP server **and** registers two background services with your OS:
|
|
14
|
+
|
|
15
|
+
- **Dashboard** — web UI on `http://127.0.0.1:37737` (LaunchAgent on macOS,
|
|
16
|
+
systemd-user unit on Linux, Scheduled Task on Windows). Survives reboot.
|
|
17
|
+
- **Reflection** — file-watch drain of the reflection queues. Fires
|
|
18
|
+
whenever the server touches `~/.tam/.reflect-pending` (debounce 10s).
|
|
19
|
+
|
|
20
|
+
Pass `--no-services` to skip the auto-service install.
|
|
14
21
|
|
|
15
22
|
## Quick start
|
|
16
23
|
|
|
@@ -32,22 +39,24 @@ Alias: `tam` (same binary).
|
|
|
32
39
|
|
|
33
40
|
| Command | What it does |
|
|
34
41
|
|---|---|
|
|
35
|
-
| `install` | Create venv, install Python server. Idempotent. |
|
|
42
|
+
| `install` | Create venv, install Python server, register dashboard + reflection services. Idempotent. |
|
|
36
43
|
| `start` | Run the MCP server (stdin/stdout). |
|
|
37
44
|
| `connect <ide>` | Wire an IDE config. `claude-code` `codex` `cursor` `cline` `continue` `windsurf` `gemini-cli` `opencode` `aider` |
|
|
45
|
+
| `dashboard <sub>` | Manage background services: `install`, `uninstall`, `start`, `stop`, `restart`, `status`, `logs`, `open`. |
|
|
38
46
|
| `demo` | Health-check end-to-end. |
|
|
39
|
-
| `status` | Print install paths, DB size
|
|
47
|
+
| `status` | Print install paths, DB size, **service status, dashboard HTTP probe**. |
|
|
40
48
|
| `doctor` | Diagnose Python/uv/git on PATH. |
|
|
41
49
|
| `upgrade` | Pull the latest server version. |
|
|
42
|
-
| `uninstall` | Remove `~/.tam` (needs `--yes`).
|
|
50
|
+
| `uninstall` | Remove `~/.tam` and unregister all services (needs `--yes`). |
|
|
43
51
|
|
|
44
52
|
## Options
|
|
45
53
|
|
|
46
54
|
| Flag | Meaning |
|
|
47
55
|
|---|---|
|
|
48
|
-
| `--server-version
|
|
56
|
+
| `--server-version 12.3.0` | Pin a specific server release. |
|
|
49
57
|
| `--source pypi\|git` | Force install source. Default: PyPI with GitHub fallback. |
|
|
50
58
|
| `--memory-dir /path` | Override install dir (default `~/.tam`). |
|
|
59
|
+
| `--no-services` | Skip dashboard / reflection auto-install. |
|
|
51
60
|
| `--yes` | Skip prompts (needed for `uninstall`). |
|
|
52
61
|
| `--no-color` | Disable ANSI. |
|
|
53
62
|
| `--verbose` | Print every shell command. |
|
|
@@ -63,11 +72,32 @@ Alias: `tam` (same binary).
|
|
|
63
72
|
```
|
|
64
73
|
~/.tam/
|
|
65
74
|
├── .venv/ # isolated Python env
|
|
66
|
-
│ └── bin/total-agent-memory # MCP server entry-point
|
|
67
|
-
|
|
75
|
+
│ └── bin/total-agent-memory # MCP server entry-point
|
|
76
|
+
├── logs/
|
|
77
|
+
│ ├── dashboard.log
|
|
78
|
+
│ └── reflection.log
|
|
79
|
+
├── memory.db # SQLite — your memory lives here
|
|
80
|
+
└── .reflect-pending # touched on every save → triggers reflection
|
|
68
81
|
```
|
|
69
82
|
|
|
70
|
-
|
|
83
|
+
Background services registered with your OS:
|
|
84
|
+
|
|
85
|
+
| OS | Location |
|
|
86
|
+
|---------|--------------------------------------------------------------------------|
|
|
87
|
+
| macOS | `~/Library/LaunchAgents/com.total-agent-memory.{dashboard,reflection}.plist` |
|
|
88
|
+
| Linux | `~/.config/systemd/user/tam-{dashboard,reflection}.{service,path}` |
|
|
89
|
+
| Windows | Task Scheduler — `TotalAgentMemory-dashboard` (reflection: manual) |
|
|
90
|
+
|
|
91
|
+
Nothing system-wide. Uninstall with `tam uninstall --yes` (removes services first).
|
|
92
|
+
|
|
93
|
+
### Verify
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
tam dashboard status
|
|
97
|
+
# ● dashboard running
|
|
98
|
+
# ◉ reflection watching
|
|
99
|
+
# ✓ HTTP http://127.0.0.1:37737 → 200
|
|
100
|
+
```
|
|
71
101
|
|
|
72
102
|
## Links
|
|
73
103
|
|
package/bin/cli.js
CHANGED
|
@@ -16,16 +16,19 @@ ${bold('Usage:')} total-agent-memory <command> [options]
|
|
|
16
16
|
${dim('alias:')} tam <command> [options]
|
|
17
17
|
|
|
18
18
|
${bold('Commands:')}
|
|
19
|
-
install Install Python server
|
|
19
|
+
install Install Python server + autostart dashboard/reflection
|
|
20
20
|
start Start the MCP server (default if no command)
|
|
21
21
|
connect <ide> Wire memory into an IDE config
|
|
22
22
|
${dim('claude-code | codex | cursor | cline | continue')}
|
|
23
23
|
${dim('aider | windsurf | gemini-cli | opencode')}
|
|
24
|
+
dashboard <sub> Manage background services
|
|
25
|
+
${dim('install | uninstall | start | stop | restart')}
|
|
26
|
+
${dim('status | logs | open')}
|
|
24
27
|
demo Seed demo session + run a recall to prove it works
|
|
25
|
-
status Health check + tool count + DB stats
|
|
28
|
+
status Health check + tool count + DB stats + service status
|
|
26
29
|
doctor Diagnose environment (python, uv, ports, configs)
|
|
27
30
|
upgrade Upgrade to the latest server version
|
|
28
|
-
uninstall Remove ~/.tam and IDE wirings
|
|
31
|
+
uninstall Remove ~/.tam, services, and IDE wirings
|
|
29
32
|
version Print version
|
|
30
33
|
help Show this message
|
|
31
34
|
|
|
@@ -34,20 +37,25 @@ ${bold('Options:')}
|
|
|
34
37
|
--server-version <v> Pin server version (default: latest)
|
|
35
38
|
--source <pypi|git> Where to install from (default: auto)
|
|
36
39
|
--memory-dir <path> Override install dir (default: ~/.tam)
|
|
40
|
+
--no-services Skip dashboard/reflection autostart during install
|
|
37
41
|
--yes, -y Skip prompts
|
|
38
42
|
--no-color Disable ANSI colors
|
|
39
43
|
--verbose Print every shell command
|
|
40
44
|
|
|
41
45
|
${bold('Examples:')}
|
|
42
|
-
${c.dim('#
|
|
43
|
-
|
|
46
|
+
${c.dim('# One-shot: install everything (server + dashboard + reflection)')}
|
|
47
|
+
${c.dim('# and wire Claude Code:')}
|
|
44
48
|
npx -y total-agent-memory connect claude-code
|
|
49
|
+
${c.dim('# → open http://127.0.0.1:37737 for the dashboard')}
|
|
45
50
|
|
|
46
|
-
${c.dim('#
|
|
47
|
-
npx
|
|
51
|
+
${c.dim('# Check that all services are running:')}
|
|
52
|
+
npx total-agent-memory dashboard status
|
|
53
|
+
|
|
54
|
+
${c.dim('# Install server only, skip auto-services:')}
|
|
55
|
+
npx -y total-agent-memory install --no-services
|
|
48
56
|
|
|
49
57
|
${c.dim('# Pin a specific server version:')}
|
|
50
|
-
npx -y total-agent-memory install --server-version
|
|
58
|
+
npx -y total-agent-memory install --server-version 12.3.0
|
|
51
59
|
|
|
52
60
|
${bold('Docs:')} https://totalmemory.dev
|
|
53
61
|
${bold('Repo:')} https://github.com/vbcherepanov/total-agent-memory
|
package/lib/commands.js
CHANGED
|
@@ -8,6 +8,14 @@ const { connect, SUPPORTED } = require('./ide');
|
|
|
8
8
|
const { diagnose, printReport, printRemediation } = require('./doctor');
|
|
9
9
|
const { memoryDir, venvDir, exists, home } = require('./paths');
|
|
10
10
|
const { run, runOrThrow } = require('./sh');
|
|
11
|
+
const {
|
|
12
|
+
installServices,
|
|
13
|
+
uninstallServices,
|
|
14
|
+
serviceStatus,
|
|
15
|
+
controlService,
|
|
16
|
+
logsPath,
|
|
17
|
+
probeDashboardHealth,
|
|
18
|
+
} = require('./services');
|
|
11
19
|
const { c, bold, ok, fail, warn, info, banner, step } = require('./style');
|
|
12
20
|
|
|
13
21
|
async function ensureInstalled(ctx) {
|
|
@@ -97,6 +105,20 @@ async function cmdStatus(ctx) {
|
|
|
97
105
|
} else {
|
|
98
106
|
process.stdout.write(` ${warn(`memory.db : ${db} (will be created on first save)`)}\n`);
|
|
99
107
|
}
|
|
108
|
+
// Background services + dashboard health
|
|
109
|
+
process.stdout.write(`\n${bold('Services')}\n`);
|
|
110
|
+
const rows = await serviceStatus(ctx.flags);
|
|
111
|
+
for (const row of rows) {
|
|
112
|
+
const mode = row.mode || (row.running ? 'running' : row.installed ? 'idle' : 'absent');
|
|
113
|
+
const mark = mode === 'running' ? c.green('●')
|
|
114
|
+
: mode === 'watching' ? c.cyan('◉')
|
|
115
|
+
: mode === 'idle' ? c.yellow('○')
|
|
116
|
+
: c.red('✗');
|
|
117
|
+
process.stdout.write(` ${mark} ${row.name.padEnd(12)} ${c.dim(mode)}\n`);
|
|
118
|
+
}
|
|
119
|
+
const dashUrl = `http://127.0.0.1:${process.env.DASHBOARD_PORT || '37737'}`;
|
|
120
|
+
const healthy = await probeDashboardHealth(ctx.flags);
|
|
121
|
+
process.stdout.write(` ${healthy ? ok(`HTTP ${dashUrl} → 200`) : warn(`HTTP ${dashUrl} → no response`)}\n`);
|
|
100
122
|
return 0;
|
|
101
123
|
}
|
|
102
124
|
|
|
@@ -128,12 +150,91 @@ async function cmdUninstall(ctx) {
|
|
|
128
150
|
process.stdout.write(`${info('Re-run with --yes to confirm.')}\n`);
|
|
129
151
|
return 0;
|
|
130
152
|
}
|
|
153
|
+
// Remove background services first — otherwise LaunchAgents/units keep
|
|
154
|
+
// respawning against a freshly-deleted venv and litter logs with 5xx.
|
|
155
|
+
try { await uninstallServices(ctx.flags); } catch (_) {}
|
|
131
156
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
132
157
|
process.stdout.write(`${ok('Removed.')}\n`);
|
|
133
158
|
process.stdout.write(`${info('IDE configs were left intact. Edit ~/.claude.json / ~/.cursor/mcp.json / etc. manually to drop the MCP entry.')}\n`);
|
|
134
159
|
return 0;
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
163
|
+
// dashboard — manage the background dashboard + reflection services
|
|
164
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async function cmdDashboard(ctx) {
|
|
167
|
+
const sub = (ctx.positional[0] || 'status').toLowerCase();
|
|
168
|
+
const dashUrl = `http://127.0.0.1:${process.env.DASHBOARD_PORT || '37737'}`;
|
|
169
|
+
process.stdout.write(banner());
|
|
170
|
+
|
|
171
|
+
if (sub === 'install') {
|
|
172
|
+
await ensureInstalled(ctx);
|
|
173
|
+
const okFlag = await installServices(ctx.flags);
|
|
174
|
+
if (okFlag) process.stdout.write(`\n${ok(`Open ${c.cyan(dashUrl)} to view the dashboard.`)}\n`);
|
|
175
|
+
return okFlag ? 0 : 1;
|
|
176
|
+
}
|
|
177
|
+
if (sub === 'uninstall' || sub === 'remove') {
|
|
178
|
+
await uninstallServices(ctx.flags);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (sub === 'start' || sub === 'stop' || sub === 'restart') {
|
|
182
|
+
const target = ctx.positional[1] || 'dashboard';
|
|
183
|
+
process.stdout.write(`${info(`${sub} ${target}…`)}\n`);
|
|
184
|
+
try {
|
|
185
|
+
await controlService(sub, target, ctx.flags);
|
|
186
|
+
process.stdout.write(`${ok('done')}\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
process.stderr.write(`${fail(e.message)}\n`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (sub === 'logs') {
|
|
194
|
+
const target = ctx.positional[1] || 'dashboard';
|
|
195
|
+
const stream = ctx.flags.err ? 'stderr' : 'stdout';
|
|
196
|
+
const lp = logsPath(target, ctx.flags, stream);
|
|
197
|
+
if (!lp || !exists(lp)) {
|
|
198
|
+
process.stdout.write(`${warn(`no log file yet: ${lp || target}`)}\n`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
const data = fs.readFileSync(lp, 'utf8');
|
|
202
|
+
const tail = data.split('\n').slice(-50).join('\n');
|
|
203
|
+
process.stdout.write(`${c.dim(`--- ${lp} (last 50 lines) ---`)}\n${tail}\n`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
if (sub === 'open') {
|
|
207
|
+
const opener = process.platform === 'darwin' ? 'open'
|
|
208
|
+
: process.platform === 'win32' ? 'start'
|
|
209
|
+
: 'xdg-open';
|
|
210
|
+
await run(opener, [dashUrl], { silent: true });
|
|
211
|
+
process.stdout.write(`${ok(`Opened ${c.cyan(dashUrl)}`)}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
if (sub === 'status' || sub === '') {
|
|
215
|
+
const rows = await serviceStatus(ctx.flags);
|
|
216
|
+
process.stdout.write(`${bold('Services')}\n`);
|
|
217
|
+
for (const row of rows) {
|
|
218
|
+
const mode = row.mode || (row.running ? 'running' : row.installed ? 'idle' : 'absent');
|
|
219
|
+
const mark = mode === 'running' ? c.green('●')
|
|
220
|
+
: mode === 'watching' ? c.cyan('◉')
|
|
221
|
+
: mode === 'scheduled' ? c.magenta('◷')
|
|
222
|
+
: mode === 'idle' ? c.yellow('○')
|
|
223
|
+
: c.red('✗');
|
|
224
|
+
process.stdout.write(` ${mark} ${row.name.padEnd(12)} ${c.dim(mode)}${row.note ? c.dim(' — ' + row.note) : ''}\n`);
|
|
225
|
+
}
|
|
226
|
+
const healthy = await probeDashboardHealth(ctx.flags);
|
|
227
|
+
process.stdout.write(`\n ${healthy ? ok(`HTTP ${dashUrl} → 200`) : warn(`HTTP ${dashUrl} → no response`)}\n`);
|
|
228
|
+
if (!healthy && !rows.find((r) => r.name === 'dashboard' && r.installed)) {
|
|
229
|
+
process.stdout.write(`\n ${info(`Run: ${c.cyan('npx total-agent-memory dashboard install')}`)}\n`);
|
|
230
|
+
}
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
process.stderr.write(`${fail(`unknown dashboard subcommand: ${sub}`)}\n`);
|
|
234
|
+
process.stderr.write(`Try: install | uninstall | start | stop | restart | status | logs | open\n`);
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
|
|
137
238
|
// First-run onboarding: install + suggest a connect step.
|
|
138
239
|
async function cmdOnboard(ctx) {
|
|
139
240
|
process.stdout.write(banner());
|
|
@@ -165,4 +266,5 @@ module.exports = {
|
|
|
165
266
|
upgrade: cmdUpgrade,
|
|
166
267
|
uninstall: cmdUninstall,
|
|
167
268
|
onboard: cmdOnboard,
|
|
269
|
+
dashboard: cmdDashboard,
|
|
168
270
|
};
|
package/lib/doctor.js
CHANGED
|
@@ -16,9 +16,10 @@ async function diagnose() {
|
|
|
16
16
|
pip: null,
|
|
17
17
|
pipx: null,
|
|
18
18
|
git: null,
|
|
19
|
+
ollama: null,
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
for (const bin of ['python3', 'python', 'uv', 'pipx', 'git']) {
|
|
22
|
+
for (const bin of ['python3', 'python', 'uv', 'pipx', 'git', 'ollama']) {
|
|
22
23
|
const r = probe(bin, ['--version']);
|
|
23
24
|
if (r.ok) {
|
|
24
25
|
env[bin] = { path: r.path, version: r.out };
|
|
@@ -32,9 +33,42 @@ async function diagnose() {
|
|
|
32
33
|
env.pythonPatch = +m[3];
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
// Probe Ollama HTTP — works whether installed via brew, the .app, or
|
|
37
|
+
// running in Docker. We hit /api/tags (cheap, always-available).
|
|
38
|
+
env.ollamaHttp = await probeOllamaHttp();
|
|
35
39
|
return env;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
async function probeOllamaHttp(timeoutMs = 1500) {
|
|
43
|
+
const host = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
|
|
44
|
+
const url = host.endsWith('/') ? host + 'api/tags' : host + '/api/tags';
|
|
45
|
+
return await new Promise((resolve) => {
|
|
46
|
+
try {
|
|
47
|
+
const http = url.startsWith('https') ? require('https') : require('http');
|
|
48
|
+
const req = http.get(url, (res) => {
|
|
49
|
+
let body = '';
|
|
50
|
+
res.on('data', (d) => { body += d.toString(); });
|
|
51
|
+
res.on('end', () => {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
resolve({ ok: false, status: res.statusCode, host });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let models = [];
|
|
57
|
+
try {
|
|
58
|
+
const j = JSON.parse(body);
|
|
59
|
+
models = (j.models || []).map((m) => m.name);
|
|
60
|
+
} catch (_) {}
|
|
61
|
+
resolve({ ok: true, host, models, modelCount: models.length });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
req.on('error', () => resolve({ ok: false, host, error: 'unreachable' }));
|
|
65
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ ok: false, host, error: 'timeout' }); });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
resolve({ ok: false, host, error: String(e) });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
function pickInstaller(env) {
|
|
39
73
|
// Prefer uv (fastest, most reliable). Fall back to venv+pip.
|
|
40
74
|
if (env.uv) return 'uv';
|
|
@@ -61,6 +95,18 @@ function printReport(env) {
|
|
|
61
95
|
} else {
|
|
62
96
|
process.stdout.write(` ${warn('git : not found (needed only for --source git fallback)')}\n`);
|
|
63
97
|
}
|
|
98
|
+
// Ollama — optional but unlocks LLM-powered enrichment (deep entity
|
|
99
|
+
// extraction, summarisation, contradiction detection). Without it the
|
|
100
|
+
// server still works in degraded mode: FTS5 + embeddings, no LLM stages.
|
|
101
|
+
if (env.ollamaHttp && env.ollamaHttp.ok) {
|
|
102
|
+
const models = env.ollamaHttp.modelCount;
|
|
103
|
+
process.stdout.write(` ${ok(`ollama : reachable at ${env.ollamaHttp.host} (${models} model${models === 1 ? '' : 's'})`)}\n`);
|
|
104
|
+
} else if (env.ollama) {
|
|
105
|
+
process.stdout.write(` ${warn(`ollama : binary present but daemon not reachable at ${env.ollamaHttp ? env.ollamaHttp.host : 'http://127.0.0.1:11434'}`)}\n`);
|
|
106
|
+
process.stdout.write(` ${c.dim('start with: ollama serve')}\n`);
|
|
107
|
+
} else {
|
|
108
|
+
process.stdout.write(` ${warn(`ollama : not found (optional — needed for LLM enrichment)`)}\n`);
|
|
109
|
+
}
|
|
64
110
|
return env;
|
|
65
111
|
}
|
|
66
112
|
|
|
@@ -80,6 +126,21 @@ function printRemediation(env) {
|
|
|
80
126
|
process.stdout.write(`\n${bold('Optional — install uv for 10× faster installs:')}\n`);
|
|
81
127
|
process.stdout.write(` ${c.cyan('curl -LsSf https://astral.sh/uv/install.sh | sh')}\n`);
|
|
82
128
|
}
|
|
129
|
+
// Suggest Ollama install only if it's missing entirely. If the binary
|
|
130
|
+
// is present but the daemon is down, the printReport message already
|
|
131
|
+
// tells the user to `ollama serve`.
|
|
132
|
+
if (!env.ollama && (!env.ollamaHttp || !env.ollamaHttp.ok)) {
|
|
133
|
+
process.stdout.write(`\n${bold('Optional — install Ollama for local LLM enrichment:')}\n`);
|
|
134
|
+
if (process.platform === 'darwin') {
|
|
135
|
+
process.stdout.write(` ${c.cyan('brew install ollama && brew services start ollama')}\n`);
|
|
136
|
+
} else if (process.platform === 'linux') {
|
|
137
|
+
process.stdout.write(` ${c.cyan('curl -fsSL https://ollama.com/install.sh | sh')}\n`);
|
|
138
|
+
} else if (process.platform === 'win32') {
|
|
139
|
+
process.stdout.write(` ${c.cyan('winget install Ollama.Ollama')}\n`);
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(` ${c.cyan('ollama pull qwen2.5-coder:7b && ollama pull nomic-embed-text')} ${c.dim('# default models')}\n`);
|
|
142
|
+
process.stdout.write(` ${c.dim('(server still works without Ollama — runs in FTS+embedding mode)')}\n`);
|
|
143
|
+
}
|
|
83
144
|
}
|
|
84
145
|
|
|
85
146
|
module.exports = { diagnose, pickInstaller, printReport, printRemediation };
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,13 @@ const { diagnose } = require('./doctor');
|
|
|
8
8
|
const { mcpEntry, SUPPORTED } = require('./ide');
|
|
9
9
|
const { memoryDir, venvDir, venvBin } = require('./paths');
|
|
10
10
|
const { install, pinned } = require('./install');
|
|
11
|
+
const {
|
|
12
|
+
installServices,
|
|
13
|
+
uninstallServices,
|
|
14
|
+
serviceStatus,
|
|
15
|
+
controlService,
|
|
16
|
+
probeDashboardHealth,
|
|
17
|
+
} = require('./services');
|
|
11
18
|
|
|
12
19
|
module.exports = {
|
|
13
20
|
...cmds,
|
|
@@ -19,4 +26,9 @@ module.exports = {
|
|
|
19
26
|
venvBin,
|
|
20
27
|
installServer: install,
|
|
21
28
|
pinnedVersion: pinned,
|
|
29
|
+
installServices,
|
|
30
|
+
uninstallServices,
|
|
31
|
+
serviceStatus,
|
|
32
|
+
controlService,
|
|
33
|
+
probeDashboardHealth,
|
|
22
34
|
};
|
package/lib/install.js
CHANGED
|
@@ -5,12 +5,13 @@ const path = require('path');
|
|
|
5
5
|
const { runOrThrow, run, which } = require('./sh');
|
|
6
6
|
const { memoryDir, venvDir, venvBin, ensureDir, exists } = require('./paths');
|
|
7
7
|
const { diagnose, pickInstaller, printReport, printRemediation } = require('./doctor');
|
|
8
|
+
const { installServices } = require('./services');
|
|
8
9
|
const { c, bold, ok, fail, warn, info, step } = require('./style');
|
|
9
10
|
|
|
10
11
|
const PYPI_PACKAGE = 'total-agent-memory';
|
|
11
12
|
// Pinned to the latest stable release at the time of the wrapper release.
|
|
12
13
|
// Override via `--server-version` or env TAM_SERVER_VERSION.
|
|
13
|
-
const DEFAULT_SERVER_VERSION = '12.
|
|
14
|
+
const DEFAULT_SERVER_VERSION = '12.4.0';
|
|
14
15
|
const GIT_URL = 'https://github.com/vbcherepanov/total-agent-memory.git';
|
|
15
16
|
|
|
16
17
|
function pinned(flags) {
|
|
@@ -110,15 +111,16 @@ async function install(ctx) {
|
|
|
110
111
|
return 1;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
const totalSteps = flags['no-services'] ? 3 : 4;
|
|
113
115
|
process.stdout.write(`\n${bold('Installing server…')}\n`);
|
|
114
|
-
process.stdout.write(` ${step(1,
|
|
116
|
+
process.stdout.write(` ${step(1, totalSteps, `venv → ${c.dim(venvDir(flags))}`)}\n`);
|
|
115
117
|
await createVenv(flags, env);
|
|
116
118
|
process.stdout.write(` ${ok('venv ready')}\n`);
|
|
117
119
|
|
|
118
|
-
process.stdout.write(` ${step(2,
|
|
120
|
+
process.stdout.write(` ${step(2, totalSteps, 'resolving source (PyPI → GitHub fallback)…')}\n`);
|
|
119
121
|
const source = await pickSource(flags);
|
|
120
122
|
|
|
121
|
-
process.stdout.write(` ${step(3,
|
|
123
|
+
process.stdout.write(` ${step(3, totalSteps, 'pip install…')}\n`);
|
|
122
124
|
await pipInstall(flags, env, source);
|
|
123
125
|
|
|
124
126
|
// Verify entry-point present.
|
|
@@ -127,8 +129,27 @@ async function install(ctx) {
|
|
|
127
129
|
process.stderr.write(`\n${fail(`entry-point ${bin} not found after install`)}\n`);
|
|
128
130
|
return 1;
|
|
129
131
|
}
|
|
132
|
+
|
|
133
|
+
// Step 4 — register background services (dashboard + reflection) unless
|
|
134
|
+
// explicitly opted out. Failure here is non-fatal: the server itself is
|
|
135
|
+
// installed, the user can re-run `npx total-agent-memory dashboard install`.
|
|
136
|
+
if (!flags['no-services']) {
|
|
137
|
+
process.stdout.write(` ${step(4, totalSteps, 'background services (dashboard + reflection)…')}\n`);
|
|
138
|
+
try {
|
|
139
|
+
await installServices(flags, { dryRun: process.env.TAM_INSTALL_DRY === '1' });
|
|
140
|
+
} catch (e) {
|
|
141
|
+
process.stdout.write(` ${warn(`services install failed: ${e.message}`)}\n`);
|
|
142
|
+
process.stdout.write(` ${info(`Retry later with: ${c.cyan('npx total-agent-memory dashboard install')}`)}\n`);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
process.stdout.write(` ${info('--no-services set — skipping dashboard/reflection autostart')}\n`);
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
process.stdout.write(`\n${ok(c.bold('Installation complete.'))}\n`);
|
|
131
|
-
process.stdout.write(`${info(`Binary:
|
|
149
|
+
process.stdout.write(`${info(`Binary: ${c.dim(bin)}`)}\n`);
|
|
150
|
+
if (!flags['no-services']) {
|
|
151
|
+
process.stdout.write(`${info(`Dashboard: ${c.cyan('http://127.0.0.1:37737')}`)}\n`);
|
|
152
|
+
}
|
|
132
153
|
return 0;
|
|
133
154
|
}
|
|
134
155
|
|
package/lib/services.js
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Manage background services (dashboard + reflection) per OS.
|
|
4
|
+
//
|
|
5
|
+
// macOS → ~/Library/LaunchAgents/com.total-agent-memory.<name>.plist
|
|
6
|
+
// Linux → ~/.config/systemd/user/tam-<name>.service (+ .path for reflection)
|
|
7
|
+
// Windows → schtasks /Create (best-effort) or printed instructions
|
|
8
|
+
//
|
|
9
|
+
// All services point at the existing entry points inside the installed
|
|
10
|
+
// venv (no new server code needed):
|
|
11
|
+
// dashboard → python -m src.dashboard
|
|
12
|
+
// reflection → python -m src.tools.run_reflection
|
|
13
|
+
//
|
|
14
|
+
// Why not bundle a supervisor? Each OS has a battle-tested supervisor
|
|
15
|
+
// already (launchd / systemd). Re-implementing one inside the venv would
|
|
16
|
+
// be a downgrade on restart-on-crash, log rotation, and boot-order.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const { run, runOrThrow, which, probe } = require('./sh');
|
|
22
|
+
const { memoryDir, venvBin, ensureDir, exists, home } = require('./paths');
|
|
23
|
+
const { c, ok, fail, warn, info } = require('./style');
|
|
24
|
+
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Service definitions (OS-independent shape)
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function serviceDefs(flags) {
|
|
30
|
+
const md = memoryDir(flags);
|
|
31
|
+
const logDir = path.join(md, 'logs');
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: 'dashboard',
|
|
35
|
+
title: 'Web dashboard',
|
|
36
|
+
module: 'src.dashboard',
|
|
37
|
+
env: {
|
|
38
|
+
DASHBOARD_PORT: process.env.DASHBOARD_PORT || '37737',
|
|
39
|
+
DASHBOARD_BIND: process.env.DASHBOARD_BIND || '127.0.0.1',
|
|
40
|
+
TAM_MEMORY_DIR: md,
|
|
41
|
+
},
|
|
42
|
+
port: parseInt(process.env.DASHBOARD_PORT || '37737', 10),
|
|
43
|
+
healthcheck: '/api/stats',
|
|
44
|
+
keepAlive: true, // long-running HTTP server
|
|
45
|
+
logFile: path.join(logDir, 'dashboard.log'),
|
|
46
|
+
errFile: path.join(logDir, 'dashboard.err'),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'reflection',
|
|
50
|
+
title: 'Reflection drain (file-watch)',
|
|
51
|
+
module: 'src.tools.run_reflection',
|
|
52
|
+
env: {
|
|
53
|
+
REFLECT_DEBOUNCE_SEC: process.env.REFLECT_DEBOUNCE_SEC || '10',
|
|
54
|
+
TAM_MEMORY_DIR: md,
|
|
55
|
+
},
|
|
56
|
+
// File-watch trigger: server touches this on every save.
|
|
57
|
+
watchPath: path.join(md, '.reflect-pending'),
|
|
58
|
+
keepAlive: false, // re-spawned by file-watch, exits when drain done
|
|
59
|
+
throttleSec: 5,
|
|
60
|
+
// Safety net periodic run every hour even if no trigger fires.
|
|
61
|
+
startIntervalSec: 3600,
|
|
62
|
+
logFile: path.join(logDir, 'reflection.log'),
|
|
63
|
+
errFile: path.join(logDir, 'reflection.err'),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
// Cron-style: backfill orphan graph edges 4× per day. Keeps the
|
|
67
|
+
// knowledge graph fresh as new saves accumulate.
|
|
68
|
+
name: 'orphan-backfill',
|
|
69
|
+
title: 'Orphan-edge backfill (4×/day)',
|
|
70
|
+
module: 'src.tools.backfill_orphan_edges',
|
|
71
|
+
moduleArgs: ['--min-mentions=1', '--limit=500', '--trigger-now'],
|
|
72
|
+
env: { TAM_MEMORY_DIR: md },
|
|
73
|
+
keepAlive: false,
|
|
74
|
+
// launchd StartCalendarInterval — fires at 00, 06, 12, 18 daily.
|
|
75
|
+
calendarHours: [0, 6, 12, 18],
|
|
76
|
+
// systemd timer OnCalendar equivalent.
|
|
77
|
+
systemdOnCalendar: '00,06,12,18:00:00',
|
|
78
|
+
logFile: path.join(logDir, 'orphan-backfill.log'),
|
|
79
|
+
errFile: path.join(logDir, 'orphan-backfill.err'),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// Weekly check for new releases — non-critical, informational.
|
|
83
|
+
name: 'check-updates',
|
|
84
|
+
title: 'Check for new releases (weekly)',
|
|
85
|
+
module: 'src.tools.check_updates',
|
|
86
|
+
env: { TAM_MEMORY_DIR: md },
|
|
87
|
+
keepAlive: false,
|
|
88
|
+
// launchd StartCalendarInterval — Sunday 03:00.
|
|
89
|
+
calendarWeekly: { Weekday: 0, Hour: 3, Minute: 0 },
|
|
90
|
+
// systemd timer — Sun *-*-* 03:00:00.
|
|
91
|
+
systemdOnCalendar: 'Sun *-*-* 03:00:00',
|
|
92
|
+
logFile: path.join(logDir, 'check-updates.log'),
|
|
93
|
+
errFile: path.join(logDir, 'check-updates.err'),
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
99
|
+
// macOS — LaunchAgent
|
|
100
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function plistLabel(name) {
|
|
103
|
+
return `com.total-agent-memory.${name}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function plistPath(name) {
|
|
107
|
+
return path.join(home(), 'Library', 'LaunchAgents', `${plistLabel(name)}.plist`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderPlist(svc, pyPath) {
|
|
111
|
+
const envEntries = Object.entries(svc.env)
|
|
112
|
+
.map(([k, v]) => ` <key>${escapeXml(k)}</key><string>${escapeXml(String(v))}</string>`)
|
|
113
|
+
.join('\n');
|
|
114
|
+
const cmdArgs = ['-m', svc.module, ...(svc.moduleArgs || [])];
|
|
115
|
+
const args = cmdArgs
|
|
116
|
+
.map((a) => ` <string>${escapeXml(a)}</string>`)
|
|
117
|
+
.join('\n');
|
|
118
|
+
const watchBlock = svc.watchPath
|
|
119
|
+
? ` <key>WatchPaths</key>\n <array>\n <string>${escapeXml(svc.watchPath)}</string>\n </array>\n`
|
|
120
|
+
: '';
|
|
121
|
+
const throttleBlock = svc.throttleSec
|
|
122
|
+
? ` <key>ThrottleInterval</key><integer>${svc.throttleSec}</integer>\n`
|
|
123
|
+
: '';
|
|
124
|
+
const intervalBlock = svc.startIntervalSec
|
|
125
|
+
? ` <key>StartInterval</key><integer>${svc.startIntervalSec}</integer>\n`
|
|
126
|
+
: '';
|
|
127
|
+
// StartCalendarInterval — for cron-style daily/weekly jobs.
|
|
128
|
+
let calendarBlock = '';
|
|
129
|
+
if (Array.isArray(svc.calendarHours) && svc.calendarHours.length) {
|
|
130
|
+
const dicts = svc.calendarHours
|
|
131
|
+
.map((h) => ` <dict><key>Hour</key><integer>${h}</integer><key>Minute</key><integer>0</integer></dict>`)
|
|
132
|
+
.join('\n');
|
|
133
|
+
calendarBlock = ` <key>StartCalendarInterval</key>\n <array>\n${dicts}\n </array>\n`;
|
|
134
|
+
} else if (svc.calendarWeekly) {
|
|
135
|
+
const w = svc.calendarWeekly;
|
|
136
|
+
const keys = ['Weekday', 'Hour', 'Minute']
|
|
137
|
+
.filter((k) => k in w)
|
|
138
|
+
.map((k) => ` <key>${k}</key><integer>${w[k]}</integer>`)
|
|
139
|
+
.join('\n');
|
|
140
|
+
calendarBlock = ` <key>StartCalendarInterval</key>\n <dict>\n${keys}\n </dict>\n`;
|
|
141
|
+
}
|
|
142
|
+
// keepAlive=true → HTTP server (always running). false → triggered by
|
|
143
|
+
// file-watch or calendar.
|
|
144
|
+
const runAtLoad = svc.keepAlive ? '<true/>' : '<false/>';
|
|
145
|
+
const keepAlive = svc.keepAlive ? '<true/>' : '<false/>';
|
|
146
|
+
|
|
147
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
148
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
149
|
+
<plist version="1.0">
|
|
150
|
+
<dict>
|
|
151
|
+
<key>Label</key><string>${plistLabel(svc.name)}</string>
|
|
152
|
+
<key>ProgramArguments</key>
|
|
153
|
+
<array>
|
|
154
|
+
<string>${escapeXml(pyPath)}</string>
|
|
155
|
+
${args}
|
|
156
|
+
</array>
|
|
157
|
+
<key>EnvironmentVariables</key>
|
|
158
|
+
<dict>
|
|
159
|
+
${envEntries}
|
|
160
|
+
</dict>
|
|
161
|
+
<key>RunAtLoad</key>${runAtLoad}
|
|
162
|
+
<key>KeepAlive</key>${keepAlive}
|
|
163
|
+
${watchBlock}${throttleBlock}${intervalBlock}${calendarBlock} <key>StandardOutPath</key><string>${escapeXml(svc.logFile)}</string>
|
|
164
|
+
<key>StandardErrorPath</key><string>${escapeXml(svc.errFile)}</string>
|
|
165
|
+
</dict>
|
|
166
|
+
</plist>
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeXml(s) {
|
|
171
|
+
return String(s)
|
|
172
|
+
.replace(/&/g, '&')
|
|
173
|
+
.replace(/</g, '<')
|
|
174
|
+
.replace(/>/g, '>')
|
|
175
|
+
.replace(/"/g, '"');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function macInstall(svcs, pyPath, opts) {
|
|
179
|
+
ensureDir(path.dirname(plistPath('x')));
|
|
180
|
+
for (const svc of svcs) {
|
|
181
|
+
ensureDir(path.dirname(svc.logFile));
|
|
182
|
+
if (svc.watchPath) ensureDir(path.dirname(svc.watchPath));
|
|
183
|
+
const p = plistPath(svc.name);
|
|
184
|
+
fs.writeFileSync(p, renderPlist(svc, pyPath));
|
|
185
|
+
if (opts.dryRun) continue;
|
|
186
|
+
const label = plistLabel(svc.name);
|
|
187
|
+
const target = `gui/${process.getuid()}/${label}`;
|
|
188
|
+
// Bootout previous version (if any). launchd is async — give it a
|
|
189
|
+
// beat before bootstrapping the replacement, otherwise we hit
|
|
190
|
+
// "Input/output error" on rapid replace.
|
|
191
|
+
await run('launchctl', ['bootout', target], { silent: true });
|
|
192
|
+
await _sleep(150);
|
|
193
|
+
const r = await run('launchctl', ['bootstrap', `gui/${process.getuid()}`, p], { silent: true });
|
|
194
|
+
if (r.code !== 0) {
|
|
195
|
+
// Retry once after a longer pause (launchd was likely still
|
|
196
|
+
// tearing down the old job).
|
|
197
|
+
await _sleep(500);
|
|
198
|
+
const r2 = await run('launchctl', ['bootstrap', `gui/${process.getuid()}`, p], { silent: true });
|
|
199
|
+
if (r2.code !== 0) {
|
|
200
|
+
// Fall back to legacy load (older macOS / sandbox issues).
|
|
201
|
+
await run('launchctl', ['load', '-w', p], { silent: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (svc.keepAlive) {
|
|
205
|
+
// Kickstart so the long-running service runs *now*.
|
|
206
|
+
await run('launchctl', ['kickstart', '-k', target], { silent: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _sleep(ms) {
|
|
212
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function macUninstall(svcs, opts) {
|
|
216
|
+
for (const svc of svcs) {
|
|
217
|
+
const p = plistPath(svc.name);
|
|
218
|
+
if (!opts.dryRun) {
|
|
219
|
+
await run('launchctl', ['bootout', `gui/${process.getuid()}/${plistLabel(svc.name)}`], { silent: true });
|
|
220
|
+
await run('launchctl', ['unload', '-w', p], { silent: true });
|
|
221
|
+
}
|
|
222
|
+
if (exists(p)) fs.unlinkSync(p);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function macStatus(svcs) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const svc of svcs) {
|
|
229
|
+
const r = await run('launchctl', ['print', `gui/${process.getuid()}/${plistLabel(svc.name)}`], { silent: true });
|
|
230
|
+
const installed = exists(plistPath(svc.name));
|
|
231
|
+
const loaded = r.code === 0;
|
|
232
|
+
const running = loaded && /state\s*=\s*running/i.test(r.stdout);
|
|
233
|
+
// Cron-style services with calendar/interval triggers are healthy
|
|
234
|
+
// when loaded — they just sit until their next fire. File-watch
|
|
235
|
+
// services likewise: "watching" means ready, not stopped.
|
|
236
|
+
let mode;
|
|
237
|
+
if (running) mode = 'running';
|
|
238
|
+
else if (loaded && (svc.calendarHours || svc.calendarWeekly)) mode = 'scheduled';
|
|
239
|
+
else if (loaded && svc.watchPath) mode = 'watching';
|
|
240
|
+
else if (installed) mode = 'idle';
|
|
241
|
+
else mode = 'absent';
|
|
242
|
+
out.push({ name: svc.name, installed, running, mode });
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function macControl(action, name) {
|
|
248
|
+
const target = `gui/${process.getuid()}/${plistLabel(name)}`;
|
|
249
|
+
if (action === 'start') {
|
|
250
|
+
return await run('launchctl', ['kickstart', '-k', target], { silent: true });
|
|
251
|
+
}
|
|
252
|
+
if (action === 'stop') {
|
|
253
|
+
return await run('launchctl', ['kill', 'SIGTERM', target], { silent: true });
|
|
254
|
+
}
|
|
255
|
+
if (action === 'restart') {
|
|
256
|
+
return await run('launchctl', ['kickstart', '-k', target], { silent: true });
|
|
257
|
+
}
|
|
258
|
+
throw new Error(`unknown action: ${action}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
262
|
+
// Linux — systemd --user
|
|
263
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
function systemdDir() {
|
|
266
|
+
return path.join(home(), '.config', 'systemd', 'user');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function systemdUnitName(name, type = 'service') {
|
|
270
|
+
return `tam-${name}.${type}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderSystemdService(svc, pyPath) {
|
|
274
|
+
const envLines = Object.entries(svc.env)
|
|
275
|
+
.map(([k, v]) => `Environment=${k}=${v}`)
|
|
276
|
+
.join('\n');
|
|
277
|
+
// `Type=exec` lets systemd consider the unit started as soon as the
|
|
278
|
+
// binary is exec'd (good for short jobs).
|
|
279
|
+
const type = svc.keepAlive ? 'simple' : 'exec';
|
|
280
|
+
const restart = svc.keepAlive ? 'always' : 'no';
|
|
281
|
+
const restartSec = svc.keepAlive ? 'RestartSec=5\n' : '';
|
|
282
|
+
const moduleArgs = (svc.moduleArgs || []).join(' ');
|
|
283
|
+
const execStart = `${pyPath} -m ${svc.module}${moduleArgs ? ' ' + moduleArgs : ''}`;
|
|
284
|
+
return `[Unit]
|
|
285
|
+
Description=Total Agent Memory — ${svc.title}
|
|
286
|
+
After=default.target
|
|
287
|
+
|
|
288
|
+
[Service]
|
|
289
|
+
Type=${type}
|
|
290
|
+
ExecStart=${execStart}
|
|
291
|
+
Restart=${restart}
|
|
292
|
+
${restartSec}${envLines}
|
|
293
|
+
StandardOutput=append:${svc.logFile}
|
|
294
|
+
StandardError=append:${svc.errFile}
|
|
295
|
+
|
|
296
|
+
[Install]
|
|
297
|
+
WantedBy=default.target
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderSystemdCalendarTimer(svc) {
|
|
302
|
+
if (!svc.systemdOnCalendar) return null;
|
|
303
|
+
return `[Unit]
|
|
304
|
+
Description=Total Agent Memory — schedule for ${svc.title}
|
|
305
|
+
|
|
306
|
+
[Timer]
|
|
307
|
+
OnCalendar=${svc.systemdOnCalendar}
|
|
308
|
+
Persistent=true
|
|
309
|
+
Unit=${systemdUnitName(svc.name, 'service')}
|
|
310
|
+
|
|
311
|
+
[Install]
|
|
312
|
+
WantedBy=timers.target
|
|
313
|
+
`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderSystemdPath(svc) {
|
|
317
|
+
return `[Unit]
|
|
318
|
+
Description=Total Agent Memory — watch ${path.basename(svc.watchPath)}
|
|
319
|
+
|
|
320
|
+
[Path]
|
|
321
|
+
PathChanged=${svc.watchPath}
|
|
322
|
+
Unit=${systemdUnitName(svc.name, 'service')}
|
|
323
|
+
|
|
324
|
+
[Install]
|
|
325
|
+
WantedBy=default.target
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderSystemdTimer(svc) {
|
|
330
|
+
return `[Unit]
|
|
331
|
+
Description=Total Agent Memory — periodic safety run for ${svc.title}
|
|
332
|
+
|
|
333
|
+
[Timer]
|
|
334
|
+
OnUnitActiveSec=${svc.startIntervalSec}s
|
|
335
|
+
OnBootSec=60s
|
|
336
|
+
Unit=${systemdUnitName(svc.name, 'service')}
|
|
337
|
+
|
|
338
|
+
[Install]
|
|
339
|
+
WantedBy=timers.target
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function systemctlAvailable() {
|
|
344
|
+
if (!which('systemctl')) return false;
|
|
345
|
+
// `systemctl --user show-environment` returns 0 only when the user bus
|
|
346
|
+
// is reachable (i.e. lingering enabled, or interactive login session).
|
|
347
|
+
const r = probe('systemctl', ['--user', 'show-environment']);
|
|
348
|
+
return r.ok;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function linuxInstall(svcs, pyPath, opts) {
|
|
352
|
+
ensureDir(systemdDir());
|
|
353
|
+
for (const svc of svcs) {
|
|
354
|
+
ensureDir(path.dirname(svc.logFile));
|
|
355
|
+
if (svc.watchPath) ensureDir(path.dirname(svc.watchPath));
|
|
356
|
+
const svcFile = path.join(systemdDir(), systemdUnitName(svc.name, 'service'));
|
|
357
|
+
fs.writeFileSync(svcFile, renderSystemdService(svc, pyPath));
|
|
358
|
+
if (svc.watchPath) {
|
|
359
|
+
const pathFile = path.join(systemdDir(), systemdUnitName(svc.name, 'path'));
|
|
360
|
+
fs.writeFileSync(pathFile, renderSystemdPath(svc));
|
|
361
|
+
}
|
|
362
|
+
if (svc.startIntervalSec && !svc.keepAlive) {
|
|
363
|
+
const timerFile = path.join(systemdDir(), systemdUnitName(svc.name, 'timer'));
|
|
364
|
+
fs.writeFileSync(timerFile, renderSystemdTimer(svc));
|
|
365
|
+
}
|
|
366
|
+
if (svc.systemdOnCalendar) {
|
|
367
|
+
const timerFile = path.join(systemdDir(), systemdUnitName(svc.name, 'timer'));
|
|
368
|
+
fs.writeFileSync(timerFile, renderSystemdCalendarTimer(svc));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (opts.dryRun) return;
|
|
372
|
+
if (!systemctlAvailable()) {
|
|
373
|
+
process.stdout.write(` ${warn('systemctl --user bus not reachable — unit files written. Enable later with:')}\n`);
|
|
374
|
+
process.stdout.write(` ${c.cyan('systemctl --user daemon-reload')}\n`);
|
|
375
|
+
for (const svc of svcs) {
|
|
376
|
+
const unit = primaryUnitFor(svc);
|
|
377
|
+
process.stdout.write(` ${c.cyan(`systemctl --user enable --now ${unit}`)}\n`);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await run('systemctl', ['--user', 'daemon-reload'], { silent: true });
|
|
382
|
+
for (const svc of svcs) {
|
|
383
|
+
// For file-watch services we enable the `.path` unit which triggers
|
|
384
|
+
// the `.service` unit on change. For calendar/interval jobs we
|
|
385
|
+
// enable the `.timer`. For HTTP servers, enable the `.service`.
|
|
386
|
+
const unit = primaryUnitFor(svc);
|
|
387
|
+
await run('systemctl', ['--user', 'enable', '--now', unit], { silent: true });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function primaryUnitFor(svc) {
|
|
392
|
+
if (svc.watchPath) return systemdUnitName(svc.name, 'path');
|
|
393
|
+
if (svc.systemdOnCalendar || svc.startIntervalSec) return systemdUnitName(svc.name, 'timer');
|
|
394
|
+
return systemdUnitName(svc.name, 'service');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function linuxUninstall(svcs, opts) {
|
|
398
|
+
for (const svc of svcs) {
|
|
399
|
+
const units = [
|
|
400
|
+
systemdUnitName(svc.name, 'service'),
|
|
401
|
+
svc.watchPath ? systemdUnitName(svc.name, 'path') : null,
|
|
402
|
+
(svc.startIntervalSec && !svc.keepAlive) || svc.systemdOnCalendar
|
|
403
|
+
? systemdUnitName(svc.name, 'timer') : null,
|
|
404
|
+
].filter(Boolean);
|
|
405
|
+
if (!opts.dryRun && systemctlAvailable()) {
|
|
406
|
+
for (const unit of units) {
|
|
407
|
+
await run('systemctl', ['--user', 'disable', '--now', unit], { silent: true });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const unit of units) {
|
|
411
|
+
const f = path.join(systemdDir(), unit);
|
|
412
|
+
if (exists(f)) fs.unlinkSync(f);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!opts.dryRun && systemctlAvailable()) {
|
|
416
|
+
await run('systemctl', ['--user', 'daemon-reload'], { silent: true });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function linuxStatus(svcs) {
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const svc of svcs) {
|
|
423
|
+
const unit = systemdUnitName(svc.name, 'service');
|
|
424
|
+
const pathUnit = svc.watchPath ? systemdUnitName(svc.name, 'path') : null;
|
|
425
|
+
const timerUnit = (svc.systemdOnCalendar || svc.startIntervalSec) ? systemdUnitName(svc.name, 'timer') : null;
|
|
426
|
+
const installed = exists(path.join(systemdDir(), unit));
|
|
427
|
+
let running = false, watching = false, scheduled = false;
|
|
428
|
+
if (systemctlAvailable()) {
|
|
429
|
+
const r = await run('systemctl', ['--user', 'is-active', unit], { silent: true });
|
|
430
|
+
running = r.code === 0 && r.stdout.trim() === 'active';
|
|
431
|
+
if (pathUnit) {
|
|
432
|
+
const r2 = await run('systemctl', ['--user', 'is-active', pathUnit], { silent: true });
|
|
433
|
+
watching = r2.code === 0 && r2.stdout.trim() === 'active';
|
|
434
|
+
}
|
|
435
|
+
if (timerUnit) {
|
|
436
|
+
const r3 = await run('systemctl', ['--user', 'is-active', timerUnit], { silent: true });
|
|
437
|
+
scheduled = r3.code === 0 && r3.stdout.trim() === 'active';
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
let mode;
|
|
441
|
+
if (running) mode = 'running';
|
|
442
|
+
else if (scheduled) mode = 'scheduled';
|
|
443
|
+
else if (watching) mode = 'watching';
|
|
444
|
+
else if (installed) mode = 'idle';
|
|
445
|
+
else mode = 'absent';
|
|
446
|
+
out.push({ name: svc.name, installed, running, mode });
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function linuxControl(action, name) {
|
|
452
|
+
const unit = systemdUnitName(name, 'service');
|
|
453
|
+
if (!systemctlAvailable()) {
|
|
454
|
+
throw new Error('systemctl --user bus not reachable');
|
|
455
|
+
}
|
|
456
|
+
if (action === 'start') return await run('systemctl', ['--user', 'start', unit], { silent: true });
|
|
457
|
+
if (action === 'stop') return await run('systemctl', ['--user', 'stop', unit], { silent: true });
|
|
458
|
+
if (action === 'restart') return await run('systemctl', ['--user', 'restart', unit], { silent: true });
|
|
459
|
+
throw new Error(`unknown action: ${action}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// Windows — schtasks (best-effort) or printed instructions
|
|
464
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
async function winInstall(svcs, pyPath, opts) {
|
|
467
|
+
if (opts.dryRun) {
|
|
468
|
+
process.stdout.write(` ${info('Windows service install is a dry-run only in v1.2.0. Use Task Scheduler manually.')}\n`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Best-effort: create onLogon scheduled task for the dashboard.
|
|
472
|
+
// Reflection (file-watch) is not supported via schtasks; instruct user.
|
|
473
|
+
process.stdout.write(` ${warn('Windows auto-service install is limited in v1.2.0:')}\n`);
|
|
474
|
+
process.stdout.write(` ${info('Dashboard: registering schtasks onLogon trigger…')}\n`);
|
|
475
|
+
for (const svc of svcs) {
|
|
476
|
+
if (svc.name !== 'dashboard') {
|
|
477
|
+
process.stdout.write(` ${warn(`${svc.name}: not auto-installed on Windows. Run manually when needed:`)}\n`);
|
|
478
|
+
process.stdout.write(` ${c.cyan(`"${pyPath}" -m ${svc.module}`)}\n`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const taskName = `TotalAgentMemory-${svc.name}`;
|
|
482
|
+
const cmd = `"${pyPath}" -m ${svc.module}`;
|
|
483
|
+
await run('schtasks', [
|
|
484
|
+
'/Create', '/F',
|
|
485
|
+
'/SC', 'ONLOGON',
|
|
486
|
+
'/TN', taskName,
|
|
487
|
+
'/TR', cmd,
|
|
488
|
+
'/RL', 'LIMITED',
|
|
489
|
+
], { silent: true });
|
|
490
|
+
// And run it once now.
|
|
491
|
+
await run('schtasks', ['/Run', '/TN', taskName], { silent: true });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function winUninstall(svcs, opts) {
|
|
496
|
+
for (const svc of svcs) {
|
|
497
|
+
if (svc.name !== 'dashboard') continue;
|
|
498
|
+
const taskName = `TotalAgentMemory-${svc.name}`;
|
|
499
|
+
if (!opts.dryRun) await run('schtasks', ['/Delete', '/F', '/TN', taskName], { silent: true });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function winStatus(svcs) {
|
|
504
|
+
const out = [];
|
|
505
|
+
for (const svc of svcs) {
|
|
506
|
+
if (svc.name !== 'dashboard') {
|
|
507
|
+
out.push({ name: svc.name, installed: false, running: false, mode: 'absent', note: 'manual on Windows' });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const taskName = `TotalAgentMemory-${svc.name}`;
|
|
511
|
+
const r = await run('schtasks', ['/Query', '/TN', taskName, '/FO', 'CSV', '/NH'], { silent: true });
|
|
512
|
+
const installed = r.code === 0;
|
|
513
|
+
const running = installed && /Running/i.test(r.stdout);
|
|
514
|
+
const mode = running ? 'running' : (installed ? 'idle' : 'absent');
|
|
515
|
+
out.push({ name: svc.name, installed, running, mode });
|
|
516
|
+
}
|
|
517
|
+
return out;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function winControl(action, name) {
|
|
521
|
+
const taskName = `TotalAgentMemory-${name}`;
|
|
522
|
+
if (action === 'start') return await run('schtasks', ['/Run', '/TN', taskName], { silent: true });
|
|
523
|
+
if (action === 'stop') return await run('schtasks', ['/End', '/TN', taskName], { silent: true });
|
|
524
|
+
if (action === 'restart') {
|
|
525
|
+
await run('schtasks', ['/End', '/TN', taskName], { silent: true });
|
|
526
|
+
return await run('schtasks', ['/Run', '/TN', taskName], { silent: true });
|
|
527
|
+
}
|
|
528
|
+
throw new Error(`unknown action: ${action}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
532
|
+
// Public API (OS dispatch)
|
|
533
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
function osDispatch() {
|
|
536
|
+
if (process.platform === 'darwin') return { install: macInstall, uninstall: macUninstall, status: macStatus, control: macControl, name: 'macOS' };
|
|
537
|
+
if (process.platform === 'linux') return { install: linuxInstall, uninstall: linuxUninstall, status: linuxStatus, control: linuxControl, name: 'Linux' };
|
|
538
|
+
if (process.platform === 'win32') return { install: winInstall, uninstall: winUninstall, status: winStatus, control: winControl, name: 'Windows' };
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function installServices(flags, opts = {}) {
|
|
543
|
+
const d = osDispatch();
|
|
544
|
+
if (!d) {
|
|
545
|
+
process.stdout.write(` ${warn(`OS ${process.platform} not supported for auto-service install`)}\n`);
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
const py = venvBin(flags, 'python');
|
|
549
|
+
if (!exists(py)) {
|
|
550
|
+
process.stdout.write(` ${fail(`venv python not found: ${py}`)}\n`);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
const svcs = serviceDefs(flags);
|
|
554
|
+
process.stdout.write(` ${info(`Installing ${svcs.length} background services (${d.name})…`)}\n`);
|
|
555
|
+
await d.install(svcs, py, opts);
|
|
556
|
+
// Print quick verification hint.
|
|
557
|
+
process.stdout.write(` ${ok(`Services installed. Dashboard: ${c.cyan('http://127.0.0.1:37737')}`)}\n`);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function uninstallServices(flags, opts = {}) {
|
|
562
|
+
const d = osDispatch();
|
|
563
|
+
if (!d) return false;
|
|
564
|
+
const svcs = serviceDefs(flags);
|
|
565
|
+
process.stdout.write(` ${info(`Removing background services (${d.name})…`)}\n`);
|
|
566
|
+
await d.uninstall(svcs, opts);
|
|
567
|
+
process.stdout.write(` ${ok('Services removed.')}\n`);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function serviceStatus(flags) {
|
|
572
|
+
const d = osDispatch();
|
|
573
|
+
if (!d) return [];
|
|
574
|
+
return await d.status(serviceDefs(flags));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function controlService(action, name, flags) {
|
|
578
|
+
const d = osDispatch();
|
|
579
|
+
if (!d) throw new Error(`OS ${process.platform} not supported`);
|
|
580
|
+
return await d.control(action, name);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function logsPath(name, flags, which = 'stdout') {
|
|
584
|
+
const svc = serviceDefs(flags).find((s) => s.name === name);
|
|
585
|
+
if (!svc) return null;
|
|
586
|
+
return which === 'stderr' ? svc.errFile : svc.logFile;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Probe dashboard HTTP endpoint. Returns true if /api/stats answers 200.
|
|
590
|
+
async function probeDashboardHealth(flags, timeoutMs = 2000) {
|
|
591
|
+
const port = process.env.DASHBOARD_PORT || '37737';
|
|
592
|
+
return await new Promise((resolve) => {
|
|
593
|
+
const http = require('http');
|
|
594
|
+
const req = http.get(`http://127.0.0.1:${port}/api/stats`, (res) => {
|
|
595
|
+
res.on('data', () => {});
|
|
596
|
+
res.on('end', () => resolve(res.statusCode === 200));
|
|
597
|
+
});
|
|
598
|
+
req.on('error', () => resolve(false));
|
|
599
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); });
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
module.exports = {
|
|
604
|
+
serviceDefs,
|
|
605
|
+
installServices,
|
|
606
|
+
uninstallServices,
|
|
607
|
+
serviceStatus,
|
|
608
|
+
controlService,
|
|
609
|
+
logsPath,
|
|
610
|
+
probeDashboardHealth,
|
|
611
|
+
// Exposed for tests:
|
|
612
|
+
renderPlist,
|
|
613
|
+
renderSystemdService,
|
|
614
|
+
renderSystemdPath,
|
|
615
|
+
renderSystemdTimer,
|
|
616
|
+
renderSystemdCalendarTimer,
|
|
617
|
+
plistPath,
|
|
618
|
+
plistLabel,
|
|
619
|
+
systemdDir,
|
|
620
|
+
systemdUnitName,
|
|
621
|
+
};
|
package/package.json
CHANGED