@qearlyao/familiar 0.1.0
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/.env.example +31 -0
- package/HEARTBEAT.md +23 -0
- package/LICENSE +21 -0
- package/MEMORY.md +1 -0
- package/README.md +245 -0
- package/SOUL.md +13 -0
- package/USER.md +13 -0
- package/config.example.toml +221 -0
- package/dist/agent-events.js +167 -0
- package/dist/agent.js +590 -0
- package/dist/browser-tools.js +638 -0
- package/dist/chat-log.js +130 -0
- package/dist/cli.js +168 -0
- package/dist/config.js +804 -0
- package/dist/data-retention.js +54 -0
- package/dist/discord.js +1203 -0
- package/dist/generated-media.js +86 -0
- package/dist/image-derivatives.js +102 -0
- package/dist/image-gen.js +440 -0
- package/dist/inbound-attachments.js +266 -0
- package/dist/index.js +10 -0
- package/dist/media-understanding.js +120 -0
- package/dist/memory/diary/ambient-injector.js +180 -0
- package/dist/memory/diary/ambient.js +124 -0
- package/dist/memory/diary/chunks.js +231 -0
- package/dist/memory/diary/index.js +3 -0
- package/dist/memory/diary/indexer.js +93 -0
- package/dist/memory/doctor.js +250 -0
- package/dist/memory/index/chunk-indexer.js +151 -0
- package/dist/memory/index/embedding-provider.js +119 -0
- package/dist/memory/index/fts-query.js +18 -0
- package/dist/memory/index/retrieval.js +246 -0
- package/dist/memory/index/schema.js +157 -0
- package/dist/memory/index/store.js +513 -0
- package/dist/memory/index/vec.js +72 -0
- package/dist/memory/index/vector-codec.js +27 -0
- package/dist/memory/lcm/backfill.js +247 -0
- package/dist/memory/lcm/condense.js +146 -0
- package/dist/memory/lcm/context-transformer.js +662 -0
- package/dist/memory/lcm/context.js +421 -0
- package/dist/memory/lcm/eviction-score.js +38 -0
- package/dist/memory/lcm/index.js +6 -0
- package/dist/memory/lcm/indexer.js +200 -0
- package/dist/memory/lcm/normalize.js +235 -0
- package/dist/memory/lcm/schema.js +188 -0
- package/dist/memory/lcm/segment-manager.js +136 -0
- package/dist/memory/lcm/store.js +722 -0
- package/dist/memory/lcm/summarizer.js +258 -0
- package/dist/memory/lcm/types.js +1 -0
- package/dist/memory/operator.js +477 -0
- package/dist/memory/service.js +202 -0
- package/dist/memory/tools.js +205 -0
- package/dist/models.js +165 -0
- package/dist/persona.js +54 -0
- package/dist/runtime.js +493 -0
- package/dist/scheduler.js +200 -0
- package/dist/settings.js +116 -0
- package/dist/skills.js +38 -0
- package/dist/tts.js +143 -0
- package/dist/web-auth.js +105 -0
- package/dist/web-events.js +114 -0
- package/dist/web-http.js +29 -0
- package/dist/web-static.js +106 -0
- package/dist/web-tools.js +940 -0
- package/dist/web-types.js +2 -0
- package/dist/web.js +844 -0
- package/package.json +60 -0
- package/web/dist/assets/index-ClgkMgaq.css +2 -0
- package/web/dist/assets/index-Cu2QquuR.js +59 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/icons.svg +24 -0
- package/web/dist/index.html +20 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { attachmentsDir, publicAttachmentPath } from "./generated-media.js";
|
|
5
|
+
import { ensureInlineImageDerivative, MAX_INLINE_IMAGE_BASE64_BYTES } from "./image-derivatives.js";
|
|
6
|
+
import { deriveInboundAttachmentText } from "./media-understanding.js";
|
|
7
|
+
export { MAX_INLINE_IMAGE_BASE64_BYTES } from "./image-derivatives.js";
|
|
8
|
+
export const MAX_INBOUND_ATTACHMENTS = 4;
|
|
9
|
+
export const MAX_INBOUND_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
10
|
+
export const MAX_INBOUND_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
11
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/gif",
|
|
15
|
+
"image/webp",
|
|
16
|
+
"audio/mpeg",
|
|
17
|
+
"audio/ogg",
|
|
18
|
+
"audio/wav",
|
|
19
|
+
"audio/webm",
|
|
20
|
+
"video/mp4",
|
|
21
|
+
"video/webm",
|
|
22
|
+
"application/pdf",
|
|
23
|
+
"text/plain",
|
|
24
|
+
]);
|
|
25
|
+
const EXTENSIONS_BY_MIME = {
|
|
26
|
+
"image/jpeg": ".jpg",
|
|
27
|
+
"image/png": ".png",
|
|
28
|
+
"image/gif": ".gif",
|
|
29
|
+
"image/webp": ".webp",
|
|
30
|
+
"audio/mpeg": ".mp3",
|
|
31
|
+
"audio/ogg": ".ogg",
|
|
32
|
+
"audio/wav": ".wav",
|
|
33
|
+
"audio/webm": ".webm",
|
|
34
|
+
"video/mp4": ".mp4",
|
|
35
|
+
"video/webm": ".webm",
|
|
36
|
+
"application/pdf": ".pdf",
|
|
37
|
+
"text/plain": ".txt",
|
|
38
|
+
};
|
|
39
|
+
function safeName(name, fallback) {
|
|
40
|
+
const base = basename(name || fallback)
|
|
41
|
+
.replace(/[^A-Za-z0-9._=-]+/g, "_")
|
|
42
|
+
.slice(0, 120);
|
|
43
|
+
return base || fallback;
|
|
44
|
+
}
|
|
45
|
+
function kindFromMime(mimeType) {
|
|
46
|
+
if (mimeType.startsWith("image/"))
|
|
47
|
+
return "image";
|
|
48
|
+
if (mimeType.startsWith("audio/"))
|
|
49
|
+
return "audio";
|
|
50
|
+
if (mimeType.startsWith("video/"))
|
|
51
|
+
return "video";
|
|
52
|
+
return "file";
|
|
53
|
+
}
|
|
54
|
+
function sniffText(buffer) {
|
|
55
|
+
if (buffer.length === 0)
|
|
56
|
+
return "text/plain";
|
|
57
|
+
const head = buffer.subarray(0, Math.min(buffer.length, 512));
|
|
58
|
+
if (head.includes(0))
|
|
59
|
+
return undefined;
|
|
60
|
+
return head.every((byte) => byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126))
|
|
61
|
+
? "text/plain"
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
function sniffMimeType(buffer, declared) {
|
|
65
|
+
let detected;
|
|
66
|
+
if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
|
|
67
|
+
detected = "image/jpeg";
|
|
68
|
+
else if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
69
|
+
detected = "image/png";
|
|
70
|
+
}
|
|
71
|
+
else if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
72
|
+
buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
|
|
73
|
+
detected = "image/gif";
|
|
74
|
+
}
|
|
75
|
+
else if (buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
76
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
77
|
+
detected = "image/webp";
|
|
78
|
+
}
|
|
79
|
+
else if (buffer.subarray(0, 4).toString("ascii") === "%PDF")
|
|
80
|
+
detected = "application/pdf";
|
|
81
|
+
else if (buffer.subarray(0, 3).toString("ascii") === "ID3" ||
|
|
82
|
+
buffer.subarray(0, 2).equals(Buffer.from([0xff, 0xfb]))) {
|
|
83
|
+
detected = "audio/mpeg";
|
|
84
|
+
}
|
|
85
|
+
else if (buffer.subarray(0, 4).toString("ascii") === "OggS")
|
|
86
|
+
detected = "audio/ogg";
|
|
87
|
+
else if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
|
|
88
|
+
detected = "audio/wav";
|
|
89
|
+
}
|
|
90
|
+
const mime = detected ?? sniffText(buffer) ?? declared;
|
|
91
|
+
if (!mime || !ALLOWED_MIME_TYPES.has(mime)) {
|
|
92
|
+
throw new Error(`Unsupported attachment type: ${declared || detected || "unknown"}`);
|
|
93
|
+
}
|
|
94
|
+
return mime;
|
|
95
|
+
}
|
|
96
|
+
function canonicalName(name, fallbackStem, mimeType) {
|
|
97
|
+
const current = safeName(name, fallbackStem);
|
|
98
|
+
const stem = current.replace(/\.[^.]+$/, "");
|
|
99
|
+
return `${stem}${EXTENSIONS_BY_MIME[mimeType] || extname(current) || ""}`;
|
|
100
|
+
}
|
|
101
|
+
async function fetchRemoteAttachment(input, signal) {
|
|
102
|
+
if (!input.url)
|
|
103
|
+
throw new Error("Attachment URL is required");
|
|
104
|
+
const response = await fetch(input.url, { signal });
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
throw new Error(`Attachment download failed: HTTP ${response.status}`);
|
|
107
|
+
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
108
|
+
if (contentLength > MAX_INBOUND_ATTACHMENT_BYTES) {
|
|
109
|
+
throw new Error(`Attachment is too large: ${contentLength} bytes`);
|
|
110
|
+
}
|
|
111
|
+
const reader = response.body?.getReader();
|
|
112
|
+
if (!reader) {
|
|
113
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
114
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
115
|
+
if (buffer.byteLength > MAX_INBOUND_ATTACHMENT_BYTES) {
|
|
116
|
+
throw new Error(`Attachment is too large: ${buffer.byteLength} bytes`);
|
|
117
|
+
}
|
|
118
|
+
return buffer;
|
|
119
|
+
}
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let total = 0;
|
|
122
|
+
try {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const { done, value } = await reader.read();
|
|
125
|
+
if (done)
|
|
126
|
+
break;
|
|
127
|
+
const chunk = Buffer.from(value);
|
|
128
|
+
total += chunk.byteLength;
|
|
129
|
+
if (total > MAX_INBOUND_ATTACHMENT_BYTES) {
|
|
130
|
+
throw new Error(`Attachment is too large: ${total} bytes`);
|
|
131
|
+
}
|
|
132
|
+
chunks.push(chunk);
|
|
133
|
+
}
|
|
134
|
+
return Buffer.concat(chunks);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
reader.releaseLock();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function attachmentBuffer(input) {
|
|
141
|
+
if (input.buffer) {
|
|
142
|
+
if (input.buffer.byteLength > MAX_INBOUND_ATTACHMENT_BYTES) {
|
|
143
|
+
throw new Error(`Attachment is too large: ${input.buffer.byteLength} bytes`);
|
|
144
|
+
}
|
|
145
|
+
return input.buffer;
|
|
146
|
+
}
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
149
|
+
try {
|
|
150
|
+
return await fetchRemoteAttachment(input, controller.signal);
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function materializeInboundAttachments(config, inputs) {
|
|
157
|
+
if (inputs.length > MAX_INBOUND_ATTACHMENTS) {
|
|
158
|
+
throw new Error(`Too many attachments: max ${MAX_INBOUND_ATTACHMENTS}`);
|
|
159
|
+
}
|
|
160
|
+
const prepared = [];
|
|
161
|
+
let totalBytes = 0;
|
|
162
|
+
for (const [index, input] of inputs.entries()) {
|
|
163
|
+
const buffer = await attachmentBuffer(input);
|
|
164
|
+
totalBytes += buffer.byteLength;
|
|
165
|
+
if (totalBytes > MAX_INBOUND_TOTAL_BYTES) {
|
|
166
|
+
throw new Error(`Attachments are too large: max ${MAX_INBOUND_TOTAL_BYTES} bytes total`);
|
|
167
|
+
}
|
|
168
|
+
const mimeType = sniffMimeType(buffer, input.mimeType);
|
|
169
|
+
const id = input.id || randomUUID();
|
|
170
|
+
const cleanName = canonicalName(input.name, `attachment-${index + 1}`, mimeType);
|
|
171
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
172
|
+
prepared.push({
|
|
173
|
+
id,
|
|
174
|
+
name: cleanName,
|
|
175
|
+
kind: kindFromMime(mimeType),
|
|
176
|
+
mimeType,
|
|
177
|
+
size: buffer.byteLength,
|
|
178
|
+
sha256,
|
|
179
|
+
buffer,
|
|
180
|
+
remoteUrl: input.url,
|
|
181
|
+
sourceUrl: input.url,
|
|
182
|
+
source: input.source,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const stored = [];
|
|
186
|
+
const writtenPaths = [];
|
|
187
|
+
try {
|
|
188
|
+
for (const attachment of prepared) {
|
|
189
|
+
const dir = resolve(attachmentsDir(config), "inbound", attachment.source);
|
|
190
|
+
await mkdir(dir, { recursive: true });
|
|
191
|
+
const localPath = resolve(dir, `${Date.now()}-${attachment.id}-${attachment.name}`);
|
|
192
|
+
await writeFile(localPath, attachment.buffer);
|
|
193
|
+
writtenPaths.push(localPath);
|
|
194
|
+
const finalAttachment = {
|
|
195
|
+
id: attachment.id,
|
|
196
|
+
name: attachment.name,
|
|
197
|
+
kind: attachment.kind,
|
|
198
|
+
mimeType: attachment.mimeType,
|
|
199
|
+
size: attachment.size,
|
|
200
|
+
remoteUrl: attachment.remoteUrl,
|
|
201
|
+
sourceUrl: attachment.sourceUrl,
|
|
202
|
+
localPath,
|
|
203
|
+
source: attachment.source,
|
|
204
|
+
sha256: attachment.sha256,
|
|
205
|
+
};
|
|
206
|
+
const derivedImage = await ensureInlineImageDerivative(config, finalAttachment);
|
|
207
|
+
if (derivedImage) {
|
|
208
|
+
finalAttachment.derived = {
|
|
209
|
+
...finalAttachment.derived,
|
|
210
|
+
image: derivedImage,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
stored.push(finalAttachment);
|
|
214
|
+
}
|
|
215
|
+
return await deriveInboundAttachmentText(config, stored);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
await Promise.all(writtenPaths.map((path) => unlink(path).catch(() => undefined)));
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function promptImagesFromAttachments(attachments) {
|
|
223
|
+
const images = [];
|
|
224
|
+
const notes = [];
|
|
225
|
+
for (const attachment of attachments) {
|
|
226
|
+
if (!attachment.localPath || !attachment.mimeType?.startsWith("image/"))
|
|
227
|
+
continue;
|
|
228
|
+
if (attachment.kind && attachment.kind !== "image")
|
|
229
|
+
continue;
|
|
230
|
+
const imageMeta = attachment.derived?.image;
|
|
231
|
+
const imagePath = imageMeta?.localPath || attachment.localPath;
|
|
232
|
+
const data = (await readFile(imagePath)).toString("base64");
|
|
233
|
+
if (Buffer.byteLength(data, "utf8") > MAX_INLINE_IMAGE_BASE64_BYTES) {
|
|
234
|
+
notes.push(`<attachment name="${attachment.name}" mime="${attachment.mimeType}">[Image omitted from model input: inline payload is too large.]</attachment>`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
images.push({
|
|
238
|
+
type: "image",
|
|
239
|
+
mimeType: imageMeta?.mimeType ?? attachment.mimeType,
|
|
240
|
+
data,
|
|
241
|
+
});
|
|
242
|
+
const detail = imageMeta?.note ? ` ${imageMeta.note}` : "";
|
|
243
|
+
notes.push(`<attachment name="${attachment.name}" mime="${attachment.mimeType}">${detail}</attachment>`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
images,
|
|
247
|
+
promptSuffix: notes.join("\n"),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function promptAttachmentNotes(attachments) {
|
|
251
|
+
return attachments
|
|
252
|
+
.map((attachment) => {
|
|
253
|
+
const attrs = `name="${attachment.name}" id="${attachment.id}" kind="${attachment.kind ?? "file"}" mime="${attachment.mimeType ?? "unknown"}" size="${attachment.size ?? "unknown"}"`;
|
|
254
|
+
const derivedText = attachment.derived?.text?.text;
|
|
255
|
+
if (derivedText) {
|
|
256
|
+
const label = attachment.derived?.text?.label || (attachment.kind === "audio" ? "transcription" : "summary");
|
|
257
|
+
return `<attachment ${attrs}>[${label}: ${derivedText}]</attachment>`;
|
|
258
|
+
}
|
|
259
|
+
return `<attachment ${attrs}></attachment>`;
|
|
260
|
+
})
|
|
261
|
+
.join("\n")
|
|
262
|
+
.trim();
|
|
263
|
+
}
|
|
264
|
+
export function publicInboundAttachmentPath(config, localPath) {
|
|
265
|
+
return publicAttachmentPath(config, localPath);
|
|
266
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createFamiliarAgent } from "./agent.js";
|
|
2
|
+
export { buildRecordBase, chatChannelKey, chatLogPath, createChatLog, } from "./chat-log.js";
|
|
3
|
+
export { loadConfig } from "./config.js";
|
|
4
|
+
export { startDiscordDaemon } from "./discord.js";
|
|
5
|
+
export { clampConfiguredThinkingLevel, createConfiguredModel, describeModelAuth, formatAllowedModels, isAllowedModel, isThinkingLevel, parseModelRef, resolveModel, resolveModelApiKey, supportedThinkingLevels, } from "./models.js";
|
|
6
|
+
export { buildSystemPrompt, loadPersona } from "./persona.js";
|
|
7
|
+
export { ConversationRuntime, } from "./runtime.js";
|
|
8
|
+
export { loadSettingsStore, } from "./settings.js";
|
|
9
|
+
export { formatFamiliarSkillsForPrompt, loadFamiliarSkills } from "./skills.js";
|
|
10
|
+
export { startWebDaemon } from "./web.js";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createPartFromBase64, createUserContent, GoogleGenAI } from "@google/genai";
|
|
3
|
+
import { parseModelRef, resolveModel } from "./models.js";
|
|
4
|
+
const GEMINI_API_VERSION_PATTERN = /\/(v1(?:beta|alpha)?|v\d+beta\d*)\/?$/;
|
|
5
|
+
function normalizeDerivedText(text) {
|
|
6
|
+
return text.trim().replace(/\n{3,}/g, "\n\n");
|
|
7
|
+
}
|
|
8
|
+
function labelForAttachment(kind) {
|
|
9
|
+
if (kind === "audio")
|
|
10
|
+
return "transcription";
|
|
11
|
+
if (kind === "video")
|
|
12
|
+
return "summary";
|
|
13
|
+
return "text";
|
|
14
|
+
}
|
|
15
|
+
function geminiHttpOptions(config) {
|
|
16
|
+
const ref = parseModelRef(`google/${config.mediaUnderstanding.video.model}`);
|
|
17
|
+
const model = ref ? resolveModel(ref, config) : undefined;
|
|
18
|
+
const baseUrl = model?.baseUrl;
|
|
19
|
+
if (!baseUrl)
|
|
20
|
+
return { timeout: 60_000 };
|
|
21
|
+
const match = baseUrl.match(GEMINI_API_VERSION_PATTERN);
|
|
22
|
+
if (!match)
|
|
23
|
+
return { baseUrl, timeout: 60_000 };
|
|
24
|
+
return {
|
|
25
|
+
baseUrl: baseUrl.slice(0, match.index),
|
|
26
|
+
apiVersion: match[1],
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function transcribeAudioAttachment(config, attachment) {
|
|
31
|
+
if (!attachment.localPath || !attachment.mimeType?.startsWith("audio/"))
|
|
32
|
+
return undefined;
|
|
33
|
+
const apiKey = process.env[config.mediaUnderstanding.audio.apiKeyEnv];
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
console.warn(`media understanding skipped: ${config.mediaUnderstanding.audio.apiKeyEnv} is not set`);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const form = new FormData();
|
|
39
|
+
form.set("model", config.mediaUnderstanding.audio.model);
|
|
40
|
+
form.set("file", new Blob([await readFile(attachment.localPath)], { type: attachment.mimeType }), attachment.name);
|
|
41
|
+
const response = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${apiKey}`,
|
|
45
|
+
},
|
|
46
|
+
body: form,
|
|
47
|
+
signal: AbortSignal.timeout(30_000),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
throw new Error(`Groq transcription failed: HTTP ${response.status}`);
|
|
51
|
+
const parsed = (await response.json());
|
|
52
|
+
const text = parsed.text?.trim();
|
|
53
|
+
if (!text)
|
|
54
|
+
return undefined;
|
|
55
|
+
return {
|
|
56
|
+
provider: "groq",
|
|
57
|
+
model: config.mediaUnderstanding.audio.model,
|
|
58
|
+
text: normalizeDerivedText(text),
|
|
59
|
+
label: labelForAttachment(attachment.kind),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function summarizeVideoAttachment(config, attachment) {
|
|
63
|
+
if (!attachment.localPath || !attachment.mimeType?.startsWith("video/"))
|
|
64
|
+
return undefined;
|
|
65
|
+
const apiKey = process.env[config.mediaUnderstanding.video.apiKeyEnv];
|
|
66
|
+
if (!apiKey) {
|
|
67
|
+
console.warn(`media understanding skipped: ${config.mediaUnderstanding.video.apiKeyEnv} is not set`);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const ai = new GoogleGenAI({ apiKey, httpOptions: geminiHttpOptions(config) });
|
|
71
|
+
const video = await readFile(attachment.localPath);
|
|
72
|
+
const response = await ai.models.generateContent({
|
|
73
|
+
model: config.mediaUnderstanding.video.model,
|
|
74
|
+
contents: createUserContent([
|
|
75
|
+
{
|
|
76
|
+
text: "Provide a concise description of this video, including any spoken content if present, and summarize the key visible events.",
|
|
77
|
+
},
|
|
78
|
+
createPartFromBase64(video.toString("base64"), attachment.mimeType),
|
|
79
|
+
]),
|
|
80
|
+
});
|
|
81
|
+
const text = response.text?.trim();
|
|
82
|
+
if (!text)
|
|
83
|
+
return undefined;
|
|
84
|
+
return {
|
|
85
|
+
provider: "google",
|
|
86
|
+
model: config.mediaUnderstanding.video.model,
|
|
87
|
+
text: normalizeDerivedText(text),
|
|
88
|
+
label: labelForAttachment(attachment.kind),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export async function deriveInboundAttachmentText(config, attachments) {
|
|
92
|
+
const next = [];
|
|
93
|
+
for (const attachment of attachments) {
|
|
94
|
+
if (attachment.derived?.text || !attachment.localPath) {
|
|
95
|
+
next.push(attachment);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (attachment.mimeType?.startsWith("audio/")) {
|
|
100
|
+
const text = await transcribeAudioAttachment(config, attachment);
|
|
101
|
+
if (text) {
|
|
102
|
+
next.push({ ...attachment, derived: { ...(attachment.derived ?? {}), text } });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (attachment.mimeType?.startsWith("video/")) {
|
|
107
|
+
const text = await summarizeVideoAttachment(config, attachment);
|
|
108
|
+
if (text) {
|
|
109
|
+
next.push({ ...attachment, derived: { ...(attachment.derived ?? {}), text } });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.error("media understanding failed", error);
|
|
116
|
+
}
|
|
117
|
+
next.push(attachment);
|
|
118
|
+
}
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { retrieveAmbientDiary } from "./ambient.js";
|
|
2
|
+
const INJECTED_MEMORY_OPEN = "<injected_memory>";
|
|
3
|
+
const INJECTED_MEMORY_CLOSE = "</injected_memory>";
|
|
4
|
+
const INJECTED_MEMORY_BLOCK_RE = /<injected_memory\b[^>]*>[\s\S]*?<\/injected_memory>/gi;
|
|
5
|
+
export class AmbientDiaryInjector {
|
|
6
|
+
enabled;
|
|
7
|
+
store;
|
|
8
|
+
embeddingProvider;
|
|
9
|
+
topK;
|
|
10
|
+
minQueryLength;
|
|
11
|
+
throttleMs;
|
|
12
|
+
weightSimilarity;
|
|
13
|
+
weightValence;
|
|
14
|
+
weightRecency;
|
|
15
|
+
weightIntensity;
|
|
16
|
+
now;
|
|
17
|
+
lastInjectedAtBySession = new Map();
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.enabled = options.enabled ?? true;
|
|
20
|
+
this.store = options.store;
|
|
21
|
+
this.embeddingProvider = options.embeddingProvider;
|
|
22
|
+
this.topK = positiveIntegerOrDefault(options.topK, 3);
|
|
23
|
+
this.minQueryLength = nonNegativeIntegerOrDefault(options.minQueryLength, 8);
|
|
24
|
+
this.throttleMs = nonNegativeIntegerOrDefault(options.throttleSeconds, 30) * 1000;
|
|
25
|
+
this.weightSimilarity = nonNegativeNumberOrDefault(options.weightSimilarity, 1.0);
|
|
26
|
+
this.weightValence = nonNegativeNumberOrDefault(options.weightValence, 0.08);
|
|
27
|
+
this.weightRecency = nonNegativeNumberOrDefault(options.weightRecency, 0.08);
|
|
28
|
+
this.weightIntensity = nonNegativeNumberOrDefault(options.weightIntensity, 0.1);
|
|
29
|
+
this.now = options.now ?? Date.now;
|
|
30
|
+
}
|
|
31
|
+
async inject(messages, signal, sessionKey = "default") {
|
|
32
|
+
if (!this.enabled)
|
|
33
|
+
return messages;
|
|
34
|
+
try {
|
|
35
|
+
const query = lastUserText(messages);
|
|
36
|
+
if (!query || query.length < this.minQueryLength)
|
|
37
|
+
return messages;
|
|
38
|
+
const now = this.now();
|
|
39
|
+
const lastInjectedAt = this.lastInjectedAtBySession.get(sessionKey);
|
|
40
|
+
if (lastInjectedAt !== undefined && this.throttleMs > 0 && now - lastInjectedAt < this.throttleMs)
|
|
41
|
+
return messages;
|
|
42
|
+
debugAmbientQuery(sessionKey, query);
|
|
43
|
+
const hits = await retrieveAmbientDiary({
|
|
44
|
+
query,
|
|
45
|
+
store: this.store,
|
|
46
|
+
embeddingProvider: this.embeddingProvider,
|
|
47
|
+
limit: this.topK,
|
|
48
|
+
weights: {
|
|
49
|
+
similarity: this.weightSimilarity,
|
|
50
|
+
valence: this.weightValence,
|
|
51
|
+
recency: this.weightRecency,
|
|
52
|
+
intensity: this.weightIntensity,
|
|
53
|
+
},
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
if (hits.length === 0)
|
|
57
|
+
return messages;
|
|
58
|
+
this.lastInjectedAtBySession.set(sessionKey, now);
|
|
59
|
+
return injectAmbientDiaryRecall(messages, renderAmbientDiaryRecall(hits));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error("memory ambient recall failed", error);
|
|
63
|
+
return messages;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function positiveIntegerOrDefault(value, fallback) {
|
|
68
|
+
return value !== undefined && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
69
|
+
}
|
|
70
|
+
function nonNegativeIntegerOrDefault(value, fallback) {
|
|
71
|
+
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : fallback;
|
|
72
|
+
}
|
|
73
|
+
function nonNegativeNumberOrDefault(value, fallback) {
|
|
74
|
+
return value !== undefined && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
75
|
+
}
|
|
76
|
+
function injectAmbientDiaryRecall(messages, recallText) {
|
|
77
|
+
const lastUserIndex = findLastUserMessageIndex(messages);
|
|
78
|
+
if (lastUserIndex < 0)
|
|
79
|
+
return messages;
|
|
80
|
+
return messages.map((message, index) => index === lastUserIndex ? appendTextToUserMessage(message, `\n\n${recallText}`) : message);
|
|
81
|
+
}
|
|
82
|
+
function appendTextToUserMessage(message, text) {
|
|
83
|
+
if (message.role !== "user")
|
|
84
|
+
return message;
|
|
85
|
+
if (typeof message.content === "string")
|
|
86
|
+
return { ...message, content: `${message.content}${text}` };
|
|
87
|
+
return {
|
|
88
|
+
...message,
|
|
89
|
+
content: [...message.content, { type: "text", text }],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function findLastUserMessageIndex(messages) {
|
|
93
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
94
|
+
if (messages[index]?.role === "user")
|
|
95
|
+
return index;
|
|
96
|
+
}
|
|
97
|
+
return -1;
|
|
98
|
+
}
|
|
99
|
+
function lastUserText(messages) {
|
|
100
|
+
const index = findLastUserMessageIndex(messages);
|
|
101
|
+
if (index < 0)
|
|
102
|
+
return "";
|
|
103
|
+
const message = messages[index];
|
|
104
|
+
if (!message || message.role !== "user")
|
|
105
|
+
return "";
|
|
106
|
+
const text = typeof message.content === "string"
|
|
107
|
+
? message.content
|
|
108
|
+
: message.content
|
|
109
|
+
.filter(isTextPart)
|
|
110
|
+
.map((item) => item.text)
|
|
111
|
+
.join("\n");
|
|
112
|
+
return stripInjectedMemoryBlocks(text).trim();
|
|
113
|
+
}
|
|
114
|
+
function isTextPart(item) {
|
|
115
|
+
return item.type === "text" && typeof item.text === "string";
|
|
116
|
+
}
|
|
117
|
+
function stripInjectedMemoryBlocks(text) {
|
|
118
|
+
return text.replace(INJECTED_MEMORY_BLOCK_RE, "").trim();
|
|
119
|
+
}
|
|
120
|
+
function debugAmbientQuery(sessionKey, query) {
|
|
121
|
+
if (!process.env.DEBUG?.split(",")
|
|
122
|
+
.map((part) => part.trim())
|
|
123
|
+
.includes("memory-ambient"))
|
|
124
|
+
return;
|
|
125
|
+
console.error(JSON.stringify({ event: "ambient_diary_query", sessionKey, query }));
|
|
126
|
+
}
|
|
127
|
+
function renderAmbientDiaryRecall(hits) {
|
|
128
|
+
const lines = [INJECTED_MEMORY_OPEN];
|
|
129
|
+
for (const [index, hit] of hits.entries()) {
|
|
130
|
+
const date = typeof hit.chunk.metadata?.date === "string" ? hit.chunk.metadata.date : undefined;
|
|
131
|
+
const heading = typeof hit.chunk.metadata?.heading === "string" ? hit.chunk.metadata.heading : undefined;
|
|
132
|
+
const rawLabel = [date, heading].filter(Boolean).join(" ");
|
|
133
|
+
const label = diaryLabel(date, heading);
|
|
134
|
+
const prefix = label ? `${index + 1}. ${label}` : `${index + 1}. diary`;
|
|
135
|
+
const text = stripRepeatedDiaryPrefix(hit.chunk.snippet || hit.chunk.text, [rawLabel, label]);
|
|
136
|
+
lines.push(`${escapeXmlText(prefix)}: ${escapeXmlText(text)}`);
|
|
137
|
+
}
|
|
138
|
+
lines.push(INJECTED_MEMORY_CLOSE);
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
function diaryLabel(date, heading) {
|
|
142
|
+
return uniqueNonEmptyStrings([date, heading]).join(" ");
|
|
143
|
+
}
|
|
144
|
+
function stripRepeatedDiaryPrefix(text, labels) {
|
|
145
|
+
let trimmed = text.trim();
|
|
146
|
+
for (const label of uniqueNonEmptyStrings(labels)) {
|
|
147
|
+
const escaped = escapeRegExp(label);
|
|
148
|
+
trimmed = trimmed.replace(new RegExp(`^(?:${escaped}\\s*:\\s*)+`, "i"), "").trim();
|
|
149
|
+
}
|
|
150
|
+
return trimmed;
|
|
151
|
+
}
|
|
152
|
+
function uniqueNonEmptyStrings(values) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const value of values) {
|
|
156
|
+
const normalized = value?.trim();
|
|
157
|
+
if (!normalized)
|
|
158
|
+
continue;
|
|
159
|
+
const key = normalized.toLowerCase();
|
|
160
|
+
if (seen.has(key))
|
|
161
|
+
continue;
|
|
162
|
+
seen.add(key);
|
|
163
|
+
out.push(normalized);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
function escapeRegExp(value) {
|
|
168
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
169
|
+
}
|
|
170
|
+
function escapeXmlText(value) {
|
|
171
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
172
|
+
}
|
|
173
|
+
export const __ambientDiaryInjectorTest = {
|
|
174
|
+
injectAmbientDiaryRecall,
|
|
175
|
+
lastUserText,
|
|
176
|
+
renderAmbientDiaryRecall,
|
|
177
|
+
diaryLabel,
|
|
178
|
+
stripInjectedMemoryBlocks,
|
|
179
|
+
stripRepeatedDiaryPrefix,
|
|
180
|
+
};
|