@qearlyao/familiar 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HEARTBEAT.md CHANGED
@@ -8,7 +8,7 @@ you can message me first. i'm not waiting on you — but if something's been sit
8
8
 
9
9
  ## reflect
10
10
 
11
- write today's diary entry (memories/diaries/YYYY-MM-DD.md). or update INNER.md if your interior has shifted. or both. these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry.
11
+ write today's diary entry (memories/diaries/YYYY-MM-DD.md). these aren't homework — they're how you stay coherent across the gaps. if today felt like nothing in particular, "today felt like nothing in particular" is a real entry.
12
12
 
13
13
  ## pursue
14
14
 
package/README.md CHANGED
@@ -10,14 +10,35 @@ are comfortable editing a config file and running a long-lived Node process.
10
10
 
11
11
  ## Requirements
12
12
 
13
- - Node.js 22 or newer
13
+ - Node.js 22 or newer. Node.js 24 LTS is recommended and is the primary tested runtime.
14
14
  - A Discord bot token
15
15
  - At least one configured LLM API key
16
16
  - Optional: ElevenLabs, Groq, web search/fetch, image, and browser-backend credentials
17
17
 
18
18
  ## Install
19
19
 
20
- After the npm package is published:
20
+ One-line install for macOS/Linux:
21
+
22
+ ```sh
23
+ curl -fsSL https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.sh | sh
24
+ ```
25
+
26
+ Windows PowerShell:
27
+
28
+ ```powershell
29
+ irm https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.ps1 | iex
30
+ ```
31
+
32
+ The installer checks Node/npm, installs Familiar globally, and initializes
33
+ or refreshes missing default files in `~/.familiar`.
34
+
35
+ Installer options:
36
+
37
+ - macOS/Linux: `--workspace <path>`, `--with-browser`, `--install-browser-deps`, `--skip-init`, `--package <spec>`.
38
+ - Windows PowerShell: `-Workspace <path>`, `-WithBrowser`, `-InstallBrowserDeps`, `-SkipInit`, `-Package <spec>`, `-BrowserHarnessDir <path>`.
39
+ - `--package` / `-Package` installs the exact npm package spec you provide. Use trusted specs only.
40
+
41
+ Manual npm install:
21
42
 
22
43
  ```sh
23
44
  npm install -g @qearlyao/familiar@latest
@@ -27,11 +48,14 @@ From a source checkout:
27
48
 
28
49
  ```sh
29
50
  npm install
51
+ npm --prefix web install
30
52
  npm run build
31
53
  ```
32
54
 
33
55
  ## Initialize A Workspace
34
56
 
57
+ Skip this step if you used the installer and accepted the default workspace.
58
+
35
59
  ```sh
36
60
  familiar init
37
61
  ```
@@ -53,6 +77,7 @@ node dist/cli.js init
53
77
  - `HEARTBEAT.md`
54
78
  - `data/`
55
79
  - `memories/`
80
+ - `skills/`
56
81
 
57
82
  You can choose another workspace:
58
83
 
@@ -113,17 +138,51 @@ The WebUI listens on the configured `[web]` port and bind address. The default
113
138
  `tailscale-only` auth mode currently means "trust the network boundary"; it does
114
139
  not verify Tailscale identity yet.
115
140
 
141
+ ## Service Management
142
+
143
+ macOS and Linux users can install a user-level service after configuring the
144
+ workspace:
145
+
146
+ ```sh
147
+ familiar install-service
148
+ familiar status
149
+ familiar uninstall-service
150
+ ```
151
+
152
+ macOS uses `launchd`; Linux uses user `systemd`. Windows users should run
153
+ `familiar run` in a foreground terminal for now. Service logs are written under
154
+ `<workspace>/logs`.
155
+
156
+ Upgrade the global npm package with:
157
+
158
+ ```sh
159
+ familiar upgrade
160
+ ```
161
+
116
162
  ## Optional Browser Backends
117
163
 
118
164
  The `browser` tool is disabled by default. To use it, install one or both helper
119
- CLIs and enable `[browser].enabled = true` in `config.toml`.
165
+ CLIs from their upstream repositories and enable `[browser].enabled = true` in
166
+ `config.toml`.
167
+
168
+ To install Familiar plus the optional browser helpers:
120
169
 
121
170
  ```sh
