@qearlyao/familiar 0.2.2 → 0.2.4

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.
Files changed (60) hide show
  1. package/README.md +6 -14
  2. package/config.example.toml +1 -1
  3. package/dist/added-models.js +6 -15
  4. package/dist/agent-events.js +1 -3
  5. package/dist/agent.js +3 -4
  6. package/dist/browser-tools.js +15 -11
  7. package/dist/chat-log.js +3 -2
  8. package/dist/cli.js +2 -2
  9. package/dist/config-overrides.js +5 -14
  10. package/dist/config-registry.js +1 -4
  11. package/dist/config.js +45 -113
  12. package/dist/contact-note.js +2 -12
  13. package/dist/data-retention.js +1 -3
  14. package/dist/discord.js +72 -19
  15. package/dist/generated-media.js +3 -2
  16. package/dist/hot-reload.js +1 -3
  17. package/dist/image-gen.js +12 -51
  18. package/dist/inbound-attachments.js +64 -23
  19. package/dist/memory/diary/ambient-injector.js +1 -3
  20. package/dist/memory/diary/ambient.js +1 -3
  21. package/dist/memory/diary/chunks.js +1 -3
  22. package/dist/memory/diary/indexer.js +1 -3
  23. package/dist/memory/doctor.js +3 -8
  24. package/dist/memory/index/chunk-indexer.js +27 -6
  25. package/dist/memory/index/retrieval.js +1 -3
  26. package/dist/memory/index/store.js +47 -19
  27. package/dist/memory/lcm/backfill.js +19 -16
  28. package/dist/memory/lcm/context-transformer.js +17 -29
  29. package/dist/memory/lcm/context.js +10 -4
  30. package/dist/memory/lcm/eviction-score.js +25 -13
  31. package/dist/memory/lcm/indexer.js +1 -5
  32. package/dist/memory/lcm/normalize.js +22 -1
  33. package/dist/memory/lcm/store.js +27 -24
  34. package/dist/memory/operator.js +3 -31
  35. package/dist/memory/service.js +1 -3
  36. package/dist/memory/tools.js +0 -4
  37. package/dist/memory/util.js +6 -0
  38. package/dist/models.js +3 -0
  39. package/dist/persona.js +3 -15
  40. package/dist/runtime.js +12 -23
  41. package/dist/scheduler.js +15 -49
  42. package/dist/service.js +39 -27
  43. package/dist/settings.js +7 -32
  44. package/dist/silent-marker.js +64 -0
  45. package/dist/tts.js +0 -6
  46. package/dist/util/fs.js +41 -0
  47. package/dist/util/guards.js +8 -0
  48. package/dist/util/image-mime.js +31 -0
  49. package/dist/util/time.js +29 -0
  50. package/dist/web-auth.js +4 -1
  51. package/dist/web-static.js +36 -1
  52. package/dist/web-tools.js +8 -5
  53. package/dist/web.js +253 -69
  54. package/npm-shrinkwrap.json +5139 -0
  55. package/package.json +5 -4
  56. package/web/dist/assets/index-B23WT77N.js +63 -0
  57. package/web/dist/assets/index-D3MotFzN.css +2 -0
  58. package/web/dist/index.html +2 -2
  59. package/web/dist/assets/index-BPZQbZh5.js +0 -61
  60. package/web/dist/assets/index-CcQ13VAY.css +0 -2
