@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 +1 -12
- package/dist/browser-tools.js +3 -0
- package/dist/discord.js +70 -17
- 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,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
|
-
|
|
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
|
|
352
|
-
if (
|
|
353
|
-
return
|
|
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 }
|
|
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(
|
|
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
|
|
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,
|
|
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.
|
|
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);
|