@qearlyao/familiar 0.2.2 → 0.2.3

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
@@ -85,6 +85,7 @@ node dist/cli.js init
85
85
  - `USER.md`
86
86
  - `MEMORY.md`
87
87
  - `HEARTBEAT.md`
88
+ - `CONTACT.md`
88
89
  - `data/`
89
90
  - `memories/`
90
91
  - `skills/`
@@ -297,18 +298,6 @@ For OpenAI Responses models, Familiar strips replayed reasoning items from
297
298
  outgoing payloads while pi-ai sends `store: false`; otherwise OpenAI can reject
298
299
  later turns with missing `rs_...` item references.
299
300
 
300
- ## Release Checks
301
-
302
- Before publishing:
303
-
304
- ```sh
305
- npm run build
306
- npm pack --dry-run
307
- ```
308
-
309
- The npm package is intentionally published from built output plus workspace
310
- templates, not the full source tree.
311
-
312
301
  ## License
313
302
 
314
303
  MIT
@@ -120,6 +120,9 @@ function buildSpawnInvocation(spec, currentPlatform = platform(), comSpec = proc
120
120
  const commandLine = [spec.command, ...spec.args].map(quoteWindowsShellArg).join(" ");
121
121
  return {
122
122
  command: comSpec,
123
+ // Windows npm shims are .cmd files, so we must cross cmd.exe here.
124
+ // The caller already validates browser.site/browser.command and individual
125
+ // args before they reach this shell boundary.
123
126
  // cmd.exe strips one outer quote pair from the /c string. Wrap the whole
124
127
  // already-quoted command so .cmd shims with spaced paths still receive argv.
125
128
  args: ["/d", "/s", "/c", `"${commandLine}"`],
package/dist/discord.js CHANGED
@@ -1,19 +1,21 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { once } from "node:events";
3
3
  import { readFile } from "node:fs/promises";
4
+ import { extname } from "node:path";
4
5
  import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, ChannelType, Client, Events, GatewayIntentBits, InteractionContextType, MessageFlags, Partials, } from "discord.js";
5
6
  import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
6
7
  import { chatChannelKey, createChatLog } from "./chat-log.js";
7
8
  import { materializeInboundAttachments, promptImagesFromAttachments } from "./inbound-attachments.js";
8
9
  import { ConversationRuntime } from "./runtime.js";
9
10
  import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, isHeartbeatDue, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
11
+ import { parseAgentReply as parseSilentMarker } from "./silent-marker.js";
10
12
  const FAMILIAR_COMMAND_NAME = "familiar";
11
13
  const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
12
14
  const CHANNEL_TRIGGER_CHOICES = ["mention", "always"];
13
15
  const EPHEMERAL_REPLY = MessageFlags.Ephemeral;
14
- const SILENT_RESPONSE_MARKER = "[[FAMILIAR_SILENT]]";
15
16
  const HEARTBEAT_SKIPPED = Symbol("heartbeat-skipped");
16
17
  const CRON_SKIPPED = Symbol("cron-skipped");
18
+ const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 20_000;
17
19
  async function withReadyClient(token) {
18
20
  const client = new Client({
19
21
  intents: [
@@ -327,12 +329,19 @@ async function delayBetweenBurstChunks(config, channel) {
327
329
  function normalizeOutboundText(text) {
328
330
  return text.trim() || "(empty response)";
329
331
  }
332
+ function fallbackMimeType(name) {
333
+ return extname(name).toLowerCase() === ".mp3" ? "audio/mpeg" : "application/octet-stream";
334
+ }
330
335
  async function discordAttachmentPayload(attachment) {
331
336
  if (!attachment.localPath)
332
337
  return undefined;
338
+ const data = await readFile(attachment.localPath);
339
+ const bytes = new Uint8Array(data.byteLength);
340
+ bytes.set(data);
333
341
  return {
334
- attachment: await readFile(attachment.localPath),
342
+ bytes,
335
343
  name: attachment.name,
344
+ mimeType: attachment.mimeType || fallbackMimeType(attachment.name),
336
345
  };
337
346
  }
338
347
  async function discordAttachmentPayloads(attachments) {
@@ -344,19 +353,48 @@ async function discordAttachmentPayloads(attachments) {
344
353
  }
345
354
  return payloads;
346
355
  }
356
+ async function postDiscordAttachments(config, channelId, attachments) {
357
+ const files = await discordAttachmentPayloads(attachments);
358
+ if (files.length === 0)
359
+ return [];
360
+ const form = new FormData();
361
+ form.set("payload_json", JSON.stringify({}));
362
+ for (const [index, file] of files.entries()) {
363
+ form.set(`files[${index}]`, new Blob([file.bytes], { type: file.mimeType }), file.name);
364
+ }
365
+ const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
366
+ method: "POST",
367
+ headers: { Authorization: `Bot ${config.discord.token}` },
368
+ body: form,
369
+ });
370
+ const data = (await response.json().catch(() => ({})));
371
+ if (!response.ok || !data.id)
372
+ throw new Error(data.message || `Discord attachment send failed (${response.status})`);
373
+ return [data.id];
374
+ }
375
+ async function withDiscordSendTimeout(operation, label, timeoutMs = DISCORD_ATTACHMENT_SEND_TIMEOUT_MS) {
376
+ let timeout;
377
+ const timeoutPromise = new Promise((_, reject) => {
378
+ timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
379
+ });
380
+ try {
381
+ return await Promise.race([operation, timeoutPromise]);
382
+ }
383
+ finally {
384
+ if (timeout)
385
+ clearTimeout(timeout);
386
+ }
387
+ }
347
388
  export const __test = {
348
389
  discordAttachmentPayloads,
390
+ postDiscordAttachments,
391
+ withDiscordSendTimeout,
349
392
  };
350
393
  function parseAgentReply(text) {
351
- const normalized = text.replace(/\r\n/g, "\n").trim();
352
- if (normalized === SILENT_RESPONSE_MARKER) {
353
- return { text: "", silent: true };
354
- }
355
- if (normalized.startsWith(`${SILENT_RESPONSE_MARKER}\n`)) {
356
- const reason = normalized.slice(SILENT_RESPONSE_MARKER.length).trim();
357
- return { text: reason, silent: true };
358
- }
359
- return { text: normalizeOutboundText(text), silent: false };
394
+ const parsed = parseSilentMarker(text);
395
+ if (parsed.silent)
396
+ return parsed;
397
+ return { text: normalizeOutboundText(parsed.text), silent: false };
360
398
  }
361
399
  async function sendReply(config, message, text, replyToMessageId, attachments = []) {
362
400
  const normalizedText = normalizeOutboundText(text);
@@ -365,7 +403,6 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
365
403
  for (const [index, chunk] of chunks.entries()) {
366
404
  if (index > 0)
367
405
  await delayBetweenBurstChunks(config, message.channel);
368
- const files = index === 0 ? await discordAttachmentPayloads(attachments) : [];
369
406
  let sent;
370
407
  if (index === 0 && config.discord.replyMode === "reply") {
371
408
  try {
@@ -373,7 +410,7 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
373
410
  if (!message.channel.isSendable()) {
374
411
  throw new Error(`Discord channel is not sendable: ${message.channelId}`);
375
412
  }
376
- const options = { content: chunk, reply: { messageReference: replyTarget }, files };
413
+ const options = { content: chunk, reply: { messageReference: replyTarget } };
377
414
  sent = await message.channel.send(options);
378
415
  sentIds.push(sent.id);
379
416
  continue;
@@ -385,9 +422,10 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
385
422
  if (!message.channel.isSendable()) {
386
423
  throw new Error(`Discord channel is not sendable: ${message.channelId}`);
387
424
  }
388
- sent = await message.channel.send(files.length > 0 ? { content: chunk, files } : chunk);
425
+ sent = await message.channel.send(chunk);
389
426
  sentIds.push(sent.id);
390
427
  }
428
+ sendDiscordAttachmentsInBackground(config, message.channelId, attachments);
391
429
  return sentIds;
392
430
  }
393
431
  async function sendChannelMessage(config, channel, text, attachments = []) {
@@ -400,12 +438,19 @@ async function sendChannelMessage(config, channel, text, attachments = []) {
400
438
  for (const [index, chunk] of chunks.entries()) {
401
439
  if (index > 0)
402
440
  await delayBetweenBurstChunks(config, channel);
403
- const files = index === 0 ? await discordAttachmentPayloads(attachments) : [];
404
- const sent = await channel.send(files.length > 0 ? { content: chunk, files } : chunk);
441
+ const sent = await channel.send(chunk);
405
442
  sentIds.push(sent.id);
406
443
  }
444
+ sendDiscordAttachmentsInBackground(config, channel.id, attachments);
407
445
  return sentIds;
408
446
  }
447
+ function sendDiscordAttachmentsInBackground(config, channelId, attachments) {
448
+ if (attachments.length === 0)
449
+ return;
450
+ void withDiscordSendTimeout(postDiscordAttachments(config, channelId, attachments), "Discord attachment send").catch((error) => {
451
+ console.error("Discord attachment send failed", error);
452
+ });
453
+ }
409
454
  function buildChannelRef(channel, channelId) {
410
455
  const scope = channel.type === ChannelType.DM ? "dm" : channel.isThread() ? "thread" : "channel";
411
456
  const channelName = "name" in channel ? channel.name : undefined;
@@ -796,9 +841,13 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
796
841
  await recorder.flush();
797
842
  }
798
843
  const parsedReply = parseAgentReply(reply.text);
844
+ const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
799
845
  const messageIds = parsedReply.silent
800
846
  ? []
801
- : await sendReply(config, await fetchMessageAnchor(message, dispatch.triggerMessageId), parsedReply.text, dispatch.triggerMessageId, reply.attachments);
847
+ : await sendReply(config, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
848
+ if (parsedReply.silent) {
849
+ sendDiscordAttachmentsInBackground(config, replyAnchor.channelId, reply.attachments);
850
+ }
802
851
  await runtime.completeActiveJob({
803
852
  text: parsedReply.text,
804
853
  messageIds,
@@ -888,6 +937,8 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
888
937
  const messageIds = parsedReply.silent
889
938
  ? []
890
939
  : await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
940
+ if (parsedReply.silent)
941
+ sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
891
942
  await heartbeatRuntime.noteOutbound({
892
943
  text: parsedReply.text,
893
944
  messageIds,
@@ -1000,6 +1051,8 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
1000
1051
  const messageIds = parsedReply.silent
1001
1052
  ? []
1002
1053
  : await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
1054
+ if (parsedReply.silent)
1055
+ sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
1003
1056
  await runtime.noteOutbound({
1004
1057
  text: parsedReply.text,
1005
1058
  messageIds,
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, resolve } from "node:path";
4
4
  import { attachmentsDir, publicAttachmentPath } from "./generated-media.js";
5
5
  import { ensureInlineImageDerivative, MAX_INLINE_IMAGE_BASE64_BYTES } from "./image-derivatives.js";
@@ -184,6 +184,7 @@ export async function materializeInboundAttachments(config, inputs) {
184
184
  }
185
185
  const stored = [];
186
186
  const writtenPaths = [];
187
+ const existingDerivedPaths = await knownDerivedImagePaths(config);
187
188
  try {
188
189
  for (const attachment of prepared) {
189
190
  const dir = resolve(attachmentsDir(config), "inbound", attachment.source);
@@ -205,6 +206,10 @@ export async function materializeInboundAttachments(config, inputs) {
205
206
  };
206
207
  const derivedImage = await ensureInlineImageDerivative(config, finalAttachment);
207
208
  if (derivedImage) {
209
+ if (derivedImage.localPath && !existingDerivedPaths.has(derivedImage.localPath)) {
210
+ writtenPaths.push(derivedImage.localPath);
211
+ existingDerivedPaths.add(derivedImage.localPath);
212
+ }
208
213
  finalAttachment.derived = {
209
214
  ...finalAttachment.derived,
210
215
  image: derivedImage,
@@ -219,6 +224,11 @@ export async function materializeInboundAttachments(config, inputs) {
219
224
  throw error;
220
225
  }
221
226
  }
227
+ async function knownDerivedImagePaths(config) {
228
+ const dir = resolve(attachmentsDir(config), "derived", "image");
229
+ const entries = await readdir(dir).catch(() => []);
230
+ return new Set(entries.map((entry) => resolve(dir, entry)));
231
+ }
222
232
  export async function promptImagesFromAttachments(attachments) {
223
233
  const images = [];
224
234
  const notes = [];
@@ -15,8 +15,11 @@ export class ChunkIndexer {
15
15
  async replaceSource(corpus, sourceId, inputs, signal) {
16
16
  const prepared = this.prepare(inputs.map((input) => ({ ...input, corpus, sourceId })));
17
17
  const keepMappings = prepared.map((item) => ({ contentHash: item.contentHash, chunkIndex: item.chunkIndex }));
18
- this.store.deleteBySourceExceptMappings(corpus, sourceId, keepMappings);
19
- const result = await this.insertPrepared(prepared, inputs.length - prepared.length, signal);
18
+ const result = await this.insertPrepared(prepared, inputs.length - prepared.length, signal, {
19
+ corpus,
20
+ sourceId,
21
+ keepMappings,
22
+ });
20
23
  return result;
21
24
  }
22
25
  prepare(inputs) {
@@ -46,10 +49,14 @@ export class ChunkIndexer {
46
49
  }
47
50
  return prepared;
48
51
  }
49
- async insertPrepared(prepared, skipped, signal) {
52
+ async insertPrepared(prepared, skipped, signal, replaceSource) {
50
53
  const startedAt = Date.now();
51
- if (prepared.length === 0)
54
+ if (prepared.length === 0) {
55
+ if (replaceSource) {
56
+ this.store.deleteBySourceExceptMappings(replaceSource.corpus, replaceSource.sourceId, []);
57
+ }
52
58
  return { ids: [], embedded: 0, reused: 0, skipped };
59
+ }
53
60
  const present = this.store.whichHashesPresent(prepared.map((item) => item.contentHash));
54
61
  for (const item of prepared)
55
62
  item.existingId = present.get(item.contentHash) ?? null;
@@ -125,8 +132,18 @@ export class ChunkIndexer {
125
132
  embedding,
126
133
  });
127
134
  }
128
- this.store.recordSourceMappings(existingMappings);
129
- const insertedIds = this.store.insertChunks(toInsert);
135
+ let insertedIds = [];
136
+ const writeChunks = () => {
137
+ if (replaceSource) {
138
+ this.store.deleteBySourceExceptMappings(replaceSource.corpus, replaceSource.sourceId, replaceSource.keepMappings);
139
+ }
140
+ this.store.recordSourceMappings(existingMappings);
141
+ insertedIds = this.store.insertChunks(toInsert);
142
+ };
143
+ if (replaceSource && !this.store.db.inTransaction)
144
+ this.store.db.transaction(writeChunks).immediate();
145
+ else
146
+ writeChunks();
130
147
  for (let index = 0; index < insertPositions.length; index++) {
131
148
  ids[insertPositions[index]] = insertedIds[index];
132
149
  }
@@ -149,19 +149,15 @@ export class LcmContextTransformer {
149
149
  mode: candidate.reasons.includes("context_threshold") ? "aggressive" : "normal",
150
150
  previousSummary,
151
151
  }, input.signal);
152
- const summaryId = `${input.sessionKey}:summary-${++state.summaryCounter}`;
153
152
  const message = createSyntheticLcmSummaryMessage(renderLcmSummaryMessage(summaryText), this.now());
154
153
  const summaryItem = {
155
154
  type: "summary",
156
- id: summaryId,
155
+ id: "",
157
156
  sourceIds: chunkItems.map((item) => item.id),
158
157
  depth: 1,
159
158
  message,
160
159
  tokens: estimateAgentMessageTokens(message),
161
160
  };
162
- state.items.splice(startIndex, removeCount, summaryItem);
163
- compacted = true;
164
- tokensSaved = Math.max(0, candidate.chunkTokens - summaryItem.tokens);
165
161
  const persisted = await this.persistRuntimeSummary({
166
162
  text: summaryText,
167
163
  sourceItems: chunkItems,
@@ -169,8 +165,12 @@ export class LcmContextTransformer {
169
165
  sessionId: input.sessionId,
170
166
  signal: input.signal,
171
167
  });
168
+ summaryItem.id = `${input.sessionKey}:summary-${++state.summaryCounter}`;
172
169
  if (persisted?.summaryId !== undefined)
173
170
  summaryItem.persistedSummaryId = persisted.summaryId;
171
+ state.items.splice(startIndex, removeCount, summaryItem);
172
+ compacted = true;
173
+ tokensSaved = Math.max(0, candidate.chunkTokens - summaryItem.tokens);
174
174
  await this.condenseRuntimeSummaries({ state, sessionKey: input.sessionKey, signal: input.signal });
175
175
  };
176
176
  input.state.compactionQueue = input.state.compactionQueue.then(run, run);
@@ -221,39 +221,13 @@ async function prune(service, options) {
221
221
  console.log("Prune cancelled");
222
222
  return;
223
223
  }
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
224
  const report = service.lcmStore.applyNewSessionRetention({
237
225
  newSessionRetainDepth: options.retainDepth,
238
- activeSegmentId,
226
+ activeSegmentId: null,
239
227
  vacuum: options.vacuum,
240
228
  });
241
229
  for (const ref of report.indexDeletes)
242
230
  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
231
  console.log(`Pruned ${report.rawRecordsDeleted} raw record(s), ${report.summariesDeleted} summary row(s), ` +
258
232
  `${report.affectedSegments.length} closed segment(s) scanned`);
259
233
  }
package/dist/persona.js CHANGED
@@ -41,7 +41,7 @@ ${renderedFiles}
41
41
  <note_to_self>
42
42
  you can edit MEMORY.md when something about her is worth keeping.
43
43
  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.
44
+ when there's nothing worth saying, reply with exactly the literal string [[FAMILIAR_SILENT]]. quiet's a real choice.
45
45
  </note_to_self>
46
46
  ${renderedSkillsBlock}
47
47
  </system-reminder>`;
package/dist/runtime.js CHANGED
@@ -91,6 +91,8 @@ export class ConversationRuntime {
91
91
  for (const record of this.records) {
92
92
  if (record.type === "job_completed" || record.type === "job_failed")
93
93
  terminalJobIds.add(record.jobId);
94
+ if (record.type === "outbound" && record.jobId)
95
+ terminalJobIds.add(record.jobId);
94
96
  if (record.type === "job_queued") {
95
97
  queuedJobs.push({
96
98
  jobId: record.jobId,
@@ -180,9 +182,17 @@ export class ConversationRuntime {
180
182
  }
181
183
  getLastCompletedTriggerRecordId() {
182
184
  let last = 0;
185
+ const queuedTriggerRecordIds = new Map();
183
186
  for (const record of this.records) {
187
+ if (record.type === "job_queued")
188
+ queuedTriggerRecordIds.set(record.jobId, record.triggerRecordId);
184
189
  if (record.type === "job_completed")
185
190
  last = Math.max(last, record.triggerRecordId);
191
+ if (record.type === "outbound" && record.jobId) {
192
+ const triggerRecordId = queuedTriggerRecordIds.get(record.jobId);
193
+ if (triggerRecordId !== undefined)
194
+ last = Math.max(last, triggerRecordId);
195
+ }
186
196
  }
187
197
  return last;
188
198
  }
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
  }
@@ -153,8 +155,8 @@ async function runOptional(command, args, options) {
153
155
  // Best-effort cleanup for stale service registrations.
154
156
  }
155
157
  }
156
- function guiDomain() {
157
- return `gui/${userInfo().uid}`;
158
+ function guiDomain(options = {}) {
159
+ return `gui/${options.userId ?? userInfo().uid}`;
158
160
  }
159
161
  function unsupported(platformName) {
160
162
  return {
@@ -174,9 +176,9 @@ export async function installService(workspacePath, options = {}) {
174
176
  const serviceText = spec.platform === "darwin" ? launchdPlist(spec) : systemdUnit(spec);
175
177
  await writeFile(spec.paths.servicePath, serviceText, "utf8");
176
178
  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);
179
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
180
+ await run("launchctl", ["bootstrap", guiDomain(options), spec.paths.servicePath], options);
181
+ await run("launchctl", ["kickstart", "-k", `${guiDomain(options)}/${SERVICE_LABEL}`], options);
180
182
  }
181
183
  else {
182
184
  if (!(await hasCommand("systemctl", options))) {
@@ -204,7 +206,7 @@ export async function uninstallService(workspacePath, options = {}) {
204
206
  if (spec.platform !== "darwin" && spec.platform !== "linux")
205
207
  return unsupported(spec.platform);
206
208
  if (spec.platform === "darwin") {
207
- await runOptional("launchctl", ["bootout", guiDomain(), spec.paths.servicePath], options);
209
+ await runOptional("launchctl", ["bootout", guiDomain(options), spec.paths.servicePath], options);
208
210
  }
209
211
  else {
210
212
  if (await hasCommand("systemctl", options)) {
@@ -242,7 +244,7 @@ export async function serviceStatus(workspacePath, options = {}) {
242
244
  async function supervisorState(spec, options) {
243
245
  try {
244
246
  if (spec.platform === "darwin") {
245
- await capture("launchctl", ["print", `${guiDomain()}/${SERVICE_LABEL}`], options);
247
+ await capture("launchctl", ["print", `${guiDomain(options)}/${SERVICE_LABEL}`], options);
246
248
  return "loaded";
247
249
  }
248
250
  const state = (await capture("systemctl", ["--user", "is-active", SYSTEMD_SERVICE], options)).trim();
@@ -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
+ }
@@ -54,7 +54,29 @@ export async function serveStatic(response, requestPath) {
54
54
  stream.pipe(response);
55
55
  return true;
56
56
  }
57
- export async function serveAttachment(config, response, requestPath) {
57
+ function parseRangeHeader(rangeHeader, size) {
58
+ if (!rangeHeader)
59
+ return undefined;
60
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
61
+ if (!match)
62
+ return undefined;
63
+ const [, rawStart, rawEnd] = match;
64
+ if (!rawStart && !rawEnd)
65
+ return undefined;
66
+ if (!rawStart) {
67
+ const suffixLength = Number(rawEnd);
68
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
69
+ return undefined;
70
+ return { start: Math.max(0, size - suffixLength), end: size - 1 };
71
+ }
72
+ const start = Number(rawStart);
73
+ const end = rawEnd ? Number(rawEnd) : size - 1;
74
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
75
+ return undefined;
76
+ }
77
+ return { start, end: Math.min(end, size - 1) };
78
+ }
79
+ export async function serveAttachment(config, response, requestPath, rangeHeader) {
58
80
  const relativePath = decodeURIComponent(requestPath.replace(/^\/api\/web\/attachments\/?/, ""));
59
81
  if (!relativePath) {
60
82
  sendText(response, 404, "Not found");
@@ -93,9 +115,22 @@ export async function serveAttachment(config, response, requestPath) {
93
115
  const fileStat = await stat(fileRealPath).catch(() => undefined);
94
116
  if (!fileStat?.isFile())
95
117
  continue;
118
+ const range = parseRangeHeader(rangeHeader, fileStat.size);
119
+ if (range) {
120
+ response.writeHead(206, {
121
+ "content-type": mimeType(filePath),
122
+ "content-length": String(range.end - range.start + 1),
123
+ "content-range": `bytes ${range.start}-${range.end}/${fileStat.size}`,
124
+ "accept-ranges": "bytes",
125
+ "cache-control": "private, max-age=31536000, immutable",
126
+ });
127
+ createReadStream(fileRealPath, { start: range.start, end: range.end }).pipe(response);
128
+ return true;
129
+ }
96
130
  response.writeHead(200, {
97
131
  "content-type": mimeType(filePath),
98
132
  "content-length": String(fileStat.size),
133
+ "accept-ranges": "bytes",
99
134
  "cache-control": "private, max-age=31536000, immutable",
100
135
  });
101
136
  createReadStream(fileRealPath).pipe(response);