@qearlyao/familiar 0.2.5 → 0.4.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/HEARTBEAT.md +1 -1
- package/README.md +33 -0
- package/config.example.toml +4 -2
- package/dist/{agent.js → agent/factory.js} +97 -328
- package/dist/agent/payload-normalizers.js +52 -0
- package/dist/agent/session-helpers.js +86 -0
- package/dist/agent/tool-descriptions.js +4 -0
- package/dist/agent/tools.js +30 -0
- package/dist/agent/transcript-log.js +93 -0
- package/dist/cli.js +45 -15
- package/dist/config/enums.js +35 -0
- package/dist/{config.js → config/index.js} +9 -272
- package/dist/config/interpolate.js +15 -0
- package/dist/config/model-refs.js +11 -0
- package/dist/{config-overrides.js → config/overrides.js} +1 -1
- package/dist/config/readers.js +116 -0
- package/dist/{config-registry.js → config/registry.js} +27 -8
- package/dist/config/sections.js +113 -0
- package/dist/{settings.js → config/settings.js} +5 -2
- package/dist/config/types.js +1 -0
- package/dist/{chat-log.js → conversation/chat-log.js} +16 -4
- package/dist/{contact-note.js → conversation/contact-note.js} +1 -1
- package/dist/conversation/ids.js +11 -0
- package/dist/conversation/owner-identity.js +29 -0
- package/dist/discord/channel.js +32 -0
- package/dist/discord/chunking.js +163 -0
- package/dist/discord/client.js +44 -0
- package/dist/discord/commands.js +181 -0
- package/dist/discord/daemon.js +379 -0
- package/dist/discord/inbound.js +44 -0
- package/dist/discord/send.js +115 -0
- package/dist/discord/turn.js +55 -0
- package/dist/index.js +12 -11
- package/dist/lifecycle/control.js +1 -0
- package/dist/{data-retention.js → lifecycle/data-retention.js} +1 -1
- package/dist/{hot-reload.js → lifecycle/hot-reload.js} +2 -2
- package/dist/{service.js → lifecycle/service.js} +1 -0
- package/dist/media/attachment-limits.js +3 -0
- package/dist/{generated-media.js → media/generated-media.js} +1 -1
- package/dist/{image-gen.js → media/image-gen.js} +2 -2
- package/dist/{inbound-attachments.js → media/inbound-attachments.js} +47 -43
- package/dist/media/media-understanding.js +215 -0
- package/dist/memory/index/store.js +21 -17
- package/dist/memory/index/vector-codec.js +2 -2
- package/dist/memory/lcm/context-transformer.js +6 -2
- package/dist/memory/lcm/segment-manager.js +6 -2
- package/dist/memory/lcm/store/index-ids.js +6 -0
- package/dist/memory/lcm/store/inserts.js +31 -0
- package/dist/memory/lcm/store/normalizers.js +91 -0
- package/dist/memory/lcm/store/row-mappers.js +114 -0
- package/dist/memory/lcm/store/row-types.js +1 -0
- package/dist/memory/lcm/store/serialization.js +37 -0
- package/dist/memory/lcm/store/snapshots.js +73 -0
- package/dist/memory/lcm/store.js +20 -360
- package/dist/memory/lcm/summarizer.js +1 -1
- package/dist/{added-models.js → models/added-models.js} +1 -1
- package/dist/{persona.js → prompting/persona.js} +1 -1
- package/dist/runtime/agent-core.js +82 -0
- package/dist/{agent-events.js → runtime/agent-events.js} +1 -1
- package/dist/runtime/agent-work-queue.js +55 -0
- package/dist/{runtime.js → runtime/conversation-runtime.js} +91 -43
- package/dist/runtime/runtime-manager.js +51 -0
- package/dist/runtime/scheduler-runner.js +243 -0
- package/dist/{scheduler.js → runtime/scheduler.js} +4 -4
- package/dist/{browser-tools.js → tools/browser-tools.js} +24 -34
- package/dist/util/fs.js +2 -1
- package/dist/web/agent-routes.js +104 -0
- package/dist/web/auth-routes.js +39 -0
- package/dist/web/auth.js +205 -0
- package/dist/web/config-routes.js +55 -0
- package/dist/web/conversation-routes.js +122 -0
- package/dist/web/daemon.js +108 -0
- package/dist/web/diary-routes.js +88 -0
- package/dist/web/errors.js +3 -0
- package/dist/web/event-hub.js +246 -0
- package/dist/{web-http.js → web/http.js} +19 -5
- package/dist/web/memes.js +25 -0
- package/dist/web/messages.js +348 -0
- package/dist/web/multipart.js +86 -0
- package/dist/web/payloads.js +34 -0
- package/dist/web/request-context.js +25 -0
- package/dist/web/route-helpers.js +9 -0
- package/dist/web/routes.js +37 -0
- package/dist/web/runtime-actions.js +231 -0
- package/dist/web/session-store.js +161 -0
- package/dist/{web-static.js → web/static.js} +19 -14
- package/dist/web/stream.js +78 -0
- package/dist/web-tools/cache.js +42 -0
- package/dist/web-tools/config.js +16 -0
- package/dist/web-tools/fetch-providers.js +119 -0
- package/dist/web-tools/format.js +88 -0
- package/dist/web-tools/http.js +81 -0
- package/dist/web-tools/index.js +152 -0
- package/dist/web-tools/routing.js +29 -0
- package/dist/web-tools/safety.js +73 -0
- package/dist/web-tools/search-providers.js +277 -0
- package/dist/web-tools/types.js +54 -0
- package/dist/web-tools/util.js +23 -0
- package/npm-shrinkwrap.json +319 -201
- package/package.json +6 -4
- package/web/dist/assets/index-C-k4O5Dz.js +6 -0
- package/web/dist/assets/index-Dj-L9nX4.css +2 -0
- package/web/dist/assets/markdown-kaIeGxdv.js +14 -0
- package/web/dist/assets/react-Bi_azaFt.js +9 -0
- package/web/dist/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- package/web/dist/assets/ui-C12-nN_X.js +51 -0
- package/web/dist/assets/vendor-D1QXMhXm.js +16 -0
- package/web/dist/index.html +11 -3
- package/dist/discord.js +0 -1299
- package/dist/media-understanding.js +0 -120
- package/dist/web-auth.js +0 -111
- package/dist/web-tools.js +0 -941
- package/dist/web.js +0 -1209
- package/web/dist/assets/index-B23WT77N.js +0 -63
- package/web/dist/assets/index-D3MotFzN.css +0 -2
- /package/dist/{control.js → agent/types.js} +0 -0
- /package/dist/{image-derivatives.js → media/image-derivatives.js} +0 -0
- /package/dist/{tts.js → media/tts.js} +0 -0
- /package/dist/{models.js → models/index.js} +0 -0
- /package/dist/{skills.js → prompting/skills.js} +0 -0
- /package/dist/{silent-marker.js → runtime/silent-marker.js} +0 -0
- /package/dist/{web-events.js → web/events.js} +0 -0
- /package/dist/{web-types.js → web/types.js} +0 -0
|
@@ -4,11 +4,11 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { findEnvKeys, generateImages, getEnvApiKey, getImageModels, getImageProviders, } from "@earendil-works/pi-ai";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
|
+
import { parseModelRef } from "../models/index.js";
|
|
8
|
+
import { imageMimeTypeFromPath, sniffImageMimeType } from "../util/image-mime.js";
|
|
7
9
|
import { ensureGeneratedAttachmentsDir } from "./generated-media.js";
|
|
8
10
|
import { ensureInlineImageDerivative } from "./image-derivatives.js";
|
|
9
11
|
import { promptImagesFromAttachments } from "./inbound-attachments.js";
|
|
10
|
-
import { parseModelRef } from "./models.js";
|
|
11
|
-
import { imageMimeTypeFromPath, sniffImageMimeType } from "./util/image-mime.js";
|
|
12
12
|
const IMAGE_GEN_NOTICE_PREFIX = "Generated image attachment:";
|
|
13
13
|
const OPENROUTER_IMAGE_BASE_URL = "https://openrouter.ai/api/v1";
|
|
14
14
|
const imageGenSchema = Type.Object({
|
|
@@ -1,56 +1,43 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { IMAGE_EXTENSION_BY_MIME, sniffImageMimeType } from "../util/image-mime.js";
|
|
5
|
+
import { MAX_INBOUND_ATTACHMENT_BYTES, MAX_INBOUND_ATTACHMENTS, MAX_INBOUND_TOTAL_BYTES } from "./attachment-limits.js";
|
|
4
6
|
import { attachmentsDir, publicAttachmentPath } from "./generated-media.js";
|
|
5
7
|
import { ensureInlineImageDerivative, MAX_INLINE_IMAGE_BASE64_BYTES } from "./image-derivatives.js";
|
|
6
8
|
import { deriveInboundAttachmentText } from "./media-understanding.js";
|
|
7
|
-
|
|
9
|
+
export { MAX_INBOUND_ATTACHMENT_BYTES, MAX_INBOUND_ATTACHMENTS, MAX_INBOUND_TOTAL_BYTES, } from "./attachment-limits.js";
|
|
8
10
|
export { MAX_INLINE_IMAGE_BASE64_BYTES } from "./image-derivatives.js";
|
|
9
|
-
export const MAX_INBOUND_ATTACHMENTS = 4;
|
|
10
|
-
export const MAX_INBOUND_ATTACHMENT_BYTES = 12 * 1024 * 1024;
|
|
11
|
-
export const MAX_INBOUND_TOTAL_BYTES = 24 * 1024 * 1024;
|
|
12
11
|
const TEXT_ATTACHMENT_PREVIEW_LINES = 2;
|
|
13
12
|
const TEXT_ATTACHMENT_PREVIEW_CHARS = 1000;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"audio/
|
|
21
|
-
"audio/
|
|
22
|
-
"audio/
|
|
23
|
-
"
|
|
24
|
-
"video/
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
...IMAGE_EXTENSION_BY_MIME,
|
|
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",
|
|
13
|
+
const MP4_FILE_TYPE_BRANDS = new Set(["avc1", "dash", "iso2", "isom", "M4V ", "mp41", "mp42", "MSNV"]);
|
|
14
|
+
const ATTACHMENT_MIME_POLICY = {
|
|
15
|
+
...Object.fromEntries(Object.entries(IMAGE_EXTENSION_BY_MIME).map(([mimeType, extension]) => [
|
|
16
|
+
mimeType,
|
|
17
|
+
{ kind: "image", extension },
|
|
18
|
+
])),
|
|
19
|
+
"audio/mpeg": { kind: "audio", extension: ".mp3" },
|
|
20
|
+
"audio/ogg": { kind: "audio", extension: ".ogg" },
|
|
21
|
+
"audio/wav": { kind: "audio", extension: ".wav" },
|
|
22
|
+
"audio/webm": { kind: "audio", extension: ".webm" },
|
|
23
|
+
"video/mp4": { kind: "video", extension: ".mp4" },
|
|
24
|
+
"video/quicktime": { kind: "video", extension: ".mov" },
|
|
25
|
+
"video/webm": { kind: "video", extension: ".webm" },
|
|
26
|
+
"application/pdf": { kind: "file", extension: ".pdf" },
|
|
27
|
+
"text/plain": { kind: "file", extension: ".txt" },
|
|
38
28
|
};
|
|
29
|
+
const MIME_TYPE_BY_EXTENSION = Object.fromEntries(Object.entries(ATTACHMENT_MIME_POLICY)
|
|
30
|
+
.filter(([, policy]) => policy.kind === "video")
|
|
31
|
+
.map(([mimeType, policy]) => [policy.extension, mimeType]));
|
|
32
|
+
function mimePolicy(mimeType) {
|
|
33
|
+
return ATTACHMENT_MIME_POLICY[mimeType];
|
|
34
|
+
}
|
|
39
35
|
function safeName(name, fallback) {
|
|
40
36
|
const base = basename(name || fallback)
|
|
41
37
|
.replace(/[^A-Za-z0-9._=-]+/g, "_")
|
|
42
38
|
.slice(0, 120);
|
|
43
39
|
return base || fallback;
|
|
44
40
|
}
|
|
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
41
|
function textAttachmentPreview(buffer, mimeType) {
|
|
55
42
|
if (mimeType !== "text/plain")
|
|
56
43
|
return undefined;
|
|
@@ -84,7 +71,22 @@ function sniffText(buffer) {
|
|
|
84
71
|
}
|
|
85
72
|
return "text/plain";
|
|
86
73
|
}
|
|
87
|
-
function
|
|
74
|
+
function mimeFromExtension(name) {
|
|
75
|
+
if (!name)
|
|
76
|
+
return undefined;
|
|
77
|
+
return MIME_TYPE_BY_EXTENSION[extname(name).toLowerCase()];
|
|
78
|
+
}
|
|
79
|
+
function sniffVideoMimeType(buffer) {
|
|
80
|
+
if (buffer.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3])))
|
|
81
|
+
return "video/webm";
|
|
82
|
+
if (buffer.length < 12 || buffer.subarray(4, 8).toString("ascii") !== "ftyp")
|
|
83
|
+
return undefined;
|
|
84
|
+
const brand = buffer.subarray(8, 12).toString("ascii");
|
|
85
|
+
if (brand === "qt ")
|
|
86
|
+
return "video/quicktime";
|
|
87
|
+
return MP4_FILE_TYPE_BRANDS.has(brand) ? "video/mp4" : undefined;
|
|
88
|
+
}
|
|
89
|
+
function sniffMimeType(buffer, declared, name) {
|
|
88
90
|
let detected = sniffImageMimeType(buffer);
|
|
89
91
|
if (!detected && buffer.subarray(0, 4).toString("ascii") === "%PDF")
|
|
90
92
|
detected = "application/pdf";
|
|
@@ -97,8 +99,10 @@ function sniffMimeType(buffer, declared) {
|
|
|
97
99
|
else if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
|
|
98
100
|
detected = "audio/wav";
|
|
99
101
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
detected ??= sniffVideoMimeType(buffer);
|
|
103
|
+
const declaredMime = declared && mimePolicy(declared) ? declared : undefined;
|
|
104
|
+
const mime = detected ?? sniffText(buffer) ?? declaredMime ?? mimeFromExtension(name);
|
|
105
|
+
if (!mime || !mimePolicy(mime)) {
|
|
102
106
|
throw new Error(`Unsupported attachment type: ${declared || detected || "unknown"}`);
|
|
103
107
|
}
|
|
104
108
|
return mime;
|
|
@@ -106,7 +110,7 @@ function sniffMimeType(buffer, declared) {
|
|
|
106
110
|
function canonicalName(name, fallbackStem, mimeType) {
|
|
107
111
|
const current = safeName(name, fallbackStem);
|
|
108
112
|
const stem = current.replace(/\.[^.]+$/, "");
|
|
109
|
-
return `${stem}${
|
|
113
|
+
return `${stem}${mimePolicy(mimeType)?.extension || extname(current) || ""}`;
|
|
110
114
|
}
|
|
111
115
|
async function fetchRemoteAttachment(input, signal) {
|
|
112
116
|
if (!input.url)
|
|
@@ -175,14 +179,14 @@ export async function materializeInboundAttachments(config, inputs) {
|
|
|
175
179
|
if (totalBytes > MAX_INBOUND_TOTAL_BYTES) {
|
|
176
180
|
throw new Error(`Attachments are too large: max ${MAX_INBOUND_TOTAL_BYTES} bytes total`);
|
|
177
181
|
}
|
|
178
|
-
const mimeType = sniffMimeType(buffer, input.mimeType);
|
|
182
|
+
const mimeType = sniffMimeType(buffer, input.mimeType, input.name);
|
|
179
183
|
const id = input.id || randomUUID();
|
|
180
184
|
const cleanName = canonicalName(input.name, `attachment-${index + 1}`, mimeType);
|
|
181
185
|
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
182
186
|
prepared.push({
|
|
183
187
|
id,
|
|
184
188
|
name: cleanName,
|
|
185
|
-
kind:
|
|
189
|
+
kind: mimePolicy(mimeType)?.kind ?? "file",
|
|
186
190
|
mimeType,
|
|
187
191
|
size: buffer.byteLength,
|
|
188
192
|
sha256,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createPartFromUri, createUserContent, FileState, GoogleGenAI } from "@google/genai";
|
|
3
|
+
import { parseModelRef, resolveModel } from "../models/index.js";
|
|
4
|
+
const GEMINI_API_VERSION_PATTERN = /\/(v1(?:beta|alpha)?|v\d+beta\d*)\/?$/;
|
|
5
|
+
const AUDIO_UNDERSTANDING_TIMEOUT_MS = 30_000;
|
|
6
|
+
const VIDEO_UNDERSTANDING_TIMEOUT_MS = 5 * 60_000;
|
|
7
|
+
const GEMINI_FILE_POLL_INTERVAL_MS = 2_000;
|
|
8
|
+
const GEMINI_FILE_DELETE_TIMEOUT_MS = 5_000;
|
|
9
|
+
function normalizeDerivedText(text) {
|
|
10
|
+
return text.trim().replace(/\n{3,}/g, "\n\n");
|
|
11
|
+
}
|
|
12
|
+
function labelForAttachment(kind) {
|
|
13
|
+
if (kind === "audio")
|
|
14
|
+
return "transcription";
|
|
15
|
+
if (kind === "video")
|
|
16
|
+
return "summary";
|
|
17
|
+
return "text";
|
|
18
|
+
}
|
|
19
|
+
function geminiHttpOptions(config) {
|
|
20
|
+
const ref = parseModelRef(`google/${config.mediaUnderstanding.video.model}`);
|
|
21
|
+
const model = ref ? resolveModel(ref, config) : undefined;
|
|
22
|
+
const baseUrl = model?.baseUrl;
|
|
23
|
+
if (!baseUrl)
|
|
24
|
+
return { timeout: VIDEO_UNDERSTANDING_TIMEOUT_MS };
|
|
25
|
+
const match = baseUrl.match(GEMINI_API_VERSION_PATTERN);
|
|
26
|
+
if (!match)
|
|
27
|
+
return { baseUrl, timeout: VIDEO_UNDERSTANDING_TIMEOUT_MS };
|
|
28
|
+
return {
|
|
29
|
+
baseUrl: baseUrl.slice(0, match.index),
|
|
30
|
+
apiVersion: match[1],
|
|
31
|
+
timeout: VIDEO_UNDERSTANDING_TIMEOUT_MS,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function timedOut(error) {
|
|
35
|
+
return (error instanceof Error &&
|
|
36
|
+
(/timed out|timeout/i.test(error.message) || error.name === "AbortError" || error.name === "TimeoutError"));
|
|
37
|
+
}
|
|
38
|
+
function videoTimeoutError() {
|
|
39
|
+
return new Error(`Gemini video understanding timed out after ${Math.round(VIDEO_UNDERSTANDING_TIMEOUT_MS / 60_000)} minutes.`);
|
|
40
|
+
}
|
|
41
|
+
function remainingVideoUnderstandingMs(deadline) {
|
|
42
|
+
const remaining = deadline - Date.now();
|
|
43
|
+
if (remaining <= 0)
|
|
44
|
+
throw videoTimeoutError();
|
|
45
|
+
return remaining;
|
|
46
|
+
}
|
|
47
|
+
class VideoUnderstandingDeadline {
|
|
48
|
+
expiresAt = Date.now() + VIDEO_UNDERSTANDING_TIMEOUT_MS;
|
|
49
|
+
signal() {
|
|
50
|
+
return AbortSignal.timeout(remainingVideoUnderstandingMs(this.expiresAt));
|
|
51
|
+
}
|
|
52
|
+
async sleep(ms) {
|
|
53
|
+
const delay = Math.min(ms, remainingVideoUnderstandingMs(this.expiresAt));
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
55
|
+
remainingVideoUnderstandingMs(this.expiresAt);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function fallbackDerivedText(attachment, error) {
|
|
59
|
+
if (!attachment.mimeType?.startsWith("video/"))
|
|
60
|
+
return undefined;
|
|
61
|
+
const detail = timedOut(error)
|
|
62
|
+
? `Automatic video understanding timed out after ${Math.round(VIDEO_UNDERSTANDING_TIMEOUT_MS / 60_000)} minutes.`
|
|
63
|
+
: "Automatic video understanding failed before a summary was produced.";
|
|
64
|
+
return {
|
|
65
|
+
provider: "local",
|
|
66
|
+
model: "media-understanding-note",
|
|
67
|
+
label: "note",
|
|
68
|
+
text: detail,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function waitForGeminiFileActive(ai, uploaded, deadline) {
|
|
72
|
+
let file = uploaded;
|
|
73
|
+
let polled = false;
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (file.state === FileState.ACTIVE)
|
|
76
|
+
return file;
|
|
77
|
+
if (file.state === FileState.FAILED) {
|
|
78
|
+
throw new Error(file.error?.message ?? "Gemini file processing failed");
|
|
79
|
+
}
|
|
80
|
+
if (!file.name)
|
|
81
|
+
throw new Error("Gemini file upload did not return a file name");
|
|
82
|
+
if (polled)
|
|
83
|
+
await deadline.sleep(GEMINI_FILE_POLL_INTERVAL_MS);
|
|
84
|
+
file = await ai.files.get({
|
|
85
|
+
name: file.name,
|
|
86
|
+
config: { abortSignal: deadline.signal() },
|
|
87
|
+
});
|
|
88
|
+
polled = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function deleteGeminiFile(ai, name) {
|
|
92
|
+
try {
|
|
93
|
+
await ai.files.delete({ name, config: { abortSignal: AbortSignal.timeout(GEMINI_FILE_DELETE_TIMEOUT_MS) } });
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.warn("Gemini file cleanup failed", error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function transcribeAudioAttachment(config, attachment) {
|
|
100
|
+
if (!attachment.localPath || !attachment.mimeType?.startsWith("audio/"))
|
|
101
|
+
return undefined;
|
|
102
|
+
const apiKey = process.env[config.mediaUnderstanding.audio.apiKeyEnv];
|
|
103
|
+
if (!apiKey) {
|
|
104
|
+
console.warn(`media understanding skipped: ${config.mediaUnderstanding.audio.apiKeyEnv} is not set`);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const form = new FormData();
|
|
108
|
+
form.set("model", config.mediaUnderstanding.audio.model);
|
|
109
|
+
form.set("file", new Blob([await readFile(attachment.localPath)], { type: attachment.mimeType }), attachment.name);
|
|
110
|
+
const response = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
Authorization: `Bearer ${apiKey}`,
|
|
114
|
+
},
|
|
115
|
+
body: form,
|
|
116
|
+
signal: AbortSignal.timeout(AUDIO_UNDERSTANDING_TIMEOUT_MS),
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok)
|
|
119
|
+
throw new Error(`Groq transcription failed: HTTP ${response.status}`);
|
|
120
|
+
const parsed = (await response.json());
|
|
121
|
+
const text = parsed.text?.trim();
|
|
122
|
+
if (!text)
|
|
123
|
+
return undefined;
|
|
124
|
+
return {
|
|
125
|
+
provider: "groq",
|
|
126
|
+
model: config.mediaUnderstanding.audio.model,
|
|
127
|
+
text: normalizeDerivedText(text),
|
|
128
|
+
label: labelForAttachment(attachment.kind),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function summarizeVideoAttachment(config, attachment) {
|
|
132
|
+
if (!attachment.localPath || !attachment.mimeType?.startsWith("video/"))
|
|
133
|
+
return undefined;
|
|
134
|
+
const apiKey = process.env[config.mediaUnderstanding.video.apiKeyEnv];
|
|
135
|
+
if (!apiKey) {
|
|
136
|
+
console.warn(`media understanding skipped: ${config.mediaUnderstanding.video.apiKeyEnv} is not set`);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const ai = new GoogleGenAI({ apiKey, httpOptions: geminiHttpOptions(config) });
|
|
140
|
+
const deadline = new VideoUnderstandingDeadline();
|
|
141
|
+
let uploadedName;
|
|
142
|
+
try {
|
|
143
|
+
const uploaded = await ai.files.upload({
|
|
144
|
+
file: attachment.localPath,
|
|
145
|
+
config: {
|
|
146
|
+
mimeType: attachment.mimeType,
|
|
147
|
+
displayName: attachment.name,
|
|
148
|
+
abortSignal: deadline.signal(),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
uploadedName = uploaded.name;
|
|
152
|
+
const file = await waitForGeminiFileActive(ai, uploaded, deadline);
|
|
153
|
+
const fileUri = file.uri ?? uploaded.uri;
|
|
154
|
+
if (!fileUri)
|
|
155
|
+
throw new Error("Gemini file upload did not return a file URI");
|
|
156
|
+
const response = await ai.models.generateContent({
|
|
157
|
+
model: config.mediaUnderstanding.video.model,
|
|
158
|
+
contents: createUserContent([
|
|
159
|
+
{
|
|
160
|
+
text: "Provide a concise description of this video, including any spoken content if present, and summarize the key visible events.",
|
|
161
|
+
},
|
|
162
|
+
createPartFromUri(fileUri, file.mimeType ?? uploaded.mimeType ?? attachment.mimeType),
|
|
163
|
+
]),
|
|
164
|
+
config: { abortSignal: deadline.signal() },
|
|
165
|
+
});
|
|
166
|
+
const text = response.text?.trim();
|
|
167
|
+
if (!text)
|
|
168
|
+
return undefined;
|
|
169
|
+
return {
|
|
170
|
+
provider: "google",
|
|
171
|
+
model: config.mediaUnderstanding.video.model,
|
|
172
|
+
text: normalizeDerivedText(text),
|
|
173
|
+
label: labelForAttachment(attachment.kind),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
if (uploadedName)
|
|
178
|
+
await deleteGeminiFile(ai, uploadedName);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function deriveInboundAttachmentText(config, attachments) {
|
|
182
|
+
const next = [];
|
|
183
|
+
for (const attachment of attachments) {
|
|
184
|
+
if (attachment.derived?.text || !attachment.localPath) {
|
|
185
|
+
next.push(attachment);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
if (attachment.mimeType?.startsWith("audio/")) {
|
|
190
|
+
const text = await transcribeAudioAttachment(config, attachment);
|
|
191
|
+
if (text) {
|
|
192
|
+
next.push({ ...attachment, derived: { ...(attachment.derived ?? {}), text } });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (attachment.mimeType?.startsWith("video/")) {
|
|
197
|
+
const text = await summarizeVideoAttachment(config, attachment);
|
|
198
|
+
if (text) {
|
|
199
|
+
next.push({ ...attachment, derived: { ...(attachment.derived ?? {}), text } });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
console.error("media understanding failed", error);
|
|
206
|
+
const fallback = fallbackDerivedText(attachment, error);
|
|
207
|
+
if (fallback) {
|
|
208
|
+
next.push({ ...attachment, derived: { ...(attachment.derived ?? {}), text: fallback } });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
next.push(attachment);
|
|
213
|
+
}
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
@@ -11,6 +11,11 @@ export class MemoryIndexStore {
|
|
|
11
11
|
embeddingProvider;
|
|
12
12
|
embeddingModel;
|
|
13
13
|
embeddingDimensions;
|
|
14
|
+
vectorCapabilityValue;
|
|
15
|
+
findChunkIdByHashStmt;
|
|
16
|
+
insertChunkStmt;
|
|
17
|
+
insertFtsStmt;
|
|
18
|
+
insertMemoryVecStmt;
|
|
14
19
|
constructor(options) {
|
|
15
20
|
if (!options.db && !options.path)
|
|
16
21
|
throw new Error("MemoryIndexStore requires a db or path");
|
|
@@ -32,6 +37,16 @@ export class MemoryIndexStore {
|
|
|
32
37
|
embeddingModel: this.embeddingModel,
|
|
33
38
|
embeddingDimensions: this.embeddingDimensions,
|
|
34
39
|
});
|
|
40
|
+
this.vectorCapabilityValue = readMeta(this.db, "vector_capability") === "sqlite-vec" ? "sqlite-vec" : "blob-js";
|
|
41
|
+
this.findChunkIdByHashStmt = this.db.prepare("SELECT id FROM memory_chunks WHERE content_hash = ?");
|
|
42
|
+
this.insertChunkStmt = this.db.prepare(`INSERT INTO memory_chunks (
|
|
43
|
+
content_hash, corpus, text_full, snippet, token_count, metadata_json, embedding_model,
|
|
44
|
+
embedding_dimensions, embedding
|
|
45
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
46
|
+
this.insertFtsStmt = this.db.prepare("INSERT INTO memory_fts(rowid, text_full, snippet) VALUES (?, ?, ?)");
|
|
47
|
+
if (this.vectorCapabilityValue === "sqlite-vec") {
|
|
48
|
+
this.insertMemoryVecStmt = this.db.prepare("INSERT INTO memory_vec(rowid, embedding) VALUES (CAST(? AS INTEGER), ?)");
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
static open(config) {
|
|
37
52
|
return new MemoryIndexStore({
|
|
@@ -80,9 +95,7 @@ export class MemoryIndexStore {
|
|
|
80
95
|
this.insertSourceMapping(preloadedId, item);
|
|
81
96
|
continue;
|
|
82
97
|
}
|
|
83
|
-
const existing = this.
|
|
84
|
-
.prepare("SELECT id FROM memory_chunks WHERE content_hash = ?")
|
|
85
|
-
.get(item.contentHash);
|
|
98
|
+
const existing = this.findChunkIdByHashStmt.get(item.contentHash);
|
|
86
99
|
if (existing)
|
|
87
100
|
this.insertSourceMapping(existing.id, item);
|
|
88
101
|
}
|
|
@@ -333,7 +346,7 @@ export class MemoryIndexStore {
|
|
|
333
346
|
};
|
|
334
347
|
}
|
|
335
348
|
vectorCapability() {
|
|
336
|
-
return
|
|
349
|
+
return this.vectorCapabilityValue;
|
|
337
350
|
}
|
|
338
351
|
vectorRowCount() {
|
|
339
352
|
try {
|
|
@@ -377,26 +390,17 @@ export class MemoryIndexStore {
|
|
|
377
390
|
? { id: knownId }
|
|
378
391
|
: knownMissingHashes?.has(item.contentHash)
|
|
379
392
|
? undefined
|
|
380
|
-
: this.
|
|
393
|
+
: this.findChunkIdByHashStmt.get(item.contentHash);
|
|
381
394
|
if (existing) {
|
|
382
395
|
knownIds.set(item.contentHash, existing.id);
|
|
383
396
|
this.insertSourceMapping(existing.id, item);
|
|
384
397
|
return existing.id;
|
|
385
398
|
}
|
|
386
|
-
const result = this.
|
|
387
|
-
.prepare(`INSERT INTO memory_chunks (
|
|
388
|
-
content_hash, corpus, text_full, snippet, token_count, metadata_json, embedding_model,
|
|
389
|
-
embedding_dimensions, embedding
|
|
390
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
391
|
-
.run(item.contentHash, item.corpus, item.text, item.snippet, item.tokenCount, item.metadataJson, this.embeddingModel, this.embeddingDimensions, encodeVector(item.embedding));
|
|
399
|
+
const result = this.insertChunkStmt.run(item.contentHash, item.corpus, item.text, item.snippet, item.tokenCount, item.metadataJson, this.embeddingModel, this.embeddingDimensions, encodeVector(item.embedding));
|
|
392
400
|
const id = Number(result.lastInsertRowid);
|
|
393
|
-
this.
|
|
394
|
-
.prepare("INSERT INTO memory_fts(rowid, text_full, snippet) VALUES (?, ?, ?)")
|
|
395
|
-
.run(id, item.text, item.snippet);
|
|
401
|
+
this.insertFtsStmt.run(id, item.text, item.snippet);
|
|
396
402
|
if (this.vectorCapability() === "sqlite-vec") {
|
|
397
|
-
this.
|
|
398
|
-
.prepare("INSERT INTO memory_vec(rowid, embedding) VALUES (CAST(? AS INTEGER), ?)")
|
|
399
|
-
.run(id, encodeVector(item.embedding));
|
|
403
|
+
this.insertMemoryVecStmt.run(id, encodeVector(item.embedding));
|
|
400
404
|
}
|
|
401
405
|
knownIds.set(item.contentHash, id);
|
|
402
406
|
this.insertSourceMapping(id, item);
|
|
@@ -15,8 +15,8 @@ export function cosineDistance(a, b) {
|
|
|
15
15
|
let aNorm = 0;
|
|
16
16
|
let bNorm = 0;
|
|
17
17
|
for (let index = 0; index < a.length; index++) {
|
|
18
|
-
const av = a[index]
|
|
19
|
-
const bv = b[index]
|
|
18
|
+
const av = a[index];
|
|
19
|
+
const bv = b[index];
|
|
20
20
|
dot += av * bv;
|
|
21
21
|
aNorm += av * av;
|
|
22
22
|
bNorm += bv * bv;
|
|
@@ -176,8 +176,12 @@ export class LcmContextTransformer {
|
|
|
176
176
|
tokensSaved = Math.max(0, candidate.chunkTokens - summaryItem.tokens);
|
|
177
177
|
await this.condenseRuntimeSummaries({ state, sessionKey: input.sessionKey, signal: input.signal });
|
|
178
178
|
};
|
|
179
|
-
|
|
180
|
-
|
|
179
|
+
// Serialize compactions per state: run after the prior one settles. Keep the shared
|
|
180
|
+
// compactionQueue non-rejecting so a failure here can't poison the next caller's link,
|
|
181
|
+
// and await the real work promise so this caller still surfaces its own error.
|
|
182
|
+
const settled = input.state.compactionQueue.then(run);
|
|
183
|
+
input.state.compactionQueue = settled.catch(() => undefined);
|
|
184
|
+
await settled;
|
|
181
185
|
return { compacted, tokensSaved };
|
|
182
186
|
}
|
|
183
187
|
async persistRuntimeSummary(input) {
|
|
@@ -19,8 +19,12 @@ export class LcmSegmentManager {
|
|
|
19
19
|
}
|
|
20
20
|
subscribeRuntime(runtime, sessionId) {
|
|
21
21
|
const unsubscribe = runtime.subscribe((record) => {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Serialize projections; the prior link is always non-rejecting (see the .catch
|
|
23
|
+
// below), so each projection runs once after it. Count + log failures in the same
|
|
24
|
+
// chain and recover, so the stored queue never carries a rejection forward.
|
|
25
|
+
this.projectionQueue = this.projectionQueue
|
|
26
|
+
.then(() => this.projectRuntimeRecord(runtime, record, sessionId))
|
|
27
|
+
.catch((error) => {
|
|
24
28
|
this.projectionFailures += 1;
|
|
25
29
|
console.error(`memory projection failed for ${runtime.channelKey}`, error);
|
|
26
30
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsonOrNull, sourceRecordIdToString } from "./serialization.js";
|
|
2
|
+
export function insertRecordPrepared(db, normalized) {
|
|
3
|
+
const inserted = db
|
|
4
|
+
.prepare(`INSERT INTO lcm_records (
|
|
5
|
+
record_key, segment_id, kind, text_full, happened_at, session_id, channel_key,
|
|
6
|
+
channel_id, job_id, source_type, source_path, source_line, source_record_id,
|
|
7
|
+
source_message_id, source_ref, attachments_json, metadata_json, parts_json
|
|
8
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
9
|
+
.run(normalized.recordKey, normalized.segmentId, normalized.kind, normalized.text, normalized.happenedAt, normalized.sessionId, normalized.channelKey, normalized.channelId, normalized.jobId, normalized.source.sourceType, normalized.source.sourcePath ?? null, normalized.source.sourceLine ?? null, sourceRecordIdToString(normalized.source.sourceRecordId), normalized.source.sourceMessageId ?? null, normalized.source.sourceRef ?? null, jsonOrNull(normalized.attachments), jsonOrNull(normalized.metadata), jsonOrNull(normalized.parts));
|
|
10
|
+
const id = Number(inserted.lastInsertRowid);
|
|
11
|
+
if (normalized.kind !== "boundary") {
|
|
12
|
+
db.prepare("INSERT INTO lcm_records_fts(rowid, text_full) VALUES (?, ?)").run(id, normalized.text);
|
|
13
|
+
}
|
|
14
|
+
const row = db.prepare("SELECT * FROM lcm_records WHERE id = ?").get(id);
|
|
15
|
+
if (!row)
|
|
16
|
+
throw new Error(`Failed to read inserted LCM record: ${id}`);
|
|
17
|
+
return row;
|
|
18
|
+
}
|
|
19
|
+
export function insertSummaryPrepared(db, normalized, insertEdges) {
|
|
20
|
+
const inserted = db
|
|
21
|
+
.prepare(`INSERT INTO lcm_summaries (
|
|
22
|
+
summary_key, segment_id, depth, status, text_full, pinned,
|
|
23
|
+
covers_from_record_id, covers_to_record_id, snapshot_json, source_type, source_path,
|
|
24
|
+
source_line, source_record_id, source_message_id, source_ref, metadata_json
|
|
25
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
26
|
+
.run(normalized.summaryKey, normalized.segmentId, normalized.depth, normalized.status, normalized.text, normalized.pinned ? 1 : 0, normalized.coversFromRecordId, normalized.coversToRecordId, null, normalized.source.sourceType, normalized.source.sourcePath ?? null, normalized.source.sourceLine ?? null, sourceRecordIdToString(normalized.source.sourceRecordId), normalized.source.sourceMessageId ?? null, normalized.source.sourceRef ?? null, jsonOrNull(normalized.metadata));
|
|
27
|
+
const id = Number(inserted.lastInsertRowid);
|
|
28
|
+
db.prepare("INSERT INTO lcm_summaries_fts(rowid, text_full) VALUES (?, ?)").run(id, normalized.text);
|
|
29
|
+
insertEdges(id, normalized.sourceItems, normalized.parents);
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { stableHash } from "./serialization.js";
|
|
2
|
+
export function normalizeSource(source) {
|
|
3
|
+
return {
|
|
4
|
+
sourceType: source.sourceType,
|
|
5
|
+
sourcePath: source.sourcePath ?? null,
|
|
6
|
+
sourceLine: source.sourceLine ?? null,
|
|
7
|
+
sourceRecordId: source.sourceRecordId ?? null,
|
|
8
|
+
sourceMessageId: source.sourceMessageId ?? null,
|
|
9
|
+
sourceRef: source.sourceRef ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function normalizeRecordInput(input) {
|
|
13
|
+
const text = (input.text ?? "").trim();
|
|
14
|
+
if (!text && input.kind !== "boundary")
|
|
15
|
+
throw new Error("LCM record text must not be empty");
|
|
16
|
+
const source = normalizeSource(input.source);
|
|
17
|
+
const happenedAt = input.happenedAt ?? new Date().toISOString();
|
|
18
|
+
const parts = input.parts?.length ? input.parts : null;
|
|
19
|
+
const normalizedText = text || "Session boundary";
|
|
20
|
+
return {
|
|
21
|
+
segmentId: input.segmentId,
|
|
22
|
+
kind: input.kind,
|
|
23
|
+
text: normalizedText,
|
|
24
|
+
parts,
|
|
25
|
+
happenedAt,
|
|
26
|
+
sessionId: input.sessionId ?? null,
|
|
27
|
+
channelKey: input.channelKey ?? null,
|
|
28
|
+
channelId: input.channelId ?? null,
|
|
29
|
+
jobId: input.jobId ?? null,
|
|
30
|
+
source,
|
|
31
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
32
|
+
metadata: input.metadata ?? null,
|
|
33
|
+
recordKey: computeLcmRecordKey({ ...input, happenedAt }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function normalizeSummaryInput(input) {
|
|
37
|
+
const text = (input.text ?? "").trim();
|
|
38
|
+
const source = normalizeSource(input.source);
|
|
39
|
+
const status = input.status ?? (text ? "ready" : "placeholder");
|
|
40
|
+
const normalizedText = text || "";
|
|
41
|
+
return {
|
|
42
|
+
segmentId: input.segmentId,
|
|
43
|
+
depth: input.depth,
|
|
44
|
+
status,
|
|
45
|
+
text: normalizedText,
|
|
46
|
+
pinned: input.pinned ?? false,
|
|
47
|
+
coversFromRecordId: input.coversFromRecordId ?? null,
|
|
48
|
+
coversToRecordId: input.coversToRecordId ?? null,
|
|
49
|
+
source,
|
|
50
|
+
sourceItems: input.sourceItems ?? [],
|
|
51
|
+
parents: input.parents ?? [],
|
|
52
|
+
metadata: input.metadata ?? null,
|
|
53
|
+
summaryKey: stableHash({
|
|
54
|
+
segmentId: input.segmentId,
|
|
55
|
+
depth: input.depth,
|
|
56
|
+
status,
|
|
57
|
+
text: normalizedText,
|
|
58
|
+
coversFromRecordId: input.coversFromRecordId ?? null,
|
|
59
|
+
coversToRecordId: input.coversToRecordId ?? null,
|
|
60
|
+
source,
|
|
61
|
+
parents: input.parents ?? [],
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function computeLcmRecordKey(input) {
|
|
66
|
+
const text = (input.text ?? "").trim();
|
|
67
|
+
if (!text && input.kind !== "boundary")
|
|
68
|
+
throw new Error("LCM record text must not be empty");
|
|
69
|
+
const parts = input.parts?.length ? input.parts : null;
|
|
70
|
+
return stableHash({
|
|
71
|
+
segmentId: input.segmentId,
|
|
72
|
+
kind: input.kind,
|
|
73
|
+
text: text || "Session boundary",
|
|
74
|
+
parts,
|
|
75
|
+
happenedAt: input.happenedAt ?? new Date().toISOString(),
|
|
76
|
+
source: normalizeSource(input.source),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function dedupeSummaryParentIds(values) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const result = [];
|
|
82
|
+
for (const value of values) {
|
|
83
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
84
|
+
throw new Error("LCM summary parents must be positive integer ids");
|
|
85
|
+
if (seen.has(value))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(value);
|
|
88
|
+
result.push(value);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|