@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 +1 -1
- package/README.md +56 -4
- package/dist/agent.js +89 -27
- package/dist/browser-tools.js +0 -12
- package/dist/cli.js +66 -24
- package/dist/control.js +1 -0
- package/dist/discord.js +14 -2
- package/dist/hot-reload.js +130 -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 +132 -0
- package/scripts/install.sh +152 -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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
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,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
|
|
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, { 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
|
|
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"
|
|
159
|
-
console.log(
|
|
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());
|
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,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 ||
|
|
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);
|