@qearlyao/familiar 0.1.0 → 0.1.1

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,29 @@ 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
+ `~/.familiar` when no workspace exists yet.
34
+
35
+ Manual npm install:
21
36
 
22
37
  ```sh
23
38
  npm install -g @qearlyao/familiar@latest
@@ -27,11 +42,14 @@ From a source checkout:
27
42
 
28
43
  ```sh
29
44
  npm install
45
+ npm --prefix web install
30
46
  npm run build
31
47
  ```
32
48
 
33
49
  ## Initialize A Workspace
34
50
 
51
+ Skip this step if you used the installer and accepted the default workspace.
52
+
35
53
  ```sh
36
54
  familiar init
37
55
  ```
@@ -53,6 +71,7 @@ node dist/cli.js init
53
71
  - `HEARTBEAT.md`
54
72
  - `data/`
55
73
  - `memories/`
74
+ - `skills/`
56
75
 
57
76
  You can choose another workspace:
58
77
 
@@ -113,17 +132,50 @@ The WebUI listens on the configured `[web]` port and bind address. The default
113
132
  `tailscale-only` auth mode currently means "trust the network boundary"; it does
114
133
  not verify Tailscale identity yet.
115
134
 
135
+ ## Service Management
136
+
137
+ macOS and Linux users can install a user-level service after configuring the
138
+ workspace:
139
+
140
+ ```sh
141
+ familiar install-service
142
+ familiar status
143
+ familiar uninstall-service
144
+ ```
145
+
146
+ macOS uses `launchd`; Linux uses user `systemd`. Windows users should run
147
+ `familiar run` in a foreground terminal for now. Service logs are written under
148
+ `<workspace>/logs`.
149
+
150
+ Upgrade the global npm package with:
151
+
152
+ ```sh
153
+ familiar upgrade
154
+ ```
155
+
116
156
  ## Optional Browser Backends
117
157
 
118
158
  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`.
159
+ CLIs from their upstream repositories and enable `[browser].enabled = true` in
160
+ `config.toml`.
161
+
162
+ To install Familiar plus the optional browser helpers:
120
163
 
121
164
  ```sh
122
- npm install -g @jackwener/opencli browser-harness
165
+ curl -fsSL https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.sh | sh -s -- --with-browser
166
+ ```
167
+
168
+ Windows PowerShell:
169
+
170
+ ```powershell
171
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/qearlyao/familiar/main/scripts/install.ps1))) -WithBrowser
123
172
  ```
124
173
 
174
+ - `--with-browser` installs OpenCLI with npm and browser-harness from its upstream repo with `uv`; it requires `git`, `uv`, and Python 3.11+.
125
175
  - `browser-harness` is best for attaching to your already-running Chrome via CDP.
126
176
  - OpenCLI is best for site adapters, owned sessions, and unattended Browser Bridge flows.
177
+ - OpenCLI: [jackwener/OpenCLI](https://github.com/jackwener/OpenCLI)
178
+ - browser-harness: [browser-use/browser-harness](https://github.com/browser-use/browser-harness)
127
179
 
128
180
  Familiar stores browser screenshots under the active workspace data directory:
129
181
  `<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;
@@ -403,6 +425,34 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
403
425
  session.agent.state.thinkingLevel = thinkingLevel;
404
426
  session.agent.state.tools = createFamiliarTools(config, session.mediaSink, () => session.referenceAttachments, memoryService);
405
427
  };
428
+ const prepareReload = async () => {
429
+ const nextConfig = (await options.reloadConfig?.()) ?? config;
430
+ const nextPersona = await loadPersona(nextConfig);
431
+ const nextSkillsResult = loadFamiliarSkills(nextConfig);
432
+ const nextSystemPrompt = buildSystemPrompt(nextPersona, formatFamiliarSkillsForPrompt(nextSkillsResult.skills));
433
+ const nextDefaultModel = createConfiguredModel(nextConfig);
434
+ getRequestApiKey(nextConfig, nextDefaultModel);
435
+ return {
436
+ config: nextConfig,
437
+ persona: nextPersona,
438
+ skillsResult: nextSkillsResult,
439
+ systemPrompt: nextSystemPrompt,
440
+ defaultModel: nextDefaultModel,
441
+ };
442
+ };
443
+ const prepareReloadedSessions = async (next) => {
444
+ return Promise.all([...sessions.entries()].map(async ([sessionKey, sessionPromise]) => {
445
+ const session = await sessionPromise;
446
+ const { model } = resolveChannelModelForConfig(next.config, next.defaultModel, sessionKey);
447
+ const thinkingLevel = resolveChannelThinkingLevelForConfig(next.config, sessionKey, model).value;
448
+ return {
449
+ session,
450
+ model,
451
+ thinkingLevel,
452
+ tools: createFamiliarTools(next.config, session.mediaSink, () => session.referenceAttachments, memoryService),
453
+ };
454
+ }));
455
+ };
406
456
  return {
407
457
  abort(sessionKey) {
408
458
  const session = sessions.get(sessionKey);
@@ -421,33 +471,45 @@ export async function createFamiliarAgent(config, settings, memoryService, optio
421
471
  resetSession(session);
422
472
  },
423
473
  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");
474
+ while (reloadInProgress)
475
+ await reloadInProgress;
476
+ let releaseReload;
477
+ reloadInProgress = new Promise((resolveReload) => {
478
+ releaseReload = resolveReload;
479
+ });
480
+ try {
481
+ const previousModel = formatModel(defaultModel);
482
+ const next = await prepareReload();
483
+ const reloadedSessions = await prepareReloadedSessions(next);
484
+ Object.assign(config, next.config);
485
+ persona = next.persona;
486
+ skillsResult = next.skillsResult;
487
+ logSkillDiagnostics(skillsResult);
488
+ systemPrompt = next.systemPrompt;
489
+ defaultModel = next.defaultModel;
490
+ for (const nextSession of reloadedSessions) {
491
+ nextSession.session.model = nextSession.model;
492
+ nextSession.session.thinkingLevel = nextSession.thinkingLevel;
493
+ nextSession.session.agent.state.systemPrompt = systemPrompt;
494
+ nextSession.session.agent.state.model = nextSession.model;
495
+ nextSession.session.agent.state.thinkingLevel = nextSession.thinkingLevel;
496
+ nextSession.session.agent.state.tools = nextSession.tools;
497
+ }
498
+ const modelLine = previousModel === formatModel(defaultModel)
499
+ ? `default_model: ${previousModel}`
500
+ : `default_model: ${previousModel} -> ${formatModel(defaultModel)}`;
501
+ return [
502
+ "Reloaded persona prompt, skills, and live agent settings.",
503
+ modelLine,
504
+ `skills: ${skillsResult.skills.length} loaded${skillsResult.diagnostics.length ? ` (${skillsResult.diagnostics.length} warnings)` : ""}`,
505
+ `active_sessions: ${reloadedSessions.length}`,
506
+ "restart_required_for: Discord/Web listener settings, memory database paths, and long-lived memory internals",
507
+ ].join("\n");
508
+ }
509
+ finally {
510
+ releaseReload?.();
511
+ reloadInProgress = undefined;
512
+ }
451
513
  },
452
514
  resolveChannelModel,
453
515
  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,32 @@ 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, { restart: requestRestart });
140
+ webDaemon = await startWebDaemon(config, familiarAgent, discordDaemon, { restart: requestRestart });
141
+ console.log(`familiar running for workspace ${config.workspacePath}`);
142
+ console.log("agent sessions are created per channel");
143
+ console.log(`settings=${settings.path}`);
144
+ process.once("SIGINT", () => void stop(0));
145
+ process.once("SIGTERM", () => void stop(0));
118
146
  await new Promise(() => { });
119
147
  }
120
148
  function usage() {
@@ -123,8 +151,9 @@ function usage() {
123
151
  " familiar init [workspace]",
124
152
  " familiar run [workspace]",
125
153
  " familiar memory [workspace] <subcommand>",
126
- " familiar install-service",
127
- " familiar status",
154
+ " familiar install-service [workspace]",
155
+ " familiar uninstall-service [workspace]",
156
+ " familiar status [workspace]",
128
157
  " familiar upgrade",
129
158
  "",
130
159
  `Default workspace: ${DEFAULT_WORKSPACE_PATH}`,
@@ -155,8 +184,21 @@ async function main() {
155
184
  await runMemoryOperator(config, args);
156
185
  return;
157
186
  }
158
- if (command === "install-service" || command === "status" || command === "upgrade") {
159
- console.log("not yet implemented");
187
+ if (command === "install-service") {
188
+ console.log(formatServiceResult(await installService(resolveWorkspaceInput(workspace))));
189
+ return;
190
+ }
191
+ if (command === "uninstall-service") {
192
+ console.log(formatServiceResult(await uninstallService(resolveWorkspaceInput(workspace))));
193
+ return;
194
+ }
195
+ if (command === "status") {
196
+ console.log(formatServiceResult(await serviceStatus(resolveWorkspaceInput(workspace))));
197
+ return;
198
+ }
199
+ if (command === "upgrade") {
200
+ console.log("Upgrading @qearlyao/familiar globally...");
201
+ await upgradeFamiliar();
160
202
  return;
161
203
  }
162
204
  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,130 @@
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 || shouldReloadForPath(workspacePath, changedPath) || shouldReloadForPath(workspacePath, dirPath)) {
95
+ scheduleReload(relative(workspacePath, changedPath) || relative(workspacePath, dirPath) || ".");
96
+ }
97
+ });
98
+ watcher.on("error", (error) => {
99
+ logger.warn(`hot reload watcher failed for ${dirPath}`, error);
100
+ closeWatcher(dirPath);
101
+ });
102
+ watchers.set(dirPath, watcher);
103
+ }
104
+ catch (error) {
105
+ if (isEnoent(error))
106
+ return;
107
+ logger.warn(`hot reload watcher could not watch ${dirPath}`, error);
108
+ }
109
+ };
110
+ const refreshSkillWatchers = async () => {
111
+ if (closed)
112
+ return;
113
+ const skillsPath = resolve(workspacePath, SKILLS_DIR);
114
+ const wanted = new Set([skillsPath, ...(await listSkills(skillsPath))]);
115
+ for (const path of wanted)
116
+ watchDirectory(path);
117
+ for (const path of watchers.keys()) {
118
+ if (path !== workspacePath && isAtOrInsidePath(skillsPath, path) && !wanted.has(path)) {
119
+ closeWatcher(path);
120
+ }
121
+ }
122
+ };
123
+ watchDirectory(workspacePath);
124
+ void refreshSkillWatchers().catch((error) => logger.warn("hot reload skill watcher setup failed", error));
125
+ return { close };
126
+ }
127
+ export const __hotReloadTest = {
128
+ shouldReloadForPath,
129
+ listSkillDirectories,
130
+ };
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";
package/dist/runtime.js CHANGED
@@ -223,7 +223,7 @@ export class ConversationRuntime {
223
223
  return undefined;
224
224
  const [rawCommand = "", ...argParts] = normalized.split(" ");
225
225
  const command = rawCommand.replace(/^\//, "").toLowerCase();
226
- if (!["stop", "status", "new", "reload", "compact", "model", "thinking", "channel-trigger"].includes(command)) {
226
+ if (!["stop", "status", "new", "reload", "restart", "compact", "model", "thinking", "channel-trigger"].includes(command)) {
227
227
  return undefined;
228
228
  }
229
229
  return {
package/dist/scheduler.js CHANGED
@@ -193,7 +193,9 @@ export function isHeartbeatDue(options) {
193
193
  if (idleDurationMs < options.idleThresholdMs)
194
194
  return false;
195
195
  const lastHeartbeatAt = options.lastHeartbeatAt ? Date.parse(options.lastHeartbeatAt) : undefined;
196
- if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt) || lastHeartbeatAt <= options.lastUserInteractionAt) {
196
+ if (lastHeartbeatAt == null ||
197
+ !Number.isFinite(lastHeartbeatAt) ||
198
+ lastHeartbeatAt <= options.lastUserInteractionAt) {
197
199
  return true;
198
200
  }
199
201
  return options.now - lastHeartbeatAt >= Math.max(0, options.intervalMs);