@qearlyao/familiar 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -199,7 +199,8 @@ familiar uninstall-service
199
199
  macOS uses `launchd`; Linux uses user `systemd`. Windows users should run
200
200
  `familiar run` in a foreground terminal for now. The short
201
201
  `start`/`stop`/`restart` commands control the installed user service. Service
202
- logs are written under `<workspace>/logs`.
202
+ logs are written under `<workspace>/logs`; service installs configure weekly log
203
+ rotation on macOS and on Linux when `logrotate` is available.
203
204
 
204
205
  Upgrade the global npm package and append missing workspace defaults with:
205
206
 
@@ -26,6 +26,9 @@ export function parseContactNickname(raw, fallback) {
26
26
  export async function refreshContactNote() {
27
27
  cachedNickname = parseContactNickname(await loadContactNote(), "");
28
28
  }
29
+ export function applyContactNoteContent(raw) {
30
+ cachedNickname = parseContactNickname(raw, "");
31
+ }
29
32
  export function getContactNickname(fallback) {
30
33
  return cachedNickname || fallback;
31
34
  }
@@ -6,16 +6,26 @@ import { dirname, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  const execFileAsync = promisify(execFile);
8
8
  const SERVICE_LABEL = "com.qearlyao.familiar";
9
+ const LOG_ROTATION_LABEL = `${SERVICE_LABEL}.log-rotation`;
9
10
  const SYSTEMD_SERVICE = "familiar.service";
11
+ const SYSTEMD_LOGROTATE_SERVICE = "familiar-logrotate.service";
12
+ const SYSTEMD_LOGROTATE_TIMER = "familiar-logrotate.timer";
10
13
  function servicePaths(workspacePath, input) {
11
14
  const logDir = input.resolvePath(workspacePath, "logs");
15
+ const systemdUserDir = input.resolvePath(input.homeDir, ".config", "systemd", "user");
12
16
  return {
13
17
  servicePath: input.platform === "darwin"
14
18
  ? input.resolvePath(input.homeDir, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`)
15
- : input.resolvePath(input.homeDir, ".config", "systemd", "user", SYSTEMD_SERVICE),
19
+ : input.resolvePath(systemdUserDir, SYSTEMD_SERVICE),
16
20
  logDir,
17
21
  stdoutPath: input.resolvePath(logDir, "familiar.out.log"),
18
22
  stderrPath: input.resolvePath(logDir, "familiar.err.log"),
23
+ logrotateConfigPath: input.resolvePath(logDir, "familiar.logrotate.conf"),
24
+ logrotateStatePath: input.resolvePath(logDir, "familiar.logrotate.state"),
25
+ logRotationScriptPath: input.resolvePath(logDir, "familiar-rotate-logs.sh"),
26
+ logRotationLaunchdPath: input.resolvePath(input.homeDir, "Library", "LaunchAgents", `${LOG_ROTATION_LABEL}.plist`),
27
+ logrotateServicePath: input.resolvePath(systemdUserDir, SYSTEMD_LOGROTATE_SERVICE),
28
+ logrotateTimerPath: input.resolvePath(systemdUserDir, SYSTEMD_LOGROTATE_TIMER),
19
29
  };
20
30
  }
21
31
  function buildSpec(workspacePath, options = {}) {
@@ -60,6 +70,12 @@ function systemdQuote(value) {
60
70
  return value;
61
71
  return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
62
72
  }
73
+ function shellQuote(value) {
74
+ return `'${value.replaceAll("'", "'\\''")}'`;
75
+ }
76
+ function logrotateQuote(value) {
77
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
78
+ }
63
79
  function launchdPlist(spec) {
64
80
  const args = [spec.nodePath, spec.cliPath, "run", spec.workspacePath]
65
81
  .map((value) => `\t\t<string>${xmlEscape(value)}</string>`)
@@ -97,6 +113,26 @@ ${args}
97
113
  </plist>
98
114
  `;
99
115
  }
116
+ function launchdLogRotationPlist(spec) {
117
+ const args = ["/bin/sh", spec.paths.logRotationScriptPath, spec.paths.stdoutPath, spec.paths.stderrPath]
118
+ .map((value) => `\t\t<string>${xmlEscape(value)}</string>`)
119
+ .join("\n");
120
+ return `<?xml version="1.0" encoding="UTF-8"?>
121
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
122
+ <plist version="1.0">
123
+ <dict>
124
+ \t<key>Label</key>
125
+ \t<string>${LOG_ROTATION_LABEL}</string>
126
+ \t<key>ProgramArguments</key>
127
+ \t<array>
128
+ ${args}
129
+ \t</array>
130
+ \t<key>StartInterval</key>
131
+ \t<integer>604800</integer>
132
+ </dict>
133
+ </plist>
134
+ `;
135
+ }
100
136
  function systemdUnit(spec) {
101
137
  const execStart = [spec.nodePath, spec.cliPath, "run", spec.workspacePath].map(systemdQuote).join(" ");
102
138
  return `[Unit]
@@ -122,6 +158,62 @@ StandardError=append:${spec.paths.stderrPath}
122
158
  WantedBy=default.target
123
159
  `;
124
160
  }
161
+ function logRotationScript() {
162
+ return `set -eu
163
+
164
+ for log_path in "$@"; do
165
+ \t[ -s "$log_path" ] || continue
166
+ \trm -f "$log_path.8.gz"
167
+ \tindex=7
168
+ \twhile [ "$index" -ge 1 ]; do
169
+ \t\tnext=$((index + 1))
170
+ \t\tif [ -f "$log_path.$index.gz" ]; then
171
+ \t\t\tmv "$log_path.$index.gz" "$log_path.$next.gz"
172
+ \t\tfi
173
+ \t\tindex=$((index - 1))
174
+ \tdone
175
+ \tcp "$log_path" "$log_path.1"
176
+ \t: > "$log_path"
177
+ \tgzip -f "$log_path.1"
178
+ done
179
+ `;
180
+ }
181
+ function logrotateConfig(spec) {
182
+ return `${[spec.paths.stdoutPath, spec.paths.stderrPath].map(logrotateQuote).join(" ")} {
183
+ weekly
184
+ rotate 8
185
+ missingok
186
+ notifempty
187
+ copytruncate
188
+ compress
189
+ delaycompress
190
+ }
191
+ `;
192
+ }
193
+ function logrotateService(spec, logrotatePath) {
194
+ const execStart = [logrotatePath, "-s", spec.paths.logrotateStatePath, spec.paths.logrotateConfigPath]
195
+ .map(systemdQuote)
196
+ .join(" ");
197
+ return `[Unit]
198
+ Description=Rotate Familiar service logs
199
+
200
+ [Service]
201
+ Type=oneshot
202
+ ExecStart=${execStart}
203
+ `;
204
+ }
205
+ function logrotateTimer() {
206
+ return `[Unit]
207
+ Description=Rotate Familiar service logs weekly
208
+
209
+ [Timer]
210
+ OnCalendar=weekly
211
+ Persistent=true
212
+
213
+ [Install]
214
+ WantedBy=timers.target
215
+ `;
216
+ }
125
217
  async function commandExists(command) {
126
218
  try {
127
219
  await execFileAsync(command, ["--version"]);
@@ -134,6 +226,18 @@ async function commandExists(command) {
134
226
  async function hasCommand(command, options) {
135
227
  return options.commandExists ? options.commandExists(command) : commandExists(command);
136
228
  }
229
+ async function commandPath(command, options) {
230
+ if (!(await hasCommand(command, options)))
231
+ return undefined;
232
+ try {
233
+ const output = await capture("sh", ["-c", `command -v ${shellQuote(command)}`], options);
234
+ const [path] = output.trim().split(/\r?\n/);
235
+ return path || undefined;
236
+ }
237
+ catch {
238
+ return undefined;
239
+ }
240
+ }
137
241
  async function run(command, args, options) {
138
242
  if (options.runCommand) {
139
243
  await options.runCommand(command, args);
@@ -195,6 +299,57 @@ function serviceDetails(spec) {
195
299
  `stderr: ${spec.paths.stderrPath}`,
196
300
  ];
197
301
  }
302
+ async function prepareLogRotation(spec, options) {
303
+ if (spec.platform === "darwin") {
304
+ await writeFile(spec.paths.logRotationScriptPath, logRotationScript(), "utf8");
305
+ await writeFile(spec.paths.logRotationLaunchdPath, launchdLogRotationPlist(spec), "utf8");
306
+ return { detail: `log_rotation: ${spec.paths.logRotationLaunchdPath}`, installed: true };
307
+ }
308
+ if (spec.platform !== "linux")
309
+ return undefined;
310
+ const logrotatePath = await commandPath("logrotate", options);
311
+ if (!logrotatePath) {
312
+ return {
313
+ detail: "logrotate: unavailable; install logrotate and rerun familiar install-service",
314
+ installed: false,
315
+ };
316
+ }
317
+ await writeFile(spec.paths.logrotateConfigPath, logrotateConfig(spec), "utf8");
318
+ await writeFile(spec.paths.logrotateServicePath, logrotateService(spec, logrotatePath), "utf8");
319
+ await writeFile(spec.paths.logrotateTimerPath, logrotateTimer(), "utf8");
320
+ return { detail: `logrotate: ${spec.paths.logrotateConfigPath}`, installed: true };
321
+ }
322
+ async function enableLogRotation(spec, result, options) {
323
+ if (!result?.installed)
324
+ return;
325
+ if (spec.platform === "darwin") {
326
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.logRotationLaunchdPath], options);
327
+ await run("launchctl", ["bootstrap", guiDomain(options), spec.paths.logRotationLaunchdPath], options);
328
+ return;
329
+ }
330
+ if (spec.platform === "linux") {
331
+ await run("systemctl", ["--user", "enable", "--now", SYSTEMD_LOGROTATE_TIMER], options);
332
+ }
333
+ }
334
+ async function removeLogRotation(spec, options) {
335
+ if (spec.platform === "darwin") {
336
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.logRotationLaunchdPath], options);
337
+ }
338
+ else if (spec.platform === "linux" && (await hasCommand("systemctl", options))) {
339
+ await runOptional("systemctl", ["--user", "disable", "--now", SYSTEMD_LOGROTATE_TIMER], options);
340
+ }
341
+ for (const path of [
342
+ spec.paths.logRotationLaunchdPath,
343
+ spec.paths.logRotationScriptPath,
344
+ spec.paths.logrotateServicePath,
345
+ spec.paths.logrotateTimerPath,
346
+ spec.paths.logrotateConfigPath,
347
+ spec.paths.logrotateStatePath,
348
+ ]) {
349
+ if (existsSync(path))
350
+ await rm(path);
351
+ }
352
+ }
198
353
  function serviceControlTitle(action) {
199
354
  if (action === "start")
200
355
  return "Familiar service started.";
@@ -246,10 +401,12 @@ export async function installService(workspacePath, options = {}) {
246
401
  await mkdir(spec.paths.logDir, { recursive: true });
247
402
  const serviceText = spec.platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
248
403
  await writeFile(spec.paths.servicePath, serviceText, "utf8");
404
+ const logRotation = await prepareLogRotation(spec, options);
249
405
  if (spec.platform === "darwin") {
250
406
  await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
251
407
  await run("launchctl", ["bootstrap", guiDomain(options), spec.paths.servicePath], options);
252
408
  await run("launchctl", ["kickstart", "-k", `${guiDomain(options)}/${SERVICE_LABEL}`], options);
409
+ await enableLogRotation(spec, logRotation, options);
253
410
  }
254
411
  else {
255
412
  if (!(await hasCommand("systemctl", options))) {
@@ -257,8 +414,11 @@ export async function installService(workspacePath, options = {}) {
257
414
  }
258
415
  await run("systemctl", ["--user", "daemon-reload"], options);
259
416
  await run("systemctl", ["--user", "enable", "--now", SYSTEMD_SERVICE], options);
417
+ await enableLogRotation(spec, logRotation, options);
260
418
  }
261
419
  const details = serviceDetails(spec);
420
+ if (logRotation)
421
+ details.push(logRotation.detail);
262
422
  const pathWarning = versionManagedPathWarning(spec);
263
423
  if (pathWarning)
264
424
  details.push(pathWarning);
@@ -282,11 +442,13 @@ export async function uninstallService(workspacePath, options = {}) {
282
442
  return unsupported(spec.platform);
283
443
  if (spec.platform === "darwin") {
284
444
  await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
445
+ await removeLogRotation(spec, options);
285
446
  }
286
447
  else {
287
448
  if (await hasCommand("systemctl", options)) {
288
449
  await runOptional("systemctl", ["--user", "disable", "--now", SYSTEMD_SERVICE], options);
289
450
  }
451
+ await removeLogRotation(spec, options);
290
452
  }
291
453
  if (existsSync(spec.paths.servicePath))
292
454
  await rm(spec.paths.servicePath);
@@ -345,8 +507,10 @@ export const __serviceTest = {
345
507
  SYSTEMD_SERVICE,
346
508
  buildSpec,
347
509
  launchdPlist,
510
+ launchdLogRotationPlist,
348
511
  systemdUnit,
349
512
  systemdQuote,
513
+ logRotationScript,
350
514
  xmlEscape,
351
515
  versionManagedPathWarning,
352
516
  };
@@ -9,6 +9,7 @@ import { registerWebConfigRoutes } from "./config-routes.js";
9
9
  import { registerWebConversationRoutes } from "./conversation-routes.js";
10
10
  import { registerWebDiaryRoutes } from "./diary-routes.js";
11
11
  import { createWebEventHub } from "./event-hub.js";
12
+ import { registerWebFileRoutes } from "./file-routes.js";
12
13
  import { HttpError, sendText } from "./http.js";
13
14
  import { createWebRouteRegistry } from "./routes.js";
14
15
  import { createWebRuntimeActions } from "./runtime-actions.js";
@@ -69,6 +70,7 @@ export async function startWebDaemon(config, familiarAgent, agentCore, options =
69
70
  });
70
71
  registerWebConfigRoutes(route, config, agentCore);
71
72
  registerWebDiaryRoutes(route, config);
73
+ registerWebFileRoutes(route, config);
72
74
  await subscribeKnownRuntimes();
73
75
  const server = createServer((request, response) => {
74
76
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
@@ -0,0 +1,134 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { applyContactNoteContent, setContactNotePath } from "../conversation/contact-note.js";
4
+ import { isEnoent } from "../util/fs.js";
5
+ import { isRecord } from "../util/guards.js";
6
+ import { HttpError, readJsonBody, sendJson } from "./http.js";
7
+ const WEB_FILE_DEFINITIONS = [
8
+ {
9
+ id: "soul",
10
+ name: "SOUL.md",
11
+ title: "soul",
12
+ description: "who your companion is trying to be",
13
+ path: (config) => config.persona.soul,
14
+ },
15
+ {
16
+ id: "user",
17
+ name: "USER.md",
18
+ title: "you",
19
+ description: "what they should remember about you",
20
+ path: (config) => config.persona.user,
21
+ },
22
+ {
23
+ id: "memory",
24
+ name: "MEMORY.md",
25
+ title: "memory",
26
+ description: "the long thread that should stay close",
27
+ path: (config) => config.persona.memory,
28
+ },
29
+ {
30
+ id: "heartbeat",
31
+ name: "HEARTBEAT.md",
32
+ title: "heartbeat",
33
+ description: "what they do when the room gets quiet",
34
+ path: (config) => resolve(config.workspacePath, "HEARTBEAT.md"),
35
+ },
36
+ {
37
+ id: "contact",
38
+ name: "CONTACT.md",
39
+ title: "contact",
40
+ description: "the name you keep for yourself here",
41
+ path: (config) => config.persona.contact,
42
+ afterWrite: (path, content) => {
43
+ setContactNotePath(path);
44
+ applyContactNoteContent(content);
45
+ },
46
+ },
47
+ ];
48
+ const WEB_FILES = WEB_FILE_DEFINITIONS;
49
+ const MAX_WEB_FILE_BODY_BYTES = 1024 * 1024;
50
+ export function registerWebFileRoutes(route, config) {
51
+ route("GET", "/api/web/files", async (_request, response) => {
52
+ sendJson(response, 200, { files: await listWebFiles(config) });
53
+ });
54
+ route("GET", "/api/web/file", async (_request, response, url) => {
55
+ const id = url.searchParams.get("id") ?? "";
56
+ sendJson(response, 200, { file: await readWebFile(config, id) });
57
+ });
58
+ route("PUT", "/api/web/file", async (request, response) => {
59
+ const body = await readJsonBody(request, MAX_WEB_FILE_BODY_BYTES);
60
+ const { id, content } = fileUpdateFromBody(body);
61
+ sendJson(response, 200, { file: await writeWebFile(config, id, content) });
62
+ });
63
+ }
64
+ export async function listWebFiles(config) {
65
+ return Promise.all(WEB_FILES.map((definition) => readFileSummary(config, definition)));
66
+ }
67
+ export async function readWebFile(config, rawId) {
68
+ const definition = webFileDefinition(rawId);
69
+ const path = webFilePath(config, definition);
70
+ const summary = await readFileSummary(config, definition);
71
+ let content = "";
72
+ try {
73
+ content = await readFile(path, "utf8");
74
+ }
75
+ catch (error) {
76
+ if (!isEnoent(error))
77
+ throw error;
78
+ }
79
+ return { ...summary, content };
80
+ }
81
+ export async function writeWebFile(config, rawId, content) {
82
+ const definition = webFileDefinition(rawId);
83
+ const path = webFilePath(config, definition);
84
+ await mkdir(dirname(path), { recursive: true });
85
+ await writeFile(path, content, "utf8");
86
+ definition.afterWrite?.(path, content);
87
+ return readWebFile(config, definition.id);
88
+ }
89
+ async function readFileSummary(config, definition) {
90
+ const path = webFilePath(config, definition);
91
+ try {
92
+ const fileStat = await stat(path);
93
+ if (!fileStat.isFile())
94
+ throw new HttpError(404, `${definition.name} is not a file`);
95
+ return {
96
+ id: definition.id,
97
+ name: definition.name,
98
+ title: definition.title,
99
+ description: definition.description,
100
+ mtimeMs: Math.floor(fileStat.mtimeMs),
101
+ sizeBytes: fileStat.size,
102
+ exists: true,
103
+ };
104
+ }
105
+ catch (error) {
106
+ if (!isEnoent(error))
107
+ throw error;
108
+ return {
109
+ id: definition.id,
110
+ name: definition.name,
111
+ title: definition.title,
112
+ description: definition.description,
113
+ mtimeMs: null,
114
+ sizeBytes: 0,
115
+ exists: false,
116
+ };
117
+ }
118
+ }
119
+ function fileUpdateFromBody(body) {
120
+ if (!isRecord(body) || typeof body.id !== "string")
121
+ throw new HttpError(400, "file id is required");
122
+ if (typeof body.content !== "string")
123
+ throw new HttpError(400, "content is required");
124
+ return { id: body.id, content: body.content };
125
+ }
126
+ function webFileDefinition(rawId) {
127
+ const definition = WEB_FILES.find((candidate) => candidate.id === rawId);
128
+ if (!definition)
129
+ throw new HttpError(400, `unknown file: ${rawId}`);
130
+ return definition;
131
+ }
132
+ function webFilePath(config, definition) {
133
+ return resolve(definition.path(config));
134
+ }
package/dist/web/http.js CHANGED
@@ -21,13 +21,13 @@ export function sendText(response, status, text) {
21
21
  response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
22
22
  response.end(text);
23
23
  }
24
- export async function readJsonBody(request) {
24
+ export async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
25
25
  const chunks = [];
26
26
  let total = 0;
27
27
  for await (const chunk of request) {
28
28
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
29
  total += buffer.length;
30
- if (total > MAX_BODY_BYTES)
30
+ if (total > maxBytes)
31
31
  throw new HttpError(413, "Request body too large");
32
32
  chunks.push(buffer);
33
33
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@qearlyao/familiar",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@qearlyao/familiar",
9
- "version": "0.4.2",
9
+ "version": "0.4.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-agent-core": "0.78.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qearlyao/familiar",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {