total-agent-memory 1.0.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 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
- That's it. One command bootstraps a Python virtual env in `~/.tam/` (legacy
11
- `~/.claude-memory/` is migrated automatically with a symlink kept for
12
- backward-compat), installs the MCP server, and prints next-step instructions
13
- for wiring your IDE.
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`). Also removes `~/.claude-memory` if it's a symlink to `~/.tam`. |
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 11.2.2` | Pin a specific server release. |
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 (legacy claude-total-memory also installed)
67
- └── memory.db # SQLite — your memory lives here
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
- Nothing global is touched. Uninstall with `tam uninstall --yes`.
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 into ~/.tam (idempotent)
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('# Install and start, then wire Claude Code:')}
43
- npx -y total-agent-memory install
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('# All-in-one onboarding:')}
47
- npx -y total-agent-memory
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 11.2.2
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 = '11.2.2';
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, 3, `venv → ${c.dim(venvDir(flags))}`)}\n`);
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, 3, 'resolving source (PyPI → GitHub fallback)…')}\n`);
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, 3, 'pip install…')}\n`);
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: ${c.dim(bin)}`)}\n`);
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
 
@@ -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, '&amp;')
173
+ .replace(/</g, '&lt;')
174
+ .replace(/>/g, '&gt;')
175
+ .replace(/"/g, '&quot;');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-agent-memory",
3
- "version": "1.0.0",
3
+ "version": "1.3.0",
4
4
  "description": "Persistent memory for Claude Code, Codex CLI, Cursor and any MCP client. Local-first, hits #5 on LoCoMo. One command to install.",
5
5
  "keywords": [
6
6
  "mcp",