suemo 0.0.6 → 0.0.8

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
@@ -5,7 +5,9 @@
5
5
  > [!CAUTION]
6
6
  > **Bun-only.** This project will not run on Node.js and there are no plans to support it.
7
7
  >
8
- > suemo is experimental software built for the author's personal agent infrastructure. APIs may change without notice. Use at your own risk.
8
+ > suemo is experimental software built for the author's personal agent infrastructure. APIs may change without notice.
9
+ >
10
+ > Most of the code is AI-generated and only tested briefly by a single person. Use at your own risk.
9
11
 
10
12
  suemo gives AI agents a memory that survives across sessions, models, and runtimes. Write observations from a Telegram bot, query them from OpenCode, consolidate overnight — all agents share one source of truth in SurrealDB.
11
13
 
@@ -17,7 +19,7 @@ suemo gives AI agents a memory that survives across sessions, models, and runtim
17
19
  - **Bi-temporal nodes** — every fact has `valid_from` / `valid_until`; nothing is hard-deleted
18
20
  - **Contradiction detection** — calling `believe()` with a conflicting belief automatically invalidates the old one and links them with a `contradicts` edge
19
21
  - **Two-phase consolidation** — NREM clusters and compresses redundant observations via LLM; REM integrates new summaries into the broader graph with auto-scored relations
20
- - **SurrealKV time-travel** — `VERSION d'...'` queries let you inspect any node's state at any past datetime (requires `SURREAL_DATASTORE_RETENTION=90d`)
22
+ - **SurrealKV time-travel** — `VERSION d'...'` queries let you inspect any node's state at any past datetime (requires `SURREAL_DATASTORE_RETENTION=90d` / `0` for infinite)
21
23
  - **Namespace isolation** — one SurrealDB instance, multiple agents, zero collision (`namespace` = agent group, `database` = agent identity)
22
24
  - **MCP + CLI** — both interfaces are thin shells over the same domain functions; no business logic duplication
23
25
  - **Lightweight** — no bundled vector store, no embedded database process, no OpenAI SDK; embedding via `fn::embed()` runs server-side in SurrealDB
@@ -36,13 +38,23 @@ SurrealDB must be started with SurrealKV and a retention window:
36
38
  ```sh
37
39
  SURREAL_DATASTORE_VERSIONED=true SURREAL_DATASTORE_RETENTION=90d surreal start \
38
40
  --bind 0.0.0.0:8000 \
39
- --allow-funcs "fn::*" \
41
+ --allow-funcs "*" \
40
42
  -- surrealkv:///path/to/data
41
43
  ```
42
44
 
43
- For suemo, the critical capability is allowing custom functions (`fn::embed`) via `--allow-funcs "fn::*"`.
45
+ For suemo, the critical capability is allowing custom functions:
46
+
47
+ - `fn::*`
48
+ - `time::*`
49
+ - `vector::*`
50
+ - `search::*` (for `search::score`)
51
+ - `math::*` (for `math::mean`, `math::min`)
52
+ - `rand::*` (used in `wander` query)
53
+
54
+ If you run with strict capability mode, use an allowlist like:
44
55
 
45
- An embedding model must be configured in SurrealDB for `fn::embed()` to work.
56
+ - for **openai-compatible/stub**: `fn,time,vector,search,math,rand`
57
+ - for **surreal** provider (with `ml::...` in `fn::embed` wrapper): add `ml`
46
58
 
47
59
  ---
48
60
 
@@ -80,6 +92,56 @@ export default defineConfig({
80
92
 
81
93
  With this, suemo resolves default scope from nearest `.ua/suemo.json` (auto-created if missing).
82
94
 
95
+ ### Optional: one-command local service setup (Arch Linux)
96
+
97
+ **NOTE:** These init commands are Arch Linux–specific (author's primary development environment). They rely on `pacman` package checks and systemd paths matching Arch Linux layouts.
98
+
99
+ For local host deployments, suemo can install systemd service assets directly.
100
+
101
+ These commands **must be run as root**:
102
+
103
+ ```sh
104
+ sudo suemo init surreal 2gb --dry-run
105
+ sudo suemo init surreal 6gb --dry-run
106
+ sudo suemo init fastembed --dry-run
107
+ ```
108
+
109
+ If `suemo` is not on root PATH, use `sudo bunx suemo ...`.
110
+
111
+ Drop `--dry-run` to apply changes.
112
+
113
+ `suemo init surreal` also creates `/opt/suemo/surrealdb/local.env` with commented overrides and does not overwrite it if it already exists. If defined in `local.env`, these values override `common.env`:
114
+
115
+ - `SURREAL_USER`
116
+ - `SURREAL_PASS`
117
+ - `SURREAL_BIND`
118
+ - `SURREAL_PATH`
119
+
120
+ Add `--force` to regenerate managed env files (`common.env` and profile env).
121
+
122
+ What these commands do:
123
+
124
+ - `suemo init surreal <2gb|6gb>`
125
+ - checks `surrealdb` package via `pacman -Q`
126
+ - creates `/opt/suemo/surrealdb/common.env` + profile env (`2gb.env` or `6gb.env`)
127
+ - creates `/opt/suemo/surrealdb/local.env` once (user override file; not overwritten)
128
+ - writes `/etc/systemd/system/suemo-surrealdb@.service`
129
+ - enables + starts `suemo-surrealdb@<profile>.service`
130
+ - includes `VERSIONED` + retention config and strict capability allowlist:
131
+ - `SURREAL_DATASTORE_VERSIONED=true`
132
+ - `SURREAL_DATASTORE_RETENTION=90d`
133
+ - `SURREAL_CAPS_DENY_ALL=true`
134
+ - `SURREAL_CAPS_ALLOW_FUNC=fn,time,vector,search,math,rand,ml`
135
+
136
+ - `suemo init fastembed`
137
+ - checks `python-fastembed`, `python-fastapi`, `python-uvicorn` via `pacman -Q`
138
+ - installs `data/fastembed-server.py` to `/opt/suemo/fastembed-server.py`
139
+ - creates `/opt/fastembed/local.env` once (user override file; not overwritten)
140
+ - writes `/etc/systemd/system/suemo-fastembed.service`
141
+ - enables + starts `suemo-fastembed.service`
142
+
143
+ `--dry-run` prints all generated file content and planned commands to stdout without writing anything.
144
+
83
145
  **2. Apply schema**
84
146
 
85
147
  Apply schema after you set/edit namespace/database:
@@ -155,10 +217,12 @@ Global flags (inherited by all commands):
155
217
  -c, --config <path> Path to config file
156
218
  -d, --debug Verbose debug logging
157
219
 
158
- Commands:
159
- init Show init subcommands and usage guidance
160
- init config Create/update ~/.suemo/suemo.ts
161
- init schema Apply DB schema from current config (with confirm)
220
+ Commands:
221
+ init Show init subcommands and usage guidance
222
+ init config Create/update ~/.suemo/suemo.ts
223
+ init schema Apply DB schema from current config (with confirm)
224
+ init surreal Install systemd SurrealDB profile (2gb/6gb) with VERSIONED + allowlist config
225
+ init fastembed Install systemd fastembed service
162
226
  skill Print suemo skill docs (or specific reference)
163
227
  serve Start the MCP server (HTTP or stdio)
164
228
  observe <content> Store an observation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suemo",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Persistent semantic memory for AI agents — backed by SurrealDB.",
5
5
  "author": {
6
6
  "name": "Umar Alfarouk",
@@ -27,7 +27,7 @@
27
27
  "bin": {
28
28
  "suemo": "./src/cli/index.ts"
29
29
  },
30
- "files": ["src", "LICENSE", "README.md"],
30
+ "files": ["src", "LICENSE", "README.md", "tsconfig.json"],
31
31
  "exports": {
32
32
  ".": {
33
33
  "bun": "./src/index.ts",
@@ -44,9 +44,9 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@crustjs/core": "^0.0.15",
47
- "@crustjs/plugins": "^0.0.19",
48
- "@crustjs/prompts": "^0.0.9",
49
- "@crustjs/style": "^0.0.5",
47
+ "@crustjs/plugins": "^0.0.20",
48
+ "@crustjs/prompts": "^0.0.10",
49
+ "@crustjs/style": "^0.0.6",
50
50
  "@logtape/file": "^2.0.4",
51
51
  "@logtape/logtape": "^2.0.4",
52
52
  "@surrealdb/node": "^3.0.3",
@@ -1,13 +1,17 @@
1
1
  import { confirm } from '@crustjs/prompts'
2
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
3
- import { dirname, join, resolve as resolvePath } from 'node:path'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { randomBytes } from 'node:crypto'
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { basename, dirname, join, resolve as resolvePath } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
4
8
  import packageJson from '../../../package.json' with { type: 'json' }
5
9
  import { loadConfig } from '../../config.ts'
6
10
  import { connect, disconnect } from '../../db/client.ts'
7
11
  import { checkCompatibility } from '../../db/preflight.ts'
8
12
  import { runSchema } from '../../db/schema.ts'
9
13
  import { getLogger } from '../../logger.ts'
10
- import { app, initCliCommand, resolveOutputModeOrExit } from '../shared.ts'
14
+ import { app, initCliCommand, printCliJson, resolveOutputModeOrExit } from '../shared.ts'
11
15
 
12
16
  import template from '../../config.template.ts' with { type: 'text' }
13
17
 
@@ -25,6 +29,541 @@ const log = getLogger(['suemo', 'cli', 'init'])
25
29
  const init = app.sub('init')
26
30
  .meta({ description: 'Initialize suemo config and/or database schema' })
27
31
 
32
+ const SURREAL_PROFILES_DIR = '/opt/suemo/surrealdb'
33
+ const SURREAL_LOCAL_ENV_PATH = '/opt/suemo/surrealdb/local.env'
34
+ const SURREAL_SYSTEMD_UNIT_PATH = '/etc/systemd/system/suemo-surrealdb@.service'
35
+ const SURREAL_DATA_DIR = '/var/lib/surrealdb'
36
+ const FASTEMBED_INSTALL_DIR = '/opt/suemo'
37
+ const FASTEMBED_LOCAL_ENV_DIR = '/opt/fastembed'
38
+ const FASTEMBED_SCRIPT_TARGET = '/opt/suemo/fastembed-server.py'
39
+ const FASTEMBED_CACHE_DIR = '/var/cache/fastembed'
40
+ const FASTEMBED_SERVICE_PATH = '/etc/systemd/system/suemo-fastembed.service'
41
+ const FASTEMBED_USER_HOME = '/var/lib/fastembed'
42
+
43
+ const SURREAL_TEMPLATE_UNIT = `# Generated by suemo init surreal
44
+ [Unit]
45
+ Description=SurrealDB (%i profile)
46
+ Documentation=https://surrealdb.com/docs/surrealdb
47
+ After=network.target
48
+ Wants=network-online.target
49
+
50
+ [Service]
51
+ Type=simple
52
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
53
+ User=surrealdb
54
+ Group=surrealdb
55
+ EnvironmentFile=/opt/suemo/surrealdb/common.env
56
+ EnvironmentFile=/opt/suemo/surrealdb/%i.env
57
+ EnvironmentFile=-/opt/suemo/surrealdb/local.env
58
+ ExecStart=/usr/bin/surreal start
59
+ ExecStop=/bin/kill -s SIGTERM $MAINPID
60
+ Restart=on-failure
61
+ RestartSec=5s
62
+ TimeoutStartSec=30s
63
+ TimeoutStopSec=30s
64
+ LimitNOFILE=65536
65
+ LimitNPROC=4096
66
+ NoNewPrivileges=true
67
+ PrivateTmp=true
68
+ ProtectSystem=strict
69
+ ProtectHome=true
70
+ ProtectKernelTunables=true
71
+ ProtectKernelModules=true
72
+ ProtectControlGroups=true
73
+ RestrictRealtime=true
74
+ RestrictNamespaces=true
75
+ StateDirectory=surrealdb
76
+ StateDirectoryMode=0750
77
+ WorkingDirectory=/var/lib/surrealdb
78
+ StandardOutput=journal
79
+ StandardError=journal
80
+ SyslogIdentifier=suemo-surrealdb-%i
81
+
82
+ [Install]
83
+ WantedBy=multi-user.target
84
+ `
85
+
86
+ const SURREAL_COMMON_ENV_TEMPLATE = `# Generated by suemo init surreal
87
+ # DO NOT edit - managed by suemo
88
+
89
+ # Root auth (rotate regularly via local.env)
90
+ SURREAL_USER=root
91
+ SURREAL_PASS=__SURREAL_PASS__
92
+
93
+ # Network
94
+ SURREAL_BIND=127.0.0.1:8000
95
+
96
+ # Datastore
97
+ SURREAL_PATH=surrealkv:///var/lib/surrealdb/db
98
+ SURREAL_DATASTORE_VERSIONED=true
99
+ SURREAL_DATASTORE_RETENTION=90d
100
+ SURREAL_DATASTORE_SYNC_DATA=every
101
+
102
+ # SurrealKV storage tuning
103
+ SURREAL_SURREALKV_ENABLE_VLOG=true
104
+ SURREAL_SURREALKV_VLOG_THRESHOLD=4096
105
+ SURREAL_SURREALKV_VERSIONED_INDEX=false
106
+
107
+ # Grouped commit batching
108
+ SURREAL_SURREALKV_GROUPED_COMMIT_TIMEOUT=5000000
109
+ SURREAL_SURREALKV_GROUPED_COMMIT_WAIT_THRESHOLD=12
110
+ SURREAL_SURREALKV_GROUPED_COMMIT_MAX_BATCH_SIZE=4096
111
+
112
+ # Changefeed GC
113
+ SURREAL_CHANGEFEED_GC_INTERVAL=60s
114
+
115
+ # Timeouts
116
+ SURREAL_QUERY_TIMEOUT=30s
117
+ SURREAL_TRANSACTION_TIMEOUT=15s
118
+
119
+ # Capability allowlist (required for suemo workflows)
120
+ SURREAL_CAPS_DENY_ALL=true
121
+ SURREAL_CAPS_ALLOW_FUNC=fn,time,vector,search,math,rand,ml
122
+
123
+ # Logging
124
+ SURREAL_LOG=warn
125
+ SURREAL_NO_BANNER=true
126
+ `
127
+
128
+ const SURREAL_LOCAL_ENV_TEMPLATE = `# User overrides - edit this file to customize credentials
129
+ # This file is NOT overwritten by suemo init surreal
130
+ # Uncomment and set values to override defaults from common.env
131
+
132
+ #SURREAL_USER=root
133
+ #SURREAL_PASS=your-password-here
134
+ #SURREAL_BIND=127.0.0.1:8000
135
+ #SURREAL_PATH=surrealkv:///var/lib/surrealdb/db
136
+ `
137
+
138
+ const SURREAL_PROFILE_2GB_ENV = `# Generated by suemo init surreal 2gb
139
+ SURREAL_SURREALKV_BLOCK_CACHE_CAPACITY=67108864
140
+ SURREAL_SURREALKV_VLOG_MAX_FILE_SIZE=67108864
141
+ SURREAL_TRANSACTION_CACHE_SIZE=2000
142
+ SURREAL_DATASTORE_CACHE_SIZE=500
143
+ SURREAL_HNSW_CACHE_SIZE=16777216
144
+ SURREAL_RUNTIME_WORKER_THREADS=2
145
+ SURREAL_RUNTIME_MAX_BLOCKING_THREADS=64
146
+ SURREAL_NET_MAX_CONCURRENT_REQUESTS=1024
147
+ SURREAL_WEBSOCKET_READ_BUFFER_SIZE=65536
148
+ SURREAL_WEBSOCKET_WRITE_BUFFER_SIZE=65536
149
+ SURREAL_EXTERNAL_SORTING_BUFFER_LIMIT=10000
150
+ SURREAL_MEMORY_THRESHOLD=256m
151
+ `
152
+
153
+ const SURREAL_PROFILE_6GB_ENV = `# Generated by suemo init surreal 6gb
154
+ SURREAL_SURREALKV_BLOCK_CACHE_CAPACITY=1073741824
155
+ SURREAL_SURREALKV_VLOG_MAX_FILE_SIZE=134217728
156
+ SURREAL_TRANSACTION_CACHE_SIZE=5000
157
+ SURREAL_DATASTORE_CACHE_SIZE=1000
158
+ SURREAL_HNSW_CACHE_SIZE=67108864
159
+ SURREAL_RUNTIME_WORKER_THREADS=4
160
+ SURREAL_RUNTIME_MAX_BLOCKING_THREADS=128
161
+ SURREAL_NET_MAX_CONCURRENT_REQUESTS=4096
162
+ SURREAL_WEBSOCKET_READ_BUFFER_SIZE=131072
163
+ SURREAL_WEBSOCKET_WRITE_BUFFER_SIZE=131072
164
+ SURREAL_EXTERNAL_SORTING_BUFFER_LIMIT=25000
165
+ SURREAL_MEMORY_THRESHOLD=512m
166
+ `
167
+
168
+ const FASTEMBED_LOCAL_ENV_PATH = '/opt/fastembed/local.env'
169
+
170
+ const FASTEMBED_LOCAL_ENV_TEMPLATE = `# User overrides - edit this file to customize settings
171
+ # This file is NOT overwritten by suemo init fastembed
172
+ # Uncomment and set values to override defaults
173
+
174
+ #FASTEMBED_HOST=127.0.0.1
175
+ #FASTEMBED_PORT=8080
176
+ #FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
177
+ #FASTEMBED_CACHE_DIR=/var/cache/fastembed
178
+ `
179
+
180
+ const FASTEMBED_SYSTEMD_SERVICE = `# Generated by suemo init fastembed
181
+ [Unit]
182
+ Description=FastEmbed OpenAI-compatible embedding service
183
+ After=network.target
184
+
185
+ [Service]
186
+ Type=simple
187
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
188
+ User=fastembed
189
+ Group=fastembed
190
+ WorkingDirectory=/opt/suemo
191
+ Environment=FASTEMBED_HOST=127.0.0.1
192
+ Environment=FASTEMBED_PORT=8080
193
+ Environment=FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
194
+ Environment=FASTEMBED_CACHE_DIR=/var/cache/fastembed
195
+ EnvironmentFile=-/opt/fastembed/local.env
196
+ ExecStart=/usr/bin/python /opt/suemo/fastembed-server.py
197
+ Restart=on-failure
198
+ RestartSec=5s
199
+ NoNewPrivileges=true
200
+ PrivateTmp=true
201
+ ProtectSystem=strict
202
+ ProtectHome=true
203
+ ReadWritePaths=/var/cache/fastembed
204
+ StandardOutput=journal
205
+ StandardError=journal
206
+ SyslogIdentifier=suemo-fastembed
207
+
208
+ [Install]
209
+ WantedBy=multi-user.target
210
+ `
211
+
212
+ type InitAction =
213
+ | {
214
+ kind: 'mkdir'
215
+ path: string
216
+ mode: number
217
+ }
218
+ | {
219
+ kind: 'write'
220
+ path: string
221
+ mode: number
222
+ content: string
223
+ }
224
+ | {
225
+ kind: 'run'
226
+ command: string
227
+ args: string[]
228
+ requireRoot?: boolean
229
+ }
230
+
231
+ type CredentialStatus = 'preserved' | 'generated'
232
+
233
+ interface SurrealInitResult {
234
+ actions: InitAction[]
235
+ credentialStatus: CredentialStatus
236
+ }
237
+
238
+ function modeText(mode: number): string {
239
+ return mode.toString(8)
240
+ }
241
+
242
+ function hasRoot(): boolean {
243
+ return typeof process.getuid === 'function' ? process.getuid() === 0 : false
244
+ }
245
+
246
+ function runCommand(command: string, args: string[], options?: { requireRoot?: boolean; quiet?: boolean }): void {
247
+ const requireRoot = options?.requireRoot ?? false
248
+ const quiet = options?.quiet ?? false
249
+ if (requireRoot && !hasRoot()) {
250
+ throw new Error(
251
+ `Root privileges required for ${command}. Run init commands as root (e.g. sudo suemo init ... or sudo bunx suemo init ...).`,
252
+ )
253
+ }
254
+ const result = spawnSync(command, args, {
255
+ stdio: quiet ? 'ignore' : 'inherit',
256
+ })
257
+ if ((result.status ?? 1) !== 0) {
258
+ throw new Error(`Command failed: ${[command, ...args].join(' ')}`)
259
+ }
260
+ }
261
+
262
+ function requireRootForInit(subcommand: string): void {
263
+ if (hasRoot()) return
264
+ throw new Error(
265
+ `\`suemo ${subcommand}\` must be run as root. Try: \`sudo suemo ${subcommand}\` (or \`sudo bunx suemo ${subcommand}\` if suemo is not on root PATH).`,
266
+ )
267
+ }
268
+
269
+ function packageInstalled(pkg: string): boolean {
270
+ const result = spawnSync('pacman', ['-Q', pkg], { stdio: 'ignore' })
271
+ return (result.status ?? 1) === 0
272
+ }
273
+
274
+ function requireArchPackages(packages: string[]): void {
275
+ const pacmanProbe = spawnSync('pacman', ['--version'], { stdio: 'ignore' })
276
+ if ((pacmanProbe.status ?? 1) !== 0) {
277
+ throw new Error('pacman not found. `suemo init surreal/fastembed` currently supports Arch Linux systems.')
278
+ }
279
+
280
+ const missing = packages.filter((pkg) => !packageInstalled(pkg))
281
+ if (missing.length > 0) {
282
+ throw new Error(
283
+ `Missing required Arch package(s): ${missing.join(', ')}. Install with: sudo pacman -S ${missing.join(' ')}`,
284
+ )
285
+ }
286
+ }
287
+
288
+ function commandExists(command: string): boolean {
289
+ const result = spawnSync('sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' })
290
+ return (result.status ?? 1) === 0
291
+ }
292
+
293
+ function requireCommands(commands: string[]): void {
294
+ const missing = commands.filter((command) => !commandExists(command))
295
+ if (missing.length > 0) {
296
+ throw new Error(`Missing required command(s): ${missing.join(', ')}`)
297
+ }
298
+ }
299
+
300
+ function systemUserExists(user: string): boolean {
301
+ const result = spawnSync('id', ['-u', user], { stdio: 'ignore' })
302
+ return (result.status ?? 1) === 0
303
+ }
304
+
305
+ function ensureDir(path: string, mode: number): void {
306
+ if (hasRoot()) {
307
+ mkdirSync(path, { recursive: true })
308
+ chmodSync(path, mode)
309
+ return
310
+ }
311
+ runCommand('install', ['-d', '-m', modeText(mode), path], { requireRoot: true })
312
+ }
313
+
314
+ function writeRootFile(path: string, content: string, mode: number): void {
315
+ if (hasRoot()) {
316
+ mkdirSync(dirname(path), { recursive: true })
317
+ writeFileSync(path, content, 'utf-8')
318
+ chmodSync(path, mode)
319
+ return
320
+ }
321
+
322
+ const tempDir = mkdtempSync(join(tmpdir(), 'suemo-init-'))
323
+ const tempFile = join(tempDir, basename(path))
324
+ try {
325
+ writeFileSync(tempFile, content, 'utf-8')
326
+ runCommand('install', ['-D', '-m', modeText(mode), tempFile, path], { requireRoot: true })
327
+ } finally {
328
+ rmSync(tempDir, { recursive: true, force: true })
329
+ }
330
+ }
331
+
332
+ function generatePassword(): string {
333
+ return randomBytes(20).toString('base64url')
334
+ }
335
+
336
+ function printDryRunActions(actions: InitAction[]): void {
337
+ for (const action of actions) {
338
+ if (action.kind === 'mkdir') {
339
+ console.log(`[dry-run] mkdir ${action.path} (mode ${modeText(action.mode)})`)
340
+ continue
341
+ }
342
+ if (action.kind === 'run') {
343
+ console.log(`[dry-run] run ${action.command} ${action.args.join(' ')}`.trim())
344
+ continue
345
+ }
346
+ console.log(`[dry-run] write ${action.path} (mode ${modeText(action.mode)})`)
347
+ console.log('-----8<-----')
348
+ console.log(action.content.trimEnd())
349
+ console.log('----->8-----')
350
+ }
351
+ }
352
+
353
+ function applyActions(actions: InitAction[]): void {
354
+ for (const action of actions) {
355
+ if (action.kind === 'mkdir') {
356
+ ensureDir(action.path, action.mode)
357
+ continue
358
+ }
359
+ if (action.kind === 'write') {
360
+ writeRootFile(action.path, action.content, action.mode)
361
+ continue
362
+ }
363
+ if (action.requireRoot === true) {
364
+ runCommand(action.command, action.args, { requireRoot: true })
365
+ } else {
366
+ runCommand(action.command, action.args)
367
+ }
368
+ }
369
+ }
370
+
371
+ function readEnvFile(path: string): string | null {
372
+ try {
373
+ const content = readFileSync(path, 'utf-8')
374
+ log.debug(`readEnvFile: successfully read file`, { path, length: content.length })
375
+ return content
376
+ } catch (error) {
377
+ const errorCode = error && typeof error === 'object' && 'code' in error
378
+ ? (error as { code: string }).code
379
+ : 'UNKNOWN'
380
+
381
+ if (errorCode === 'ENOENT') {
382
+ log.debug(`readEnvFile: file does not exist`, { path })
383
+ return null
384
+ }
385
+
386
+ throw new Error(`Failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`)
387
+ }
388
+ }
389
+
390
+ function extractEnvValue(content: string, key: string): string | null {
391
+ const match = new RegExp(`^${key}\\s*=\\s*(.+)$`, 'm').exec(content)
392
+ if (!match || !match[1]) return null
393
+ return match[1].trim()
394
+ }
395
+
396
+ function readFastembedScriptSource(): string {
397
+ const path = fileURLToPath(new URL('../../../data/fastembed-server.py', import.meta.url))
398
+ return readFileSync(path, 'utf-8')
399
+ }
400
+
401
+ function buildSurrealActions(
402
+ profile: '2gb' | '6gb',
403
+ existingPassword: string | null,
404
+ force: boolean,
405
+ ): SurrealInitResult {
406
+ const commonEnvPath = join(SURREAL_PROFILES_DIR, 'common.env')
407
+ const profileEnvPath = join(SURREAL_PROFILES_DIR, `${profile}.env`)
408
+ const localEnvPath = SURREAL_LOCAL_ENV_PATH
409
+ const actions: InitAction[] = []
410
+
411
+ log.debug('buildSurrealActions: start', { profile, existingPassword: existingPassword ? '***' : null, force })
412
+
413
+ if (!systemUserExists('surrealdb')) {
414
+ actions.push({
415
+ kind: 'run',
416
+ command: 'useradd',
417
+ args: [
418
+ '--system',
419
+ '--home-dir',
420
+ '/var/lib/surrealdb',
421
+ '--shell',
422
+ '/usr/bin/nologin',
423
+ '--create-home',
424
+ 'surrealdb',
425
+ ],
426
+ requireRoot: true,
427
+ })
428
+ }
429
+
430
+ const credentialStatus: CredentialStatus = existingPassword && !force ? 'preserved' : 'generated'
431
+ log.debug('buildSurrealActions: credential decision', { credentialStatus })
432
+
433
+ const password = credentialStatus === 'preserved' ? existingPassword! : generatePassword()
434
+ log.debug('buildSurrealActions: password determined', { passwordSet: !!password })
435
+
436
+ let commonEnv = SURREAL_COMMON_ENV_TEMPLATE.replace('__SURREAL_PASS__', password)
437
+ const existingCommonEnv = readEnvFile(commonEnvPath)
438
+ if (existingCommonEnv && !force) {
439
+ const existingUser = extractEnvValue(existingCommonEnv, 'SURREAL_USER')
440
+ const existingPass = extractEnvValue(existingCommonEnv, 'SURREAL_PASS')
441
+ if (existingUser && existingPass) {
442
+ log.debug('buildSurrealActions: preserving existing user and pass from common.env')
443
+ const lines = commonEnv.split('\n')
444
+ const preservedLines = lines.map((line) => {
445
+ if (line.startsWith('SURREAL_USER=')) {
446
+ return `SURREAL_USER=${existingUser}`
447
+ }
448
+ if (line.startsWith('SURREAL_PASS=')) {
449
+ return `SURREAL_PASS=${existingPass}`
450
+ }
451
+ return line
452
+ })
453
+ commonEnv = preservedLines.join('\n')
454
+ }
455
+ }
456
+
457
+ let profileEnv = profile === '2gb' ? SURREAL_PROFILE_2GB_ENV : SURREAL_PROFILE_6GB_ENV
458
+ const existingProfileEnv = readEnvFile(profileEnvPath)
459
+ if (existingProfileEnv && !force) {
460
+ log.debug('buildSurrealActions: preserving existing profile env')
461
+ profileEnv = existingProfileEnv
462
+ }
463
+
464
+ const localEnvExists = existsSync(localEnvPath)
465
+
466
+ actions.push(
467
+ { kind: 'mkdir', path: SURREAL_PROFILES_DIR, mode: 0o750 },
468
+ { kind: 'mkdir', path: SURREAL_DATA_DIR, mode: 0o750 },
469
+ { kind: 'write', path: commonEnvPath, mode: 0o640, content: commonEnv },
470
+ { kind: 'write', path: profileEnvPath, mode: 0o640, content: profileEnv },
471
+ ...(!localEnvExists
472
+ ? [{ kind: 'write' as const, path: localEnvPath, mode: 0o644, content: SURREAL_LOCAL_ENV_TEMPLATE }]
473
+ : []),
474
+ { kind: 'write', path: SURREAL_SYSTEMD_UNIT_PATH, mode: 0o644, content: SURREAL_TEMPLATE_UNIT },
475
+ { kind: 'run', command: 'chown', args: ['-R', 'surrealdb:surrealdb', SURREAL_DATA_DIR], requireRoot: true },
476
+ {
477
+ kind: 'run',
478
+ command: 'chown',
479
+ args: ['root:surrealdb', commonEnvPath],
480
+ requireRoot: true,
481
+ },
482
+ {
483
+ kind: 'run',
484
+ command: 'chown',
485
+ args: ['root:surrealdb', profileEnvPath],
486
+ requireRoot: true,
487
+ },
488
+ { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
489
+ {
490
+ kind: 'run',
491
+ command: 'systemctl',
492
+ args: ['enable', '--now', `suemo-surrealdb@${profile}.service`],
493
+ requireRoot: true,
494
+ },
495
+ )
496
+
497
+ log.debug('buildSurrealActions: complete', { actionCount: actions.length })
498
+ return { actions, credentialStatus }
499
+ }
500
+
501
+ function buildFastembedActions(scriptContent: string): InitAction[] {
502
+ const actions: InitAction[] = []
503
+ const localEnvExists = existsSync(FASTEMBED_LOCAL_ENV_PATH)
504
+
505
+ if (!systemUserExists('fastembed')) {
506
+ actions.push({
507
+ kind: 'run',
508
+ command: 'useradd',
509
+ args: [
510
+ '--system',
511
+ '--home-dir',
512
+ FASTEMBED_USER_HOME,
513
+ '--shell',
514
+ '/usr/bin/nologin',
515
+ '--create-home',
516
+ 'fastembed',
517
+ ],
518
+ requireRoot: true,
519
+ })
520
+ }
521
+
522
+ actions.push(
523
+ { kind: 'mkdir', path: FASTEMBED_INSTALL_DIR, mode: 0o755 },
524
+ { kind: 'mkdir', path: FASTEMBED_LOCAL_ENV_DIR, mode: 0o755 },
525
+ { kind: 'mkdir', path: FASTEMBED_CACHE_DIR, mode: 0o755 },
526
+ { kind: 'write', path: FASTEMBED_SCRIPT_TARGET, mode: 0o755, content: scriptContent },
527
+ ...(!localEnvExists
528
+ ? [{ kind: 'write' as const, path: FASTEMBED_LOCAL_ENV_PATH, mode: 0o644, content: FASTEMBED_LOCAL_ENV_TEMPLATE }]
529
+ : []),
530
+ { kind: 'write', path: FASTEMBED_SERVICE_PATH, mode: 0o644, content: FASTEMBED_SYSTEMD_SERVICE },
531
+ { kind: 'run', command: 'chown', args: ['-R', 'fastembed:fastembed', FASTEMBED_INSTALL_DIR], requireRoot: true },
532
+ { kind: 'run', command: 'chown', args: ['root:root', FASTEMBED_LOCAL_ENV_DIR], requireRoot: true },
533
+ { kind: 'run', command: 'chown', args: ['root:root', FASTEMBED_LOCAL_ENV_PATH], requireRoot: true },
534
+ { kind: 'run', command: 'chown', args: ['-R', 'fastembed:fastembed', FASTEMBED_CACHE_DIR], requireRoot: true },
535
+ { kind: 'run', command: 'systemctl', args: ['daemon-reload'], requireRoot: true },
536
+ { kind: 'run', command: 'systemctl', args: ['enable', '--now', 'suemo-fastembed.service'], requireRoot: true },
537
+ )
538
+
539
+ return actions
540
+ }
541
+
542
+ function printInitSystemSummary(kind: 'surreal' | 'fastembed', dryRun: boolean, profile?: '2gb' | '6gb'): void {
543
+ if (kind === 'surreal') {
544
+ if (dryRun) {
545
+ console.log('Dry-run complete for SurrealDB setup.')
546
+ } else {
547
+ console.log('✓ SurrealDB setup complete.')
548
+ }
549
+ if (profile) {
550
+ console.log(`Service: suemo-surrealdb@${profile}.service`)
551
+ console.log(`Status: systemctl status suemo-surrealdb@${profile}.service`)
552
+ console.log(`Logs: journalctl -u suemo-surrealdb@${profile}.service -f`)
553
+ }
554
+ return
555
+ }
556
+
557
+ if (dryRun) {
558
+ console.log('Dry-run complete for fastembed setup.')
559
+ } else {
560
+ console.log('✓ fastembed setup complete.')
561
+ }
562
+ console.log('Service: suemo-fastembed.service')
563
+ console.log('Status: systemctl status suemo-fastembed.service')
564
+ console.log('Logs: journalctl -u suemo-fastembed.service -f')
565
+ }
566
+
28
567
  function homeConfigPath(): string {
29
568
  const home = process.env.HOME ?? process.env.USERPROFILE
30
569
  if (!home) throw new Error('HOME/USERPROFILE is not set; cannot resolve ~/.suemo path')
@@ -241,10 +780,137 @@ const initOpenCodeCmd = init.sub('opencode')
241
780
  console.log(`Installed suemo version: ${npmVersion}`)
242
781
  })
243
782
 
783
+ const initSurrealCmd = init.sub('surreal')
784
+ .meta({ description: 'Install SurrealDB systemd profile (2gb|6gb) with VERSIONED + allowlist config' })
785
+ .args([{ name: 'profile', type: 'string', required: true }])
786
+ .flags({
787
+ force: { type: 'boolean', description: 'Overwrite existing env files' },
788
+ 'dry-run': { type: 'boolean', description: 'Print generated files and planned commands', default: false },
789
+ json: { type: 'boolean', description: 'Machine-readable output mode' },
790
+ 'verbose-json': { type: 'boolean', description: 'Include embeddings in JSON output (implies --json)' },
791
+ pretty: { type: 'boolean', description: 'Human-readable output (default)' },
792
+ })
793
+ .run(async ({ args, flags }) => {
794
+ const outputMode = resolveOutputModeOrExit(flags)
795
+ await initCliCommand('init surreal', {
796
+ debug: flags.debug,
797
+ config: flags.config,
798
+ json: outputMode === 'json',
799
+ quiet: flags.quiet,
800
+ })
801
+ requireRootForInit('init surreal <2gb|6gb>')
802
+
803
+ requireCommands(['pacman', 'systemctl', 'install', 'chown', 'id'])
804
+ requireArchPackages(['surrealdb'])
805
+
806
+ const profileRaw = (args as { profile: string }).profile.trim().toLowerCase()
807
+ if (profileRaw !== '2gb' && profileRaw !== '6gb') {
808
+ throw new Error('Profile must be 2gb or 6gb')
809
+ }
810
+ const profile = profileRaw as '2gb' | '6gb'
811
+
812
+ const commonEnvPath = join(SURREAL_PROFILES_DIR, 'common.env')
813
+ const existingCommonEnv = readEnvFile(commonEnvPath)
814
+ const existingPassword = existingCommonEnv ? extractEnvValue(existingCommonEnv, 'SURREAL_PASS') : null
815
+ const force = Boolean(flags.force)
816
+ const dryRun = Boolean(flags['dry-run'])
817
+
818
+ log.debug('init surreal: reading existing password', {
819
+ commonEnvExists: existsSync(commonEnvPath),
820
+ existingPasswordFound: !!existingPassword,
821
+ force,
822
+ })
823
+
824
+ if (dryRun) {
825
+ if (outputMode === 'json') {
826
+ printCliJson({
827
+ ok: true,
828
+ dryRun: true,
829
+ profile,
830
+ existingPassword: existingPassword ?? 'none (not set)',
831
+ actions: [],
832
+ }, flags)
833
+ return
834
+ }
835
+ printInitSystemSummary('surreal', true, profile)
836
+ console.log(`Existing SURREAL_PASS preview: ${existingPassword ?? 'none (not set)'}`)
837
+ return
838
+ }
839
+
840
+ const result = buildSurrealActions(profile, existingPassword, force)
841
+ applyActions(result.actions)
842
+ printInitSystemSummary('surreal', false, profile)
843
+
844
+ if (result.credentialStatus === 'preserved') {
845
+ console.log(
846
+ `Existing SURREAL_USER and SURREAL_PASS have been preserved in ${commonEnvPath}. Use --force to regenerate password.`,
847
+ )
848
+ } else {
849
+ console.log(`Generated new SURREAL_PASS and wrote ${commonEnvPath} (rotate if needed).`)
850
+ }
851
+
852
+ if (outputMode === 'json') {
853
+ printCliJson({
854
+ ok: true,
855
+ dryRun: false,
856
+ profile,
857
+ service: `suemo-surrealdb@${profile}.service`,
858
+ credentialStatus: result.credentialStatus,
859
+ }, flags)
860
+ return
861
+ }
862
+ })
863
+
864
+ const initFastembedCmd = init.sub('fastembed')
865
+ .meta({ description: 'Install fastembed systemd service from data/fastembed.py' })
866
+ .flags({
867
+ 'dry-run': { type: 'boolean', description: 'Print generated files and planned commands', default: false },
868
+ json: { type: 'boolean', description: 'Machine-readable output mode' },
869
+ 'verbose-json': { type: 'boolean', description: 'Include embeddings in JSON output (implies --json)' },
870
+ pretty: { type: 'boolean', description: 'Human-readable output (default)' },
871
+ })
872
+ .run(async ({ flags }) => {
873
+ const outputMode = resolveOutputModeOrExit(flags)
874
+ await initCliCommand('init fastembed', {
875
+ debug: flags.debug,
876
+ config: flags.config,
877
+ json: outputMode === 'json',
878
+ quiet: flags.quiet,
879
+ })
880
+ requireRootForInit('init fastembed')
881
+
882
+ requireCommands(['pacman', 'systemctl', 'install', 'chown', 'id'])
883
+ requireArchPackages(['python-fastembed', 'python-fastapi', 'uvicorn'])
884
+
885
+ const scriptContent = readFastembedScriptSource()
886
+ const actions = buildFastembedActions(scriptContent)
887
+ const dryRun = Boolean(flags['dry-run'])
888
+
889
+ if (dryRun) {
890
+ if (outputMode === 'json') {
891
+ printCliJson({ ok: true, dryRun: true, actions }, flags)
892
+ return
893
+ }
894
+ printDryRunActions(actions)
895
+ printInitSystemSummary('fastembed', true)
896
+ return
897
+ }
898
+
899
+ applyActions(actions)
900
+ printInitSystemSummary('fastembed', false)
901
+
902
+ if (outputMode === 'json') {
903
+ printCliJson({ ok: true, dryRun: false, service: 'suemo-fastembed.service' }, flags)
904
+ return
905
+ }
906
+ })
907
+
244
908
  export const initCmd = init
245
909
  .command(initConfigCmd)
246
910
  .command(initSchemaCmd)
247
911
  .command(initOpenCodeCmd)
912
+ .command(initSurrealCmd)
913
+ .command(initFastembedCmd)
248
914
  .flags({
249
915
  json: { type: 'boolean', description: 'Machine-readable output mode' },
250
916
  'verbose-json': { type: 'boolean', description: 'Include embeddings in JSON output (implies --json)' },
@@ -262,6 +928,8 @@ export const initCmd = init
262
928
  console.log(' suemo init config [--force]')
263
929
  console.log(' suemo init schema [--yes]')
264
930
  console.log(' suemo init opencode')
931
+ console.log(' suemo init surreal <2gb|6gb> [--force] [--dry-run]')
932
+ console.log(' suemo init fastembed [--dry-run]')
265
933
  console.log(' suemo skill [reference]')
266
934
  console.log('\nRun `suemo init --help` for full details.')
267
935
  })
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "moduleDetection": "force",
7
+ "resolveJsonModule": true,
8
+
9
+ "target": "ESNext",
10
+ "noEmit": true,
11
+ "isolatedModules": true,
12
+
13
+ "rewriteRelativeImportExtensions": true,
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": true,
16
+
17
+ "strict": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "exactOptionalPropertyTypes": true,
20
+ "noUnusedLocals": true,
21
+
22
+ "skipLibCheck": true,
23
+
24
+ "paths": {
25
+ "@/*": ["./*"]
26
+ }
27
+ },
28
+ "include": ["src/**/*", "suemo.config.ts"]
29
+ }