@qearlyao/familiar 0.2.1 → 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 +1 -12
- package/dist/browser-tools.js +6 -1
- package/dist/discord.js +89 -15
- package/dist/inbound-attachments.js +11 -1
- package/dist/memory/index/chunk-indexer.js +23 -6
- package/dist/memory/lcm/context-transformer.js +5 -5
- package/dist/memory/operator.js +1 -27
- package/dist/persona.js +1 -1
- package/dist/runtime.js +10 -0
- package/dist/service.js +15 -13
- package/dist/silent-marker.js +64 -0
- package/dist/web-static.js +36 -1
- package/dist/web.js +77 -19
- package/npm-shrinkwrap.json +5139 -0
- package/package.json +5 -4
- package/web/dist/assets/{index-BPZQbZh5.js → index-C-w9fjBf.js} +1 -1
- package/web/dist/index.html +1 -1
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
|
package/dist/browser-tools.js
CHANGED
|
@@ -120,7 +120,12 @@ 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
|
-
|
|
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.
|
|
126
|
+
// cmd.exe strips one outer quote pair from the /c string. Wrap the whole
|
|
127
|
+
// already-quoted command so .cmd shims with spaced paths still receive argv.
|
|
128
|
+
args: ["/d", "/s", "/c", `"${commandLine}"`],
|
|
124
129
|
options: {
|
|
125
130
|
...options,
|
|
126
131
|
windowsVerbatimArguments: true,
|
package/dist/discord.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { once } from "node:events";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { extname } from "node:path";
|
|
3
5
|
import { ApplicationCommandOptionType, ApplicationCommandType, ApplicationIntegrationType, ChannelType, Client, Events, GatewayIntentBits, InteractionContextType, MessageFlags, Partials, } from "discord.js";
|
|
4
6
|
import { createAgentEventRecorder, storedAgentEventFromAgentEvent, thinkingDurationMs, updateAgentEventSummary, } from "./agent-events.js";
|
|
5
7
|
import { chatChannelKey, createChatLog } from "./chat-log.js";
|
|
6
8
|
import { materializeInboundAttachments, promptImagesFromAttachments } from "./inbound-attachments.js";
|
|
7
9
|
import { ConversationRuntime } from "./runtime.js";
|
|
8
10
|
import { appendSchedulerLog, buildCronInjectionText, buildHeartbeatInjectionText, dueCronSlot, isHeartbeatDue, loadSchedulerState, saveSchedulerState, } from "./scheduler.js";
|
|
11
|
+
import { parseAgentReply as parseSilentMarker } from "./silent-marker.js";
|
|
9
12
|
const FAMILIAR_COMMAND_NAME = "familiar";
|
|
10
13
|
const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
11
14
|
const CHANNEL_TRIGGER_CHOICES = ["mention", "always"];
|
|
12
15
|
const EPHEMERAL_REPLY = MessageFlags.Ephemeral;
|
|
13
|
-
const SILENT_RESPONSE_MARKER = "[[FAMILIAR_SILENT]]";
|
|
14
16
|
const HEARTBEAT_SKIPPED = Symbol("heartbeat-skipped");
|
|
15
17
|
const CRON_SKIPPED = Symbol("cron-skipped");
|
|
18
|
+
const DISCORD_ATTACHMENT_SEND_TIMEOUT_MS = 20_000;
|
|
16
19
|
async function withReadyClient(token) {
|
|
17
20
|
const client = new Client({
|
|
18
21
|
intents: [
|
|
@@ -326,16 +329,72 @@ async function delayBetweenBurstChunks(config, channel) {
|
|
|
326
329
|
function normalizeOutboundText(text) {
|
|
327
330
|
return text.trim() || "(empty response)";
|
|
328
331
|
}
|
|
329
|
-
function
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
332
|
+
function fallbackMimeType(name) {
|
|
333
|
+
return extname(name).toLowerCase() === ".mp3" ? "audio/mpeg" : "application/octet-stream";
|
|
334
|
+
}
|
|
335
|
+
async function discordAttachmentPayload(attachment) {
|
|
336
|
+
if (!attachment.localPath)
|
|
337
|
+
return undefined;
|
|
338
|
+
const data = await readFile(attachment.localPath);
|
|
339
|
+
const bytes = new Uint8Array(data.byteLength);
|
|
340
|
+
bytes.set(data);
|
|
341
|
+
return {
|
|
342
|
+
bytes,
|
|
343
|
+
name: attachment.name,
|
|
344
|
+
mimeType: attachment.mimeType || fallbackMimeType(attachment.name),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async function discordAttachmentPayloads(attachments) {
|
|
348
|
+
const payloads = [];
|
|
349
|
+
for (const attachment of attachments) {
|
|
350
|
+
const payload = await discordAttachmentPayload(attachment);
|
|
351
|
+
if (payload)
|
|
352
|
+
payloads.push(payload);
|
|
333
353
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
354
|
+
return payloads;
|
|
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);
|
|
337
364
|
}
|
|
338
|
-
|
|
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
|
+
}
|
|
388
|
+
export const __test = {
|
|
389
|
+
discordAttachmentPayloads,
|
|
390
|
+
postDiscordAttachments,
|
|
391
|
+
withDiscordSendTimeout,
|
|
392
|
+
};
|
|
393
|
+
function parseAgentReply(text) {
|
|
394
|
+
const parsed = parseSilentMarker(text);
|
|
395
|
+
if (parsed.silent)
|
|
396
|
+
return parsed;
|
|
397
|
+
return { text: normalizeOutboundText(parsed.text), silent: false };
|
|
339
398
|
}
|
|
340
399
|
async function sendReply(config, message, text, replyToMessageId, attachments = []) {
|
|
341
400
|
const normalizedText = normalizeOutboundText(text);
|
|
@@ -344,7 +403,6 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
|
|
|
344
403
|
for (const [index, chunk] of chunks.entries()) {
|
|
345
404
|
if (index > 0)
|
|
346
405
|
await delayBetweenBurstChunks(config, message.channel);
|
|
347
|
-
const files = index === 0 ? attachments.flatMap((attachment) => (attachment.localPath ? [attachment.localPath] : [])) : [];
|
|
348
406
|
let sent;
|
|
349
407
|
if (index === 0 && config.discord.replyMode === "reply") {
|
|
350
408
|
try {
|
|
@@ -352,7 +410,7 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
|
|
|
352
410
|
if (!message.channel.isSendable()) {
|
|
353
411
|
throw new Error(`Discord channel is not sendable: ${message.channelId}`);
|
|
354
412
|
}
|
|
355
|
-
const options = { content: chunk, reply: { messageReference: replyTarget }
|
|
413
|
+
const options = { content: chunk, reply: { messageReference: replyTarget } };
|
|
356
414
|
sent = await message.channel.send(options);
|
|
357
415
|
sentIds.push(sent.id);
|
|
358
416
|
continue;
|
|
@@ -364,9 +422,10 @@ async function sendReply(config, message, text, replyToMessageId, attachments =
|
|
|
364
422
|
if (!message.channel.isSendable()) {
|
|
365
423
|
throw new Error(`Discord channel is not sendable: ${message.channelId}`);
|
|
366
424
|
}
|
|
367
|
-
sent = await message.channel.send(
|
|
425
|
+
sent = await message.channel.send(chunk);
|
|
368
426
|
sentIds.push(sent.id);
|
|
369
427
|
}
|
|
428
|
+
sendDiscordAttachmentsInBackground(config, message.channelId, attachments);
|
|
370
429
|
return sentIds;
|
|
371
430
|
}
|
|
372
431
|
async function sendChannelMessage(config, channel, text, attachments = []) {
|
|
@@ -379,12 +438,19 @@ async function sendChannelMessage(config, channel, text, attachments = []) {
|
|
|
379
438
|
for (const [index, chunk] of chunks.entries()) {
|
|
380
439
|
if (index > 0)
|
|
381
440
|
await delayBetweenBurstChunks(config, channel);
|
|
382
|
-
const
|
|
383
|
-
const sent = await channel.send(files.length > 0 ? { content: chunk, files } : chunk);
|
|
441
|
+
const sent = await channel.send(chunk);
|
|
384
442
|
sentIds.push(sent.id);
|
|
385
443
|
}
|
|
444
|
+
sendDiscordAttachmentsInBackground(config, channel.id, attachments);
|
|
386
445
|
return sentIds;
|
|
387
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
|
+
}
|
|
388
454
|
function buildChannelRef(channel, channelId) {
|
|
389
455
|
const scope = channel.type === ChannelType.DM ? "dm" : channel.isThread() ? "thread" : "channel";
|
|
390
456
|
const channelName = "name" in channel ? channel.name : undefined;
|
|
@@ -775,9 +841,13 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
|
|
|
775
841
|
await recorder.flush();
|
|
776
842
|
}
|
|
777
843
|
const parsedReply = parseAgentReply(reply.text);
|
|
844
|
+
const replyAnchor = await fetchMessageAnchor(message, dispatch.triggerMessageId);
|
|
778
845
|
const messageIds = parsedReply.silent
|
|
779
846
|
? []
|
|
780
|
-
: await sendReply(config,
|
|
847
|
+
: await sendReply(config, replyAnchor, parsedReply.text, dispatch.triggerMessageId, reply.attachments);
|
|
848
|
+
if (parsedReply.silent) {
|
|
849
|
+
sendDiscordAttachmentsInBackground(config, replyAnchor.channelId, reply.attachments);
|
|
850
|
+
}
|
|
781
851
|
await runtime.completeActiveJob({
|
|
782
852
|
text: parsedReply.text,
|
|
783
853
|
messageIds,
|
|
@@ -867,6 +937,8 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
|
|
|
867
937
|
const messageIds = parsedReply.silent
|
|
868
938
|
? []
|
|
869
939
|
: await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
|
|
940
|
+
if (parsedReply.silent)
|
|
941
|
+
sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
|
|
870
942
|
await heartbeatRuntime.noteOutbound({
|
|
871
943
|
text: parsedReply.text,
|
|
872
944
|
messageIds,
|
|
@@ -979,6 +1051,8 @@ export async function startDiscordDaemon(config, familiarAgent, settings, memory
|
|
|
979
1051
|
const messageIds = parsedReply.silent
|
|
980
1052
|
? []
|
|
981
1053
|
: await sendChannelMessage(config, channel, parsedReply.text, reply.attachments);
|
|
1054
|
+
if (parsedReply.silent)
|
|
1055
|
+
sendDiscordAttachmentsInBackground(config, channel.id, reply.attachments);
|
|
982
1056
|
await runtime.noteOutbound({
|
|
983
1057
|
text: parsedReply.text,
|
|
984
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.
|
|
19
|
-
|
|
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
|
-
|
|
129
|
-
const
|
|
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:
|
|
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);
|
package/dist/memory/operator.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
11
|
+
const logDir = input.resolvePath(workspacePath, "logs");
|
|
12
12
|
return {
|
|
13
13
|
servicePath: input.platform === "darwin"
|
|
14
|
-
?
|
|
15
|
-
:
|
|
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:
|
|
18
|
-
stderrPath:
|
|
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
|
|
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
|
+
}
|
package/dist/web-static.js
CHANGED
|
@@ -54,7 +54,29 @@ export async function serveStatic(response, requestPath) {
|
|
|
54
54
|
stream.pipe(response);
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
|
-
|
|
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);
|