@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 +1 -1
- package/README.md +63 -4
- package/dist/agent.js +88 -36
- package/dist/browser-tools.js +0 -12
- package/dist/cli.js +68 -24
- package/dist/control.js +1 -0
- package/dist/discord.js +14 -2
- package/dist/hot-reload.js +132 -0
- package/dist/index.js +1 -0
- package/dist/runtime.js +1 -1
- package/dist/scheduler.js +3 -1
- package/dist/service.js +284 -0
- package/dist/web-auth.js +5 -2
- package/dist/web.js +6 -1
- package/package.json +9 -5
- package/scripts/install.ps1 +185 -0
- package/scripts/install.sh +226 -0
- package/skills/image-gen/SKILL.md +36 -0
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).
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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) {
|
package/dist/browser-tools.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
await
|
|
70
|
-
await
|
|
71
|
-
await
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
129
|
+
hotReload.close();
|
|
130
|
+
await Promise.all([webDaemon?.stop(), discordDaemon?.stop()]);
|
|
113
131
|
memoryService.close();
|
|
114
|
-
process.exit(
|
|
132
|
+
process.exit(exitCode);
|
|
115
133
|
};
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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"
|
|
159
|
-
console.log(
|
|
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());
|
package/dist/control.js
ADDED
|
@@ -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";
|