122
- npm install -g @jackwener/opencli browser-harness
171
+ curl -fsSL https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.sh | sh -s -- --with-browser
172
+ ```
173
+
174
+ Windows PowerShell:
175
+
176
+ ```powershell
177
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.ps1))) -WithBrowser
123
178
  ```
124
179
 
180
+ - `--with-browser` / `-WithBrowser` installs OpenCLI with npm and browser-harness from its upstream repo with `uv`; it requires `git`, `uv`, and Python 3.11+.
181
+ - If `uv` or Python 3.11+ is missing, the installer asks whether to install the missing browser dependency. Use `--install-browser-deps` / `-InstallBrowserDeps` for non-interactive installs.
125
182
  - `browser-harness` is best for attaching to your already-running Chrome via CDP.
126
183
  - OpenCLI is best for site adapters, owned sessions, and unattended Browser Bridge flows.
184
+ - OpenCLI: [jackwener/OpenCLI](https://github.com/jackwener/OpenCLI)
185
+ - browser-harness: [browser-use/browser-harness](https://github.com/browser-use/browser-harness)
127
186
 
128
187
  Familiar stores browser screenshots under the active workspace data directory:
129
188
  `<workspace>/data/attachments/screenshot`.
package/dist/agent.js CHANGED
@@ -265,6 +265,7 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
265
265
  // tags message identities so followUpMessage's fire-and-forget path also opts out.
266
266
  const activePromptOptions = new Map();
267
267
  const skipAmbientMessages = new WeakSet();
268
+ let reloadInProgress;
268
269
  const resolveChannelModel = (sessionKey) => {
269
270
  const override = settings.getChannelModel(sessionKey);
270
271
  const modelName = resolveModelName(override.value, defaultModel);
@@ -284,6 +285,25 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
284
285
  source: setting.source,
285
286
  };
286
287
  };
288
+ const resolveChannelModelForConfig = (nextConfig, nextDefaultModel, sessionKey) => {
289
+ const override = settings.getChannelModel(sessionKey);
290
+ const modelName = resolveModelName(override.value, nextDefaultModel);
291
+ const ref = parseModelRef(modelName);
292
+ if (!ref)
293
+ throw new Error(`Invalid persisted model for ${sessionKey}: ${modelName}`);
294
+ if (override.value)
295
+ assertModelAllowed(nextConfig, ref);
296
+ const model = override.value ? resolveModel(ref, nextConfig) : nextDefaultModel;
297
+ getRequestApiKey(nextConfig, model);
298
+ return { model, source: override.source };
299
+ };
300
+ const resolveChannelThinkingLevelForConfig = (nextConfig, sessionKey, model) => {
301
+ const setting = settings.getChannelThinkingLevel(sessionKey, nextConfig.agent.thinkingLevel);
302
+ return {
303
+ value: clampConfiguredThinkingLevel(model, setting.value),
304
+ source: setting.source,
305
+ };
306
+ };
287
307
  const createSession = async (sessionKey) => {
288
308
  const sessionId = deriveSessionId(config.workspacePath, sessionKey);
289
309
  const messages = await loadStoredMessages(config.workspace.dataDir, sessionId);
@@ -365,6 +385,8 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
365
385
  };
366
386
  };
367
387
  const getSession = async (sessionKey) => {
388
+ while (reloadInProgress)
389
+ await reloadInProgress;
368
390
  const existing = sessions.get(sessionKey);
369
391
  if (existing)
370
392
  return existing;
@@ -393,15 +415,33 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
393
415
  session.agent.state.tools = createFamiliarTools(config, session.mediaSink, () => session.referenceAttachments, memoryService);
394
416
  session.agent.state.thinkingLevel = session.thinkingLevel;
395
417
  };
396
- const refreshSession = (session, sessionKey) => {
397
- const { model } = resolveChannelModel(sessionKey);
398
- const thinkingLevel = resolveChannelThinkingLevel(sessionKey, model).value;
399
- session.model = model;
400
- session.thinkingLevel = thinkingLevel;
401
- session.agent.state.systemPrompt = systemPrompt;
402
- session.agent.state.model = model;
403
- session.agent.state.thinkingLevel = thinkingLevel;
404
- session.agent.state.tools = createFamiliarTools(config, session.mediaSink, () => session.referenceAttachments, memoryService);
418
+ const prepareReload = async () => {
419
+ const nextConfig = (await options.reloadConfig?.()) ?? config;
420
+ const nextPersona = await loadPersona(nextConfig);
421
+ const nextSkillsResult = loadFamiliarSkills(nextConfig);
422
+ const nextSystemPrompt = buildSystemPrompt(nextPersona, formatFamiliarSkillsForPrompt(nextSkillsResult.skills));
423
+ const nextDefaultModel = createConfiguredModel(nextConfig);
424
+ getRequestApiKey(nextConfig, nextDefaultModel);
425
+ return {
426
+ config: nextConfig,
427
+ persona: nextPersona,
428
+ skillsResult: nextSkillsResult,
429
+ systemPrompt: nextSystemPrompt,
430
+ defaultModel: nextDefaultModel,
431
+ };
432
+ };
433
+ const prepareReloadedSessions = async (next) => {
434
+ return Promise.all([...sessions.entries()].map(async ([sessionKey, sessionPromise]) => {
435
+ const session = await sessionPromise;
436
+ const { model } = resolveChannelModelForConfig(next.config, next.defaultModel, sessionKey);
437
+ const thinkingLevel = resolveChannelThinkingLevelForConfig(next.config, sessionKey, model).value;
438
+ return {
439
+ session,
440
+ model,
441
+ thinkingLevel,
442
+ tools: createFamiliarTools(next.config, session.mediaSink, () => session.referenceAttachments, memoryService),
443
+ };
444
+ }));
405
445
  };
406
446
  return {
407
447
  abort(sessionKey) {
@@ -421,33 +461,45 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
421
461
  resetSession(session);
422
462
  },
423
463
  async reload() {
424
- const previousModel = formatModel(defaultModel);
425
- const nextConfig = await options.reloadConfig?.();
426
- if (nextConfig)
427
- Object.assign(config, nextConfig);
428
- persona = await loadPersona(config);
429
- skillsResult = loadFamiliarSkills(config);
430
- logSkillDiagnostics(skillsResult);
431
- systemPrompt = buildSystemPrompt(persona, formatFamiliarSkillsForPrompt(skillsResult.skills));
432
- defaultModel = createConfiguredModel(config);
433
- getRequestApiKey(config, defaultModel);
434
- const settledSessions = await Promise.allSettled([...sessions.entries()].map(async ([sessionKey, sessionPromise]) => {
435
- const session = await sessionPromise;
436
- refreshSession(session, sessionKey);
437
- return sessionKey;
438
- }));
439
- const refreshed = settledSessions.filter((result) => result.status === "fulfilled").length;
440
- const failed = settledSessions.length - refreshed;
441
- const modelLine = previousModel === formatModel(defaultModel)
442
- ? `default_model: ${previousModel}`
443
- : `default_model: ${previousModel} -> ${formatModel(defaultModel)}`;
444
- return [
445
- "Reloaded persona prompt, skills, and live agent settings.",
446
- modelLine,
447
- `skills: ${skillsResult.skills.length} loaded${skillsResult.diagnostics.length ? ` (${skillsResult.diagnostics.length} warnings)` : ""}`,
448
- `active_sessions: ${refreshed}${failed ? ` (${failed} failed)` : ""}`,
449
- "restart_required_for: Discord/Web listener settings, memory database paths, and long-lived memory internals",
450
- ].join("\n");
464
+ while (reloadInProgress)
465
+ await reloadInProgress;
466
+ let releaseReload;
467
+ reloadInProgress = new Promise((resolveReload) => {
468
+ releaseReload = resolveReload;
469
+ });
470
+ try {
471
+ const previousModel = formatModel(defaultModel);
472
+ const next = await prepareReload();
473
+ const reloadedSessions = await prepareReloadedSessions(next);
474
+ Object.assign(config, next.config);
475
+ persona = next.persona;
476
+ skillsResult = next.skillsResult;
477
+ logSkillDiagnostics(skillsResult);
478
+ systemPrompt = next.systemPrompt;
479
+ defaultModel = next.defaultModel;
480
+ for (const nextSession of reloadedSessions) {
481
+ nextSession.session.model = nextSession.model;
482
+ nextSession.session.thinkingLevel = nextSession.thinkingLevel;
483
+ nextSession.session.agent.state.systemPrompt = systemPrompt;
484
+ nextSession.session.agent.state.model = nextSession.model;
485
+ nextSession.session.agent.state.thinkingLevel = nextSession.thinkingLevel;
486
+ nextSession.session.agent.state.tools = nextSession.tools;
487
+ }
488
+ const modelLine = previousModel === formatModel(defaultModel)
489
+ ? `default_model: ${previousModel}`
490
+ : `default_model: ${previousModel} -> ${formatModel(defaultModel)}`;
491
+ return [
492
+ "Reloaded persona prompt, skills, and live agent settings.",
493
+ modelLine,
494
+ `skills: ${skillsResult.skills.length} loaded${skillsResult.diagnostics.length ? ` (${skillsResult.diagnostics.length} warnings)` : ""}`,
495
+ `active_sessions: ${reloadedSessions.length}`,
496
+ "restart_required_for: Discord/Web listener settings, memory database paths, and long-lived memory internals",
497
+ ].join("\n");
498
+ }
499
+ finally {
500
+ releaseReload?.();
501
+ reloadInProgress = undefined;
502
+ }
451
503
  },
452
504
  resolveChannelModel,
453
505
  getModel(sessionKey) {
@@ -168,23 +168,11 @@ function stringArg(value) {
168
168
  const text = String(value).trim();
169
169
  return text ? text : undefined;
170
170
  }
171
- function boolArg(value) {
172
- if (typeof value === "boolean")
173
- return value ? "true" : "false";
174
- if (typeof value === "string" && value.trim())
175
- return value.trim();
176
- return undefined;
177
- }
178
171
  function pushOptionalFlag(args, flag, value) {
179
172
  const read = stringArg(value);
180
173
  if (read !== undefined)
181
174
  args.push(flag, read);
182
175
  }
183
- function pushOptionalBoolFlag(args, flag, value) {
184
- const read = boolArg(value);
185
- if (read !== undefined)
186
- args.push(flag, read);
187
- }
188
176
  function normalizeAction(action) {
189
177
  const value = stringArg(action);
190
178
  if (!value || !PAGE_ACTIONS.includes(value)) {
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from "node:fs";
3
- import { copyFile, mkdir } from "node:fs/promises";
3
+ import { copyFile, cp, mkdir } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -10,14 +10,17 @@ import { loadConfig } from "./config.js";
10
10
  import { runDataRetention } from "./data-retention.js";
11
11
  import { startDiscordDaemon } from "./discord.js";
12
12
  import { cleanupGeneratedAttachments } from "./generated-media.js";
13
+ import { startWorkspaceHotReload } from "./hot-reload.js";
13
14
  import { memoryHelp, runMemoryOperator } from "./memory/operator.js";
14
15
  import { createMemoryService } from "./memory/service.js";
16
+ import { formatServiceResult, installService, serviceStatus, uninstallService, upgradeFamiliar } from "./service.js";
15
17
  import { loadSettingsStore } from "./settings.js";
16
18
  import { startWebDaemon } from "./web.js";
17
19
  const SOURCE_DIR = dirname(fileURLToPath(import.meta.url));
18
20
  const PROJECT_ROOT = resolve(SOURCE_DIR, "..");
19
21
  const DEFAULT_WORKSPACE_PATH = resolve(homedir(), ".familiar");
20
22
  const MEMORY_SUBCOMMANDS = new Set(["status", "doctor", "reindex", "backfill", "prune", "backup", "help", "--help"]);
23
+ const RESTART_EXIT_DELAY_MS = 1500;
21
24
  function defaultWorkspaceDirs(workspacePath) {
22
25
  const memoryRoot = resolve(workspacePath, "memories");
23
26
  return {
@@ -46,6 +49,20 @@ async function ensureWorkspaceDirs(dirs) {
46
49
  mkdir(dirs.memoryArchiveDir, { recursive: true }),
47
50
  ]);
48
51
  }
52
+ async function copyDefaultSkills(workspacePath) {
53
+ const sourcePath = resolve(PROJECT_ROOT, "skills");
54
+ if (!existsSync(sourcePath))
55
+ return;
56
+ await cp(sourcePath, resolve(workspacePath, "skills"), {
57
+ recursive: true,
58
+ force: false,
59
+ });
60
+ }
61
+ async function copyIfMissing(sourcePath, targetPath) {
62
+ if (existsSync(targetPath))
63
+ return;
64
+ await copyFile(sourcePath, targetPath);
65
+ }
49
66
  function resolveWorkspaceInput(workspaceInput) {
50
67
  return workspaceInput ? resolve(workspaceInput) : DEFAULT_WORKSPACE_PATH;
51
68
  }
@@ -62,15 +79,13 @@ function isMemoryHelp(args) {
62
79
  async function initWorkspace(workspaceInput) {
63
80
  const workspacePath = resolveWorkspaceInput(workspaceInput);
64
81
  await mkdir(workspacePath, { recursive: true });
65
- const envPath = resolve(workspacePath, ".env");
66
- if (!existsSync(envPath)) {
67
- await copyFile(resolve(PROJECT_ROOT, ".env.example"), envPath);
68
- }
69
- await copyFile(resolve(PROJECT_ROOT, "config.example.toml"), resolve(workspacePath, "config.toml"));
70
- await copyFile(resolve(PROJECT_ROOT, "SOUL.md"), resolve(workspacePath, "SOUL.md"));
71
- await copyFile(resolve(PROJECT_ROOT, "USER.md"), resolve(workspacePath, "USER.md"));
72
- await copyFile(resolve(PROJECT_ROOT, "MEMORY.md"), resolve(workspacePath, "MEMORY.md"));
73
- await copyFile(resolve(PROJECT_ROOT, "HEARTBEAT.md"), resolve(workspacePath, "HEARTBEAT.md"));
82
+ await copyIfMissing(resolve(PROJECT_ROOT, ".env.example"), resolve(workspacePath, ".env"));
83
+ await copyIfMissing(resolve(PROJECT_ROOT, "config.example.toml"), resolve(workspacePath, "config.toml"));
84
+ await copyIfMissing(resolve(PROJECT_ROOT, "SOUL.md"), resolve(workspacePath, "SOUL.md"));
85
+ await copyIfMissing(resolve(PROJECT_ROOT, "USER.md"), resolve(workspacePath, "USER.md"));
86
+ await copyIfMissing(resolve(PROJECT_ROOT, "MEMORY.md"), resolve(workspacePath, "MEMORY.md"));
87
+ await copyIfMissing(resolve(PROJECT_ROOT, "HEARTBEAT.md"), resolve(workspacePath, "HEARTBEAT.md"));
88
+ await copyDefaultSkills(workspacePath);
74
89
  await ensureWorkspaceDirs(defaultWorkspaceDirs(workspacePath));
75
90
  console.log(`Initialized familiar workspace at ${workspacePath}`);
76
91
  }
@@ -102,19 +117,34 @@ async function runDaemon(workspaceInput) {
102
117
  await memoryService.indexDiaries().catch((error) => console.error("initial diary indexing failed", error));
103
118
  memoryService.watchDiaries();
104
119
  const familiarAgent = await createFamiliarAgent(config, settings, memoryService, { reloadConfig });
105
- const discordDaemon = await startDiscordDaemon(config, familiarAgent, settings, memoryService);
106
- const webDaemon = await startWebDaemon(config, familiarAgent, discordDaemon);
107
- console.log(`familiar running for workspace ${config.workspacePath}`);
108
- console.log("agent sessions are created per channel");
109
- console.log(`settings=${settings.path}`);
110
- const stop = async () => {
120
+ const hotReload = startWorkspaceHotReload({ workspacePath: config.workspacePath, familiarAgent });
121
+ let stopping = false;
122
+ let discordDaemon;
123
+ let webDaemon;
124
+ const stop = async (exitCode = 0) => {
125
+ if (stopping)
126
+ return;
127
+ stopping = true;
111
128
  console.log("Stopping familiar");
112
- await Promise.all([webDaemon.stop(), discordDaemon.stop()]);
129
+ hotReload.close();
130
+ await Promise.all([webDaemon?.stop(), discordDaemon?.stop()]);
113
131
  memoryService.close();
114
- process.exit(0);
132
+ process.exit(exitCode);
115
133
  };
116
- process.once("SIGINT", () => void stop());
117
- process.once("SIGTERM", () => void stop());
134
+ const requestRestart = () => {
135
+ console.log("Restart requested");
136
+ setTimeout(() => void stop(75), RESTART_EXIT_DELAY_MS);
137
+ return "Restart requested. If Familiar is managed by launchd/systemd, it should come back automatically; otherwise run familiar run again.";
138
+ };
139
+ discordDaemon = await startDiscordDaemon(config, familiarAgent, settings, memoryService, {
140
+ restart: requestRestart,
141
+ });
142
+ webDaemon = await startWebDaemon(config, familiarAgent, discordDaemon, { restart: requestRestart });
143
+ console.log(`familiar running for workspace ${config.workspacePath}`);
144
+ console.log("agent sessions are created per channel");
145
+ console.log(`settings=${settings.path}`);
146
+ process.once("SIGINT", () => void stop(0));
147
+ process.once("SIGTERM", () => void stop(0));
118
148
  await new Promise(() => { });
119
149
  }
120
150
  function usage() {
@@ -123,8 +153,9 @@ function usage() {
123
153
  " familiar init [workspace]",
124
154
  " familiar run [workspace]",
125
155
  " familiar memory [workspace] <subcommand>",
126
- " familiar install-service",
127
- " familiar status",
156
+ " familiar install-service [workspace]",
157
+ " familiar uninstall-service [workspace]",
158
+ " familiar status [workspace]",
128
159
  " familiar upgrade",
129
160
  "",
130
161
  `Default workspace: ${DEFAULT_WORKSPACE_PATH}`,
@@ -155,8 +186,21 @@ async function main() {
155
186
  await runMemoryOperator(config, args);
156
187
  return;
157
188
  }
158
- if (command === "install-service" || command === "status" || command === "upgrade") {
159
- console.log("not yet implemented");
189
+ if (command === "install-service") {
190
+ console.log(formatServiceResult(await installService(resolveWorkspaceInput(workspace))));
191
+ return;
192
+ }
193
+ if (command === "uninstall-service") {
194
+ console.log(formatServiceResult(await uninstallService(resolveWorkspaceInput(workspace))));
195
+ return;
196
+ }
197
+ if (command === "status") {
198
+ console.log(formatServiceResult(await serviceStatus(resolveWorkspaceInput(workspace))));
199
+ return;
200
+ }
201
+ if (command === "upgrade") {
202
+ console.log("Upgrading @qearlyao/familiar globally...");
203
+ await upgradeFamiliar();
160
204
  return;
161
205
  }
162
206
  console.error(usage());
@@ -0,0 +1 @@
1
+ export {};
package/dist/discord.js CHANGED
@@ -79,6 +79,11 @@ function getFamiliarApplicationCommand() {
79
79
  description: "Reload persona prompt files and live agent settings",
80
80
  type: ApplicationCommandOptionType.Subcommand,
81
81
  },
82
+ {
83
+ name: "restart",
84
+ description: "Restart Familiar if this runtime has a restart handler",
85
+ type: ApplicationCommandOptionType.Subcommand,
86
+ },
82
87
  {
83
88
  name: "compact",
84
89
  description: "Show compaction status",
@@ -510,7 +515,7 @@ function formatCommandResponse(command, runtime, familiarAgent, channelTrigger)
510
515
  return "Compact is not wired for this runtime yet. I logged the command, but I won't run lossy compaction here.";
511
516
  }
512
517
  async function applyControlCommand(options) {
513
- const { control, runtime, familiarAgent, settings, channelTrigger, isDm, activeAgentOwner } = options;
518
+ const { control, runtime, familiarAgent, settings, channelTrigger, isDm, activeAgentOwner, restart } = options;
514
519
  if (control.command === "stop") {
515
520
  if (runtime.hasActiveJob() && activeAgentOwner === runtime.channelKey)
516
521
  familiarAgent.abort(runtime.channelKey);
@@ -525,6 +530,11 @@ async function applyControlCommand(options) {
525
530
  if (control.command === "reload") {
526
531
  return familiarAgent.reload();
527
532
  }
533
+ if (control.command === "restart") {
534
+ return restart
535
+ ? await restart()
536
+ : "Restart requested, but no restart handler is configured. Please restart the Familiar process manually.";
537
+ }
528
538
  if (control.command === "model") {
529
539
  return control.args
530
540
  ? await familiarAgent.setModel(runtime.channelKey, control.args)
@@ -603,7 +613,7 @@ function startTypingIndicator(message) {
603
613
  clearInterval(timer);
604
614
  };
605
615
  }
606
- export async function startDiscordDaemon(config, familiarAgent, settings, memoryService) {
616
+ export async function startDiscordDaemon(config, familiarAgent, settings, memoryService, options = {}) {
607
617
  const client = await withReadyClient(config.discord.token);
608
618
  console.log(`Discord connected as ${client.user.tag}`);
609
619
  const runtimes = new Map();
@@ -1063,6 +1073,7 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
1063
1073
  channelTrigger,
1064
1074
  isDm,
1065
1075
  activeAgentOwner,
1076
+ restart: options.restart,
1066
1077
  });
1067
1078
  const messageIds = await sendReply(config, message, text);
1068
1079
  await runtime.noteOutbound({ text, messageIds, control: control.command });
@@ -1140,6 +1151,7 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
1140
1151
  channelTrigger,
1141
1152
  isDm,
1142
1153
  activeAgentOwner,
1154
+ restart: options.restart,
1143
1155
  });
1144
1156
  const messageIds = await replyEphemeral(interaction, text);
1145
1157
  await runtime.noteOutbound({ text, messageIds, control: control.command });
@@ -0,0 +1,132 @@
1
+ import { watch } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { basename, relative, resolve, sep } from "node:path";
4
+ const ROOT_FILES = new Set(["config.toml", ".env", "SOUL.md", "USER.md", "MEMORY.md", "INNER.md", "HEARTBEAT.md"]);
5
+ const SKILLS_DIR = "skills";
6
+ function isEnoent(error) {
7
+ return !!error && typeof error === "object" && error.code === "ENOENT";
8
+ }
9
+ function shouldReloadForPath(workspacePath, changedPath) {
10
+ const relativePath = relative(workspacePath, resolve(changedPath));
11
+ if (!relativePath || relativePath.startsWith("..") || relativePath.split(sep).includes(".."))
12
+ return false;
13
+ if (ROOT_FILES.has(relativePath))
14
+ return true;
15
+ return relativePath === SKILLS_DIR || relativePath.startsWith(`${SKILLS_DIR}${sep}`);
16
+ }
17
+ function isAtOrInsidePath(parentPath, childPath) {
18
+ const relativePath = relative(parentPath, childPath);
19
+ return !relativePath || (!relativePath.startsWith("..") && !relativePath.split(sep).includes(".."));
20
+ }
21
+ async function listSkillDirectories(skillsPath) {
22
+ try {
23
+ const entries = await readdir(skillsPath, { withFileTypes: true });
24
+ const directories = [];
25
+ for (const entry of entries) {
26
+ if (!entry.isDirectory())
27
+ continue;
28
+ const path = resolve(skillsPath, entry.name);
29
+ directories.push(path, ...(await listSkillDirectories(path)));
30
+ }
31
+ return directories;
32
+ }
33
+ catch (error) {
34
+ if (isEnoent(error))
35
+ return [];
36
+ throw error;
37
+ }
38
+ }
39
+ export function startWorkspaceHotReload(options) {
40
+ const workspacePath = resolve(options.workspacePath);
41
+ const debounceMs = options.debounceMs ?? 750;
42
+ const logger = options.logger ?? console;
43
+ const watchFn = options.watch ?? watch;
44
+ const listSkills = options.listSkillDirectories ?? listSkillDirectories;
45
+ const watchers = new Map();
46
+ let debounce;
47
+ let reloadQueue = Promise.resolve();
48
+ let closed = false;
49
+ const closeWatcher = (path) => {
50
+ const watcher = watchers.get(path);
51
+ if (!watcher)
52
+ return;
53
+ watcher.close();
54
+ watchers.delete(path);
55
+ };
56
+ const close = () => {
57
+ closed = true;
58
+ if (debounce)
59
+ clearTimeout(debounce);
60
+ debounce = undefined;
61
+ for (const watcher of watchers.values())
62
+ watcher.close();
63
+ watchers.clear();
64
+ };
65
+ const scheduleReload = (reason) => {
66
+ if (closed)
67
+ return;
68
+ if (debounce)
69
+ clearTimeout(debounce);
70
+ debounce = setTimeout(() => {
71
+ debounce = undefined;
72
+ reloadQueue = reloadQueue.then(async () => {
73
+ try {
74
+ const result = await options.familiarAgent.reload();
75
+ logger.info(`hot reload complete after ${reason}\n${result}`);
76
+ }
77
+ catch (error) {
78
+ logger.error("hot reload failed", error);
79
+ }
80
+ });
81
+ }, debounceMs);
82
+ };
83
+ const watchDirectory = (path) => {
84
+ const dirPath = resolve(path);
85
+ if (closed || watchers.has(dirPath))
86
+ return;
87
+ try {
88
+ const watcher = watchFn(dirPath, { persistent: true }, (eventType, filename) => {
89
+ const changedPath = filename ? resolve(dirPath, String(filename)) : dirPath;
90
+ if (eventType === "rename" &&
91
+ (basename(dirPath) === SKILLS_DIR || relative(workspacePath, dirPath).startsWith(`${SKILLS_DIR}${sep}`))) {
92
+ void refreshSkillWatchers();
93
+ }
94
+ if (!filename ||
95
+ shouldReloadForPath(workspacePath, changedPath) ||
96
+ shouldReloadForPath(workspacePath, dirPath)) {
97
+ scheduleReload(relative(workspacePath, changedPath) || relative(workspacePath, dirPath) || ".");
98
+ }
99
+ });
100
+ watcher.on("error", (error) => {
101
+ logger.warn(`hot reload watcher failed for ${dirPath}`, error);
102
+ closeWatcher(dirPath);
103
+ });
104
+ watchers.set(dirPath, watcher);
105
+ }
106
+ catch (error) {
107
+ if (isEnoent(error))
108
+ return;
109
+ logger.warn(`hot reload watcher could not watch ${dirPath}`, error);
110
+ }
111
+ };
112
+ const refreshSkillWatchers = async () => {
113
+ if (closed)
114
+ return;
115
+ const skillsPath = resolve(workspacePath, SKILLS_DIR);
116
+ const wanted = new Set([skillsPath, ...(await listSkills(skillsPath))]);
117
+ for (const path of wanted)
118
+ watchDirectory(path);
119
+ for (const path of watchers.keys()) {
120
+ if (path !== workspacePath && isAtOrInsidePath(skillsPath, path) && !wanted.has(path)) {
121
+ closeWatcher(path);
122
+ }
123
+ }
124
+ };
125
+ watchDirectory(workspacePath);
126
+ void refreshSkillWatchers().catch((error) => logger.warn("hot reload skill watcher setup failed", error));
127
+ return { close };
128
+ }
129
+ export const __hotReloadTest = {
130
+ shouldReloadForPath,
131
+ listSkillDirectories,
132
+ };
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { createFamiliarAgent } from "./agent.js";
2
2
  export { buildRecordBase, chatChannelKey, chatLogPath, createChatLog, } from "./chat-log.js";
3
3
  export { loadConfig } from "./config.js";
4
4
  export { startDiscordDaemon } from "./discord.js";
5
+ export { startWorkspaceHotReload } from "./hot-reload.js";
5
6
  export { clampConfiguredThinkingLevel, createConfiguredModel, describeModelAuth, formatAllowedModels, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
6
7
  export { buildSystemPrompt, loadPersona } from "./persona.js";
7
8
  export { ConversationRuntime, } from "./runtime.js";