@@ -3,6 +3,7 @@ import { mkdirSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import Database from "better-sqlite3";
5
5
  import { normalizeFtsMatchQuery } from "../index/fts-query.js";
6
+ import { runInTransaction } from "../util.js";
6
7
  import { readMeta, runLcmMigrations } from "./schema.js";
7
8
  export class LcmStore {
8
9
  db;
@@ -34,6 +35,9 @@ export class LcmStore {
34
35
  const raw = readMeta(this.db, "schema_version");
35
36
  return raw ? Number(raw) : null;
36
37
  }
38
+ computeRecordKey(input) {
39
+ return computeLcmRecordKey(input);
40
+ }
37
41
  ensureSegment(input) {
38
42
  const startedAt = input.startedAt ?? new Date().toISOString();
39
43
  this.db
@@ -68,6 +72,9 @@ export class LcmStore {
68
72
  return rows.map(segmentFromRow);
69
73
  }
70
74
  insertRecord(input) {
75
+ return this.insertRecordReturningStored(input).record.id;
76
+ }
77
+ insertRecordReturningStored(input) {
71
78
  const normalized = normalizeRecordInput(input);
72
79
  const runInsert = () => {
73
80
  this.ensureSegment({
@@ -76,14 +83,13 @@ export class LcmStore {
76
83
  channelKey: normalized.channelKey,
77
84
  startedAt: normalized.happenedAt,
78
85
  });
79
- const existing = this.db
80
- .prepare("SELECT id FROM lcm_records WHERE record_key = ?")
81
- .get(normalized.recordKey);
86
+ const existing = this.db.prepare("SELECT * FROM lcm_records WHERE record_key = ?").get(normalized.recordKey);
82
87
  if (existing)
83
- return existing.id;
84
- return insertRecordPrepared(this.db, normalized);
88
+ return { record: recordFromRow(existing), inserted: false };
89
+ const inserted = insertRecordPrepared(this.db, normalized);
90
+ return { record: recordFromRow(inserted), inserted: true };
85
91
  };
86
- return this.db.inTransaction ? runInsert() : this.db.transaction(runInsert).immediate();
92
+ return runInTransaction(this.db, runInsert);
87
93
  }
88
94
  getRecord(id) {
89
95
  const row = this.db.prepare("SELECT * FROM lcm_records WHERE id = ?").get(id);
@@ -132,7 +138,7 @@ export class LcmStore {
132
138
  this.insertSummarySources(id, sources);
133
139
  this.insertSummaryParents(id, parents);
134
140
  });
135
- const result = this.db.inTransaction ? runInsert() : this.db.transaction(runInsert).immediate();
141
+ const result = runInTransaction(this.db, runInsert);
136
142
  return result;
137
143
  }
138
144
  getSummary(id) {
@@ -246,8 +252,14 @@ export class LcmStore {
246
252
  .all(segmentId, retainDepth);
247
253
  for (const summary of summaries) {
248
254
  report.indexDeletes.push({ corpus: "lcm_summary", sourceId: lcmSummaryIndexSourceId(summary.id) });
249
- report.summaryFtsRowsDeleted += this.deleteSummaryFtsRow(summary.id);
250
255
  }
256
+ report.summaryFtsRowsDeleted += this.db
257
+ .prepare(`DELETE FROM lcm_summaries_fts
258
+ WHERE rowid IN (
259
+ SELECT id FROM lcm_summaries
260
+ WHERE segment_id = ? AND pinned = 0 AND depth < ?
261
+ )`)
262
+ .run(segmentId, retainDepth).changes;
251
263
  report.summariesDeleted += this.db
252
264
  .prepare("DELETE FROM lcm_summaries WHERE segment_id = ? AND pinned = 0 AND depth < ?")
253
265
  .run(segmentId, retainDepth).changes;
@@ -257,8 +269,10 @@ export class LcmStore {
257
269
  const records = this.db.prepare("SELECT id FROM lcm_records WHERE segment_id = ?").all(segmentId);
258
270
  for (const record of records) {
259
271
  report.indexDeletes.push({ corpus: "lcm_record", sourceId: lcmRecordIndexSourceId(record.id) });
260
- report.recordFtsRowsDeleted += this.deleteRecordFtsRow(record.id);
261
272
  }
273
+ report.recordFtsRowsDeleted += this.db
274
+ .prepare("DELETE FROM lcm_records_fts WHERE rowid IN (SELECT id FROM lcm_records WHERE segment_id = ?)")
275
+ .run(segmentId).changes;
262
276
  report.rawRecordsDeleted += this.db
263
277
  .prepare("DELETE FROM lcm_records WHERE segment_id = ?")
264
278
  .run(segmentId).changes;
@@ -318,20 +332,6 @@ export class LcmStore {
318
332
  }
319
333
  return map;
320
334
  }
321
- deleteRecordFtsRow(id) {
322
- const row = this.db.prepare("SELECT text_full FROM lcm_records WHERE id = ?").get(id);
323
- if (!row)
324
- return 0;
325
- this.db.prepare("DELETE FROM lcm_records_fts WHERE rowid = ?").run(id);
326
- return 1;
327
- }
328
- deleteSummaryFtsRow(id) {
329
- const row = this.db.prepare("SELECT text_full FROM lcm_summaries WHERE id = ?").get(id);
330
- if (!row)
331
- return 0;
332
- this.db.prepare("DELETE FROM lcm_summaries_fts WHERE rowid = ?").run(id);
333
- return 1;
334
- }
335
335
  snapshotSummariesForPrunedRecords(segmentId) {
336
336
  const summaries = this.db
337
337
  .prepare(`SELECT * FROM lcm_summaries
@@ -424,7 +424,10 @@ function insertRecordPrepared(db, normalized) {
424
424
  if (normalized.kind !== "boundary") {
425
425
  db.prepare("INSERT INTO lcm_records_fts(rowid, text_full) VALUES (?, ?)").run(id, normalized.text);
426
426
  }
427
- return id;
427
+ const row = db.prepare("SELECT * FROM lcm_records WHERE id = ?").get(id);
428
+ if (!row)
429
+ throw new Error(`Failed to read inserted LCM record: ${id}`);
430
+ return row;
428
431
  }
429
432
  function normalizeSummaryInput(input) {
430
433
  const text = (input.text ?? "").trim();
@@ -11,6 +11,7 @@ import { backfillFromChatLogs } from "./lcm/backfill.js";
11
11
  import { indexLcmRecords, indexLcmSummaries } from "./lcm/indexer.js";
12
12
  import { lcmRecordIndexSourceId, lcmSummaryIndexSourceId } from "./lcm/store.js";
13
13
  import { MemoryService } from "./service.js";
14
+ import { runInTransaction } from "./util.js";
14
15
  export async function runMemoryOperator(config, argv) {
15
16
  const [command, ...args] = argv;
16
17
  if (!command || command === "--help" || command === "help") {
@@ -151,10 +152,7 @@ async function reindex(config, service, options, embeddingProvider, signal) {
151
152
  for (const corpus of corpora)
152
153
  deleteCorpus(service.memoryStore, corpus);
153
154
  };
154
- if (service.memoryStore.db.inTransaction)
155
- runDelete();
156
- else
157
- service.memoryStore.db.transaction(runDelete).immediate();
155
+ runInTransaction(service.memoryStore.db, runDelete);
158
156
  const indexer = options.force
159
157
  ? new ChunkIndexer({
160
158
  store: service.memoryStore,
@@ -221,39 +219,13 @@ async function prune(service, options) {
221
219
  console.log("Prune cancelled");
222
220
  return;
223
221
  }
224
- const activeSegments = service.lcmStore.listSegments().filter((segment) => segment.status === "active");
225
- const activeSegmentId = activeSegments.at(-1)?.id ?? null;
226
- const runClose = () => {
227
- for (const segment of activeSegments) {
228
- if (segment.id !== activeSegmentId)
229
- service.lcmStore.closeSegment(segment.id);
230
- }
231
- };
232
- if (service.lcmStore.db.inTransaction)
233
- runClose();
234
- else
235
- service.lcmStore.db.transaction(runClose).immediate();
236
222
  const report = service.lcmStore.applyNewSessionRetention({
237
223
  newSessionRetainDepth: options.retainDepth,
238
- activeSegmentId,
224
+ activeSegmentId: null,
239
225
  vacuum: options.vacuum,
240
226
  });
241
227
  for (const ref of report.indexDeletes)
242
228
  service.memoryStore.deleteBySource(ref.corpus, ref.sourceId);
243
- const closedActive = activeSegments.filter((segment) => segment.id !== activeSegmentId);
244
- if (closedActive.length > 0) {
245
- const runReopen = () => {
246
- for (const segment of closedActive) {
247
- service.lcmStore.db
248
- .prepare("UPDATE lcm_segments SET status = 'active', closed_at = NULL, updated_at = unixepoch() WHERE id = ?")
249
- .run(segment.id);
250
- }
251
- };
252
- if (service.lcmStore.db.inTransaction)
253
- runReopen();
254
- else
255
- service.lcmStore.db.transaction(runReopen).immediate();
256
- }
257
229
  console.log(`Pruned ${report.rawRecordsDeleted} raw record(s), ${report.summariesDeleted} summary row(s), ` +
258
230
  `${report.affectedSegments.length} closed segment(s) scanned`);
259
231
  }
@@ -1,5 +1,6 @@
1
1
  import { watch } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
+ import { isEnoent } from "../util/fs.js";
3
4
  import { __ambientDiaryInjectorTest, AmbientDiaryInjector } from "./diary/ambient-injector.js";
4
5
  import { DIARY_INDEX_FILE_RE, indexAllDiaryFiles, indexDiaryFile, removeDiaryFileIndex } from "./diary/indexer.js";
5
6
  import { ChunkIndexer } from "./index/chunk-indexer.js";
@@ -196,7 +197,4 @@ function parseIndexSourceId(value, prefix) {
196
197
  const id = Number(value.slice(prefix.length + 1));
197
198
  return Number.isInteger(id) && id > 0 ? id : null;
198
199
  }
199
- function isEnoent(error) {
200
- return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
201
- }
202
200
  export const __memoryServiceTest = __ambientDiaryInjectorTest;
@@ -68,10 +68,6 @@ function makeMemoryRecallTool(deps) {
68
68
  return {
69
69
  content: [{ type: "text", text: formatRecallResults(hits) }],
70
70
  details: {
71
- query,
72
- scope,
73
- mode,
74
- limit,
75
71
  resultCount: hits.length,
76
72
  ids: hits.map((hit) => hit.id),
77
73
  },
@@ -0,0 +1,6 @@
1
+ export function positiveIntegerOrDefault(value, fallback) {
2
+ return value !== undefined && Number.isInteger(value) && value > 0 ? value : fallback;
3
+ }
4
+ export function runInTransaction(db, fn) {
5
+ return db.inTransaction ? fn() : db.transaction(fn).immediate();
6
+ }
package/dist/models.js CHANGED
@@ -95,6 +95,9 @@ export function createConfiguredModel(config) {
95
95
  }
96
96
  return resolveModel(ref, config);
97
97
  }
98
+ export function resolveProviderSetting(records, provider, modelId) {
99
+ return records[`${provider}/${modelId}`] ?? records[provider];
100
+ }
98
101
  export function resolveModelApiKey(config, model) {
99
102
  const configuredEnv = config.models.apiKeyEnvs[`${model.provider}/${model.id}`] ??
100
103
  config.models.apiKeyEnvs[model.provider] ??
package/dist/persona.js CHANGED
@@ -1,23 +1,11 @@
1
1
  import { readFile } from "node:fs/promises";
2
- function isMissingFile(error) {
3
- return error instanceof Error && "code" in error && error.code === "ENOENT";
4
- }
5
- async function readOptionalPersonaFile(path) {
6
- try {
7
- return await readFile(path, "utf8");
8
- }
9
- catch (error) {
10
- if (isMissingFile(error))
11
- return null;
12
- throw error;
13
- }
14
- }
2
+ import { readFileOrNull } from "./util/fs.js";
15
3
  export async function loadPersona(config) {
16
4
  const [soul, user, memory, inner] = await Promise.all([
17
5
  readFile(config.persona.soul, "utf8"),
18
6
  readFile(config.persona.user, "utf8"),
19
7
  readFile(config.persona.memory, "utf8"),
20
- readOptionalPersonaFile(config.persona.inner),
8
+ readFileOrNull(config.persona.inner, "utf8"),
21
9
  ]);
22
10
  return { soul, user, memory, inner };
23
11
  }
@@ -41,7 +29,7 @@ ${renderedFiles}
41
29
  <note_to_self>
42
30
  you can edit MEMORY.md when something about her is worth keeping.
43
31
  CONTACT.md is what you call her in your contact book — like a nickname only you use. edit it whenever it feels right.
44
- output [[FAMILIAR_SILENT]] if there's nothing worth saying quiet's a real choice.
32
+ when there's nothing worth saying, reply with exactly the literal string [[FAMILIAR_SILENT]]. quiet's a real choice.
45
33
  </note_to_self>
46
34
  ${renderedSkillsBlock}
47
35
  </system-reminder>`;
package/dist/runtime.js CHANGED
@@ -1,31 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { buildRecordBase, } from "./chat-log.js";
3
3
  import { promptAttachmentNotes } from "./inbound-attachments.js";
4
+ import { formatLocalTimestamp } from "./util/time.js";
4
5
  function formatAuthor(authorName, authorId) {
5
6
  return authorName ? `${authorName} (uid:${authorId})` : `uid:${authorId}`;
6
7
  }
7
- function formatLocalTimestamp(ts) {
8
- const date = new Date(ts);
9
- if (Number.isNaN(date.getTime()))
10
- return ts;
11
- const offsetMinutes = -date.getTimezoneOffset();
12
- const sign = offsetMinutes >= 0 ? "+" : "-";
13
- const absolute = Math.abs(offsetMinutes);
14
- const hours = Math.floor(absolute / 60);
15
- const minutes = absolute % 60;
16
- const offset = minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${String(minutes).padStart(2, "0")}`;
17
- const local = [
18
- date.getFullYear(),
19
- String(date.getMonth() + 1).padStart(2, "0"),
20
- String(date.getDate()).padStart(2, "0"),
21
- ].join("-");
22
- const time = [
23
- String(date.getHours()).padStart(2, "0"),
24
- String(date.getMinutes()).padStart(2, "0"),
25
- String(date.getSeconds()).padStart(2, "0"),
26
- ].join(":");
27
- return `${local} ${time} ${offset}`;
28
- }
29
8
  function formatPromptRecord(record) {
30
9
  const text = record.text.trim() || "(no text)";
31
10
  const author = record.authorName?.trim()
@@ -91,6 +70,8 @@ export class ConversationRuntime {
91
70
  for (const record of this.records) {
92
71
  if (record.type === "job_completed" || record.type === "job_failed")
93
72
  terminalJobIds.add(record.jobId);
73
+ if (record.type === "outbound" && record.jobId)
74
+ terminalJobIds.add(record.jobId);
94
75
  if (record.type === "job_queued") {
95
76
  queuedJobs.push({
96
77
  jobId: record.jobId,
@@ -180,9 +161,17 @@ export class ConversationRuntime {
180
161
  }
181
162
  getLastCompletedTriggerRecordId() {
182
163
  let last = 0;
164
+ const queuedTriggerRecordIds = new Map();
183
165
  for (const record of this.records) {
166
+ if (record.type === "job_queued")
167
+ queuedTriggerRecordIds.set(record.jobId, record.triggerRecordId);
184
168
  if (record.type === "job_completed")
185
169
  last = Math.max(last, record.triggerRecordId);
170
+ if (record.type === "outbound" && record.jobId) {
171
+ const triggerRecordId = queuedTriggerRecordIds.get(record.jobId);
172
+ if (triggerRecordId !== undefined)
173
+ last = Math.max(last, triggerRecordId);
174
+ }
186
175
  }
187
176
  return last;
188
177
  }
@@ -355,7 +344,7 @@ export class ConversationRuntime {
355
344
  }
356
345
  async noteOutbound(options) {
357
346
  const text = options.text.trim();
358
- if (!text && options.messageIds.length === 0)
347
+ if (!text && options.messageIds.length === 0 && !options.silent)
359
348
  return undefined;
360
349
  const record = {
361
350
  type: "outbound",
package/dist/scheduler.js CHANGED
@@ -1,35 +1,9 @@
1
- import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
+ import { readFileOrNull } from "./util/fs.js";
4
+ import { formatLocalTimestamp, toDate } from "./util/time.js";
5
+ export { formatLocalTimestamp } from "./util/time.js";
3
6
  const stateWriteQueues = new Map();
4
- function toDate(value) {
5
- if (value instanceof Date)
6
- return value;
7
- return new Date(value);
8
- }
9
- function formatOffset(date) {
10
- const offsetMinutes = -date.getTimezoneOffset();
11
- const sign = offsetMinutes >= 0 ? "+" : "-";
12
- const absolute = Math.abs(offsetMinutes);
13
- const hours = Math.floor(absolute / 60);
14
- const minutes = absolute % 60;
15
- return minutes === 0 ? `GMT${sign}${hours}` : `GMT${sign}${hours}:${String(minutes).padStart(2, "0")}`;
16
- }
17
- export function formatLocalTimestamp(value) {
18
- const date = toDate(value);
19
- if (Number.isNaN(date.getTime()))
20
- return String(value);
21
- const localDate = [
22
- date.getFullYear(),
23
- String(date.getMonth() + 1).padStart(2, "0"),
24
- String(date.getDate()).padStart(2, "0"),
25
- ].join("-");
26
- const localTime = [
27
- String(date.getHours()).padStart(2, "0"),
28
- String(date.getMinutes()).padStart(2, "0"),
29
- String(date.getSeconds()).padStart(2, "0"),
30
- ].join(":");
31
- return `${localDate} ${localTime} ${formatOffset(date)}`;
32
- }
33
7
  export function formatIdleDuration(ms) {
34
8
  if (!Number.isFinite(ms) || ms <= 0)
35
9
  return "0m";
@@ -53,9 +27,7 @@ export function buildHeartbeatInjectionText(options) {
53
27
  const body = options.body ??
54
28
  `hey~ been quiet for a bit. this is your time now.
55
29
 
56
- what you do with it is up to you — HEARTBEAT.md has the menu if you don't remember it. once you know the shape of it you don't have to re-read every fire, just trust what you remember and pick what fits.
57
-
58
- it's okay to sit one out, but only when that's actually the real answer — not when it's the easy one.`;
30
+ what you do with it is up to you — HEARTBEAT.md has the menu if you don't remember it. once you know the shape of it you don't have to re-read every fire.`;
59
31
  return `<heartbeat local_time="${formatLocalTimestamp(nowDate)}" idle_duration="${formatIdleDuration(idleDurationMs)}" idle_minutes="${idleMinutes}">\n${body}\n</heartbeat>`;
60
32
  }
61
33
  function pad2(value) {
@@ -146,22 +118,16 @@ export function schedulerLogPath(dataDir, now = new Date()) {
146
118
  return resolve(dataDir, "scheduler", `${now.toISOString().slice(0, 10)}.jsonl`);
147
119
  }
148
120
  export async function loadSchedulerState(dataDir) {
149
- const path = schedulerStatePath(dataDir);
150
- try {
151
- const raw = await readFile(path, "utf8");
152
- const parsed = JSON.parse(raw);
153
- return {
154
- heartbeat: parsed.heartbeat && typeof parsed.heartbeat === "object" && !Array.isArray(parsed.heartbeat)
155
- ? parsed.heartbeat
156
- : undefined,
157
- cron: parsed.cron && typeof parsed.cron === "object" && !Array.isArray(parsed.cron) ? parsed.cron : {},
158
- };
159
- }
160
- catch (error) {
161
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
162
- return { cron: {} };
163
- throw error;
164
- }
121
+ const raw = await readFileOrNull(schedulerStatePath(dataDir), "utf8");
122
+ if (raw === null)
123
+ return { cron: {} };
124
+ const parsed = JSON.parse(raw);
125
+ return {
126
+ heartbeat: parsed.heartbeat && typeof parsed.heartbeat === "object" && !Array.isArray(parsed.heartbeat)
127
+ ? parsed.heartbeat
128
+ : undefined,
129
+ cron: parsed.cron && typeof parsed.cron === "object" && !Array.isArray(parsed.cron) ? parsed.cron : {},
130
+ };
165
131
  }
166
132
  export async function saveSchedulerState(dataDir, state) {
167
133
  const path = schedulerStatePath(dataDir);
package/dist/service.js CHANGED
@@ -8,20 +8,21 @@ const execFileAsync = promisify(execFile);
8
8
  const SERVICE_LABEL = "com.qearlyao.familiar";
9
9
  const SYSTEMD_SERVICE = "familiar.service";
10
10
  function servicePaths(workspacePath, input) {
11
- const logDir = resolve(workspacePath, "logs");
11
+ const logDir = input.resolvePath(workspacePath, "logs");
12
12
  return {
13
13
  servicePath: input.platform === "darwin"
14
- ? resolve(input.homeDir, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`)
15
- : resolve(input.homeDir, ".config", "systemd", "user", SYSTEMD_SERVICE),
14
+ ? input.resolvePath(input.homeDir, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`)
15
+ : input.resolvePath(input.homeDir, ".config", "systemd", "user", SYSTEMD_SERVICE),
16
16
  logDir,
17
- stdoutPath: resolve(logDir, "familiar.out.log"),
18
- stderrPath: resolve(logDir, "familiar.err.log"),
17
+ stdoutPath: input.resolvePath(logDir, "familiar.out.log"),
18
+ stderrPath: input.resolvePath(logDir, "familiar.err.log"),
19
19
  };
20
20
  }
21
21
  function buildSpec(workspacePath, options = {}) {
22
22
  const currentPlatform = options.platform ?? platform();
23
23
  const cliPath = options.cliPath ?? currentCliPath();
24
- const resolvedWorkspacePath = resolve(workspacePath);
24
+ const resolvePath = options.resolvePath ?? resolve;
25
+ const resolvedWorkspacePath = resolvePath(workspacePath);
25
26
  return {
26
27
  platform: currentPlatform,
27
28
  workspacePath: resolvedWorkspacePath,
@@ -30,6 +31,7 @@ function buildSpec(workspacePath, options = {}) {
30
31
  paths: servicePaths(resolvedWorkspacePath, {
31
32
  platform: currentPlatform,
32
33
  homeDir: options.homeDir ?? homedir(),
34
+ resolvePath,
33
35
  }),
34
36
  };
35
37
  }
@@ -139,6 +141,26 @@ async function run(command, args, options) {
139
141
  }
140
142
  await execFileAsync(command, args);
141
143
  }
144
+ async function runInteractive(command, args, options, errorPrefix) {
145
+ const currentPlatform = options.platform ?? platform();
146
+ if (options.runCommand) {
147
+ await options.runCommand(command, args);
148
+ return;
149
+ }
150
+ await new Promise((resolveRun, rejectRun) => {
151
+ const child = spawn(command, args, {
152
+ shell: currentPlatform === "win32",
153
+ stdio: "inherit",
154
+ });
155
+ child.on("exit", (code) => {
156
+ if (code === 0)
157
+ resolveRun();
158
+ else
159
+ rejectRun(new Error(`${errorPrefix} failed with exit code ${code ?? "unknown"}`));
160
+ });
161
+ child.on("error", rejectRun);
162
+ });
163
+ }
142
164
  async function capture(command, args, options) {
143
165
  if (options.captureCommand)
144
166
  return options.captureCommand(command, args);
@@ -153,8 +175,8 @@ async function runOptional(command, args, options) {
153
175
  // Best-effort cleanup for stale service registrations.
154
176
  }
155
177
  }
156
- function guiDomain() {
157
- return `gui/${userInfo().uid}`;
178
+ function guiDomain(options = {}) {
179
+ return `gui/${options.userId ?? userInfo().uid}`;
158
180
  }
159
181
  function unsupported(platformName) {
160
182
  return {
@@ -174,9 +196,9 @@ export async function installService(workspacePath, options = {}) {
174
196
  const serviceText = spec.platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
175
197
  await writeFile(spec.paths.servicePath, serviceText, "utf8");
176
198
  if (spec.platform === "darwin") {
177
- await runOptional("launchctl", ["bootout", guiDomain(), spec.paths.servicePath], options);
178
- await run("launchctl", ["bootstrap", guiDomain(), spec.paths.servicePath], options);
179
- await run("launchctl", ["kickstart", "-k", `${guiDomain()}/${SERVICE_LABEL}`], options);
199
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
200
+ await run("launchctl", ["bootstrap", guiDomain(options), spec.paths.servicePath], options);
201
+ await run("launchctl", ["kickstart", "-k", `${guiDomain(options)}/${SERVICE_LABEL}`], options);
180
202
  }
181
203
  else {
182
204
  if (!(await hasCommand("systemctl", options))) {
@@ -204,7 +226,7 @@ export async function uninstallService(workspacePath, options = {}) {
204
226
  if (spec.platform !== "darwin" && spec.platform !== "linux")
205
227
  return unsupported(spec.platform);
206
228
  if (spec.platform === "darwin") {
207
- await runOptional("launchctl", ["bootout", guiDomain(), spec.paths.servicePath], options);
229
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
208
230
  }
209
231
  else {
210
232
  if (await hasCommand("systemctl", options)) {
@@ -242,7 +264,7 @@ export async function serviceStatus(workspacePath, options = {}) {
242
264
  async function supervisorState(spec, options) {
243
265
  try {
244
266
  if (spec.platform === "darwin") {
245
- await capture("launchctl", ["print", `${guiDomain()}/${SERVICE_LABEL}`], options);
267
+ await capture("launchctl", ["print", `${guiDomain(options)}/${SERVICE_LABEL}`], options);
246
268
  return "loaded";
247
269
  }
248
270
  const state = (await capture("systemctl", ["--user", "is-active", SYSTEMD_SERVICE], options)).trim();
@@ -252,22 +274,12 @@ async function supervisorState(spec, options) {
252
274
  return "not-loaded";
253
275
  }
254
276
  }
255
- export async function upgradeFamiliar(options = {}) {
277
+ export async function upgradeFamiliar(workspacePath, options = {}) {
256
278
  const currentPlatform = options.platform ?? platform();
257
279
  const npmCommand = currentPlatform === "win32" ? "npm.cmd" : "npm";
258
- await new Promise((resolveUpgrade, rejectUpgrade) => {
259
- const child = spawn(npmCommand, ["install", "-g", "@qearlyao/familiar@latest"], {
260
- shell: currentPlatform === "win32",
261
- stdio: "inherit",
262
- });
263
- child.on("exit", (code) => {
264
- if (code === 0)
265
- resolveUpgrade();
266
- else
267
- rejectUpgrade(new Error(`npm upgrade failed with exit code ${code ?? "unknown"}`));
268
- });
269
- child.on("error", rejectUpgrade);
270
- });
280
+ const familiarCommand = currentPlatform === "win32" ? "familiar.cmd" : "familiar";
281
+ await runInteractive(npmCommand, ["install", "-g", "@qearlyao/familiar@latest"], options, "npm upgrade");
282
+ await runInteractive(familiarCommand, ["init", workspacePath], options, "workspace default refresh");
271
283
  }
272
284
  export function formatServiceResult(result) {
273
285
  return [result.title, ...result.details].join("\n");
package/dist/settings.js CHANGED
@@ -1,13 +1,6 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
- function isThinkingLevel(value) {
4
- return (value === "off" ||
5
- value === "minimal" ||
6
- value === "low" ||
7
- value === "medium" ||
8
- value === "high" ||
9
- value === "xhigh");
10
- }
1
+ import { resolve } from "node:path";
2
+ import { isThinkingLevel } from "./models.js";
3
+ import { atomicWriteJson, createWriteQueue, readFileOrNull } from "./util/fs.js";
11
4
  function isChannelTrigger(value) {
12
5
  return value === "mention" || value === "always";
13
6
  }
@@ -37,16 +30,8 @@ function normalizeSettingsFile(value) {
37
30
  return { version: 1, channels };
38
31
  }
39
32
  async function readSettingsFile(path) {
40
- try {
41
- const raw = await readFile(path, "utf8");
42
- return normalizeSettingsFile(JSON.parse(raw));
43
- }
44
- catch (error) {
45
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
46
- return { version: 1, channels: {} };
47
- }
48
- throw error;
49
- }
33
+ const raw = await readFileOrNull(path, "utf8");
34
+ return raw === null ? { version: 1, channels: {} } : normalizeSettingsFile(JSON.parse(raw));
50
35
  }
51
36
  function pruneChannel(settings) {
52
37
  const pruned = {};
@@ -61,18 +46,8 @@ function pruneChannel(settings) {
61
46
  export async function loadSettingsStore(config) {
62
47
  const path = resolve(config.workspace.dataDir, "settings", "channel-overrides.json");
63
48
  let file = await readSettingsFile(path);
64
- let writeQueue = Promise.resolve();
65
- const persist = async () => {
66
- await mkdir(dirname(path), { recursive: true });
67
- const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
68
- await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, "utf8");
69
- await rename(tmpPath, path);
70
- };
71
- const enqueuePersist = () => {
72
- const run = writeQueue.then(persist, () => persist());
73
- writeQueue = run.then(() => undefined, () => undefined);
74
- return run;
75
- };
49
+ const enqueueWrite = createWriteQueue("channel settings");
50
+ const enqueuePersist = () => enqueueWrite(() => atomicWriteJson(path, file));
76
51
  const updateChannel = async (channelKey, patch) => {
77
52
  const next = pruneChannel({ ...file.channels[channelKey], ...patch });
78
53
  const channels = { ...file.channels };
@@ -0,0 +1,64 @@
1
+ export const SILENT_RESPONSE_MARKER = "[[FAMILIAR_SILENT]]";
2
+ function normalizeNewlines(text) {
3
+ return text.replace(/\r\n/g, "\n");
4
+ }
5
+ export function parseAgentReply(text) {
6
+ const normalized = normalizeNewlines(text);
7
+ if (normalized === SILENT_RESPONSE_MARKER) {
8
+ return { text: "", silent: true };
9
+ }
10
+ if (normalized.startsWith(`${SILENT_RESPONSE_MARKER}\n`)) {
11
+ return { text: normalized.slice(SILENT_RESPONSE_MARKER.length + 1), silent: true };
12
+ }
13
+ return { text, silent: false };
14
+ }
15
+ export function createSilentFilterState() {
16
+ return { accumulated: "", buffered: "", silent: false, carryCR: false };
17
+ }
18
+ function normalizeStreamingChunk(state, chunk) {
19
+ let working = chunk;
20
+ if (state.carryCR) {
21
+ state.carryCR = false;
22
+ working = `\r${working}`;
23
+ }
24
+ if (working.endsWith("\r")) {
25
+ state.carryCR = true;
26
+ working = working.slice(0, -1);
27
+ }
28
+ return working.replace(/\r\n/g, "\n");
29
+ }
30
+ export function consumeSilentDelta(state, delta) {
31
+ if (state.silent)
32
+ return { kind: "silent" };
33
+ const normalized = normalizeStreamingChunk(state, delta);
34
+ state.accumulated += normalized;
35
+ state.buffered += normalized;
36
+ if (state.accumulated.startsWith(`${SILENT_RESPONSE_MARKER}\n`)) {
37
+ state.silent = true;
38
+ state.buffered = "";
39
+ return { kind: "silent" };
40
+ }
41
+ if (SILENT_RESPONSE_MARKER.startsWith(state.accumulated)) {
42
+ return { kind: "buffer" };
43
+ }
44
+ const emit = state.buffered;
45
+ state.buffered = "";
46
+ return emit ? { kind: "emit", text: emit } : { kind: "buffer" };
47
+ }
48
+ export function finalizeSilentFilter(state) {
49
+ if (state.carryCR) {
50
+ state.buffered += "\r";
51
+ state.accumulated += "\r";
52
+ state.carryCR = false;
53
+ }
54
+ if (state.silent)
55
+ return { silent: true, flush: "" };
56
+ if (state.accumulated === SILENT_RESPONSE_MARKER) {
57
+ state.silent = true;
58
+ state.buffered = "";
59
+ return { silent: true, flush: "" };
60
+ }
61
+ const flush = state.buffered;
62
+ state.buffered = "";
63
+ return { silent: false, flush };
64
+ }