Extract knowledge store core helpers
This commit is contained in:
parent
9e75151e56
commit
2b1da0c5ea
7 changed files with 575 additions and 307 deletions
|
|
@ -61,6 +61,7 @@ const behaviorTests = [
|
||||||
".test-dist/tests/claude-memory.test.js",
|
".test-dist/tests/claude-memory.test.js",
|
||||||
".test-dist/tests/prompt-memory-extraction.test.js",
|
".test-dist/tests/prompt-memory-extraction.test.js",
|
||||||
".test-dist/tests/familiaros-memory-search.test.js",
|
".test-dist/tests/familiaros-memory-search.test.js",
|
||||||
|
".test-dist/tests/knowledge-store-core-seam.test.js",
|
||||||
".test-dist/tests/knowledge-store.test.js",
|
".test-dist/tests/knowledge-store.test.js",
|
||||||
".test-dist/tests/control-center-service-barrels.test.js",
|
".test-dist/tests/control-center-service-barrels.test.js",
|
||||||
".test-dist/tests/mcp-toolkit-catalog.test.js",
|
".test-dist/tests/mcp-toolkit-catalog.test.js",
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,10 @@ const promptWindowSource = readFileSync(join(appDir, "src", "prompt-window.ts"),
|
||||||
const familiarosMemorySource = readFileSync(join(appDir, "src", "familiaros-memory.ts"), "utf8");
|
const familiarosMemorySource = readFileSync(join(appDir, "src", "familiaros-memory.ts"), "utf8");
|
||||||
const familiarosMemoryStoreSource = readFileSync(join(appDir, "src", "familiaros-memory-store.ts"), "utf8");
|
const familiarosMemoryStoreSource = readFileSync(join(appDir, "src", "familiaros-memory-store.ts"), "utf8");
|
||||||
const familiarosMemorySearchSource = readFileSync(join(appDir, "src", "familiaros-memory-search.ts"), "utf8");
|
const familiarosMemorySearchSource = readFileSync(join(appDir, "src", "familiaros-memory-search.ts"), "utf8");
|
||||||
|
const knowledgeStoreSource = readFileSync(join(appDir, "src", "knowledge-store.ts"), "utf8");
|
||||||
|
const knowledgeStoreCoreSource = readFileSync(join(appDir, "src", "knowledge-store-core.ts"), "utf8");
|
||||||
|
const knowledgeStoreCoreSupportSource = readFileSync(join(appDir, "src", "knowledge-store-core-support.ts"), "utf8");
|
||||||
|
const knowledgeStoreCoreHelpersSource = readFileSync(join(appDir, "src", "knowledge-store-core-helpers.ts"), "utf8");
|
||||||
const appStateSource = readFileSync(join(appDir, "src", "app-state.ts"), "utf8");
|
const appStateSource = readFileSync(join(appDir, "src", "app-state.ts"), "utf8");
|
||||||
const appStatePreferencesSource = readFileSync(join(appDir, "src", "app-state-preferences.ts"), "utf8");
|
const appStatePreferencesSource = readFileSync(join(appDir, "src", "app-state-preferences.ts"), "utf8");
|
||||||
const appStateAnalyticsSource = readFileSync(join(appDir, "src", "app-state-analytics.ts"), "utf8");
|
const appStateAnalyticsSource = readFileSync(join(appDir, "src", "app-state-analytics.ts"), "utf8");
|
||||||
|
|
@ -407,6 +411,14 @@ assert.match(familiarosMemoryStoreSource, /export function pruneMemoryEntries/,
|
||||||
assert.match(familiarosMemorySearchSource, /export function searchMemoryEntries/, "familiaros-memory search seam must export scored memory search.");
|
assert.match(familiarosMemorySearchSource, /export function searchMemoryEntries/, "familiaros-memory search seam must export scored memory search.");
|
||||||
assert.match(familiarosMemorySearchSource, /export function buildRelevantMemoryContextBlock/, "familiaros-memory search seam must export relevant memory context shaping.");
|
assert.match(familiarosMemorySearchSource, /export function buildRelevantMemoryContextBlock/, "familiaros-memory search seam must export relevant memory context shaping.");
|
||||||
assert.match(familiarosMemorySearchSource, /export function scoreRetentionPriority/, "familiaros-memory search seam must export retention scoring.");
|
assert.match(familiarosMemorySearchSource, /export function scoreRetentionPriority/, "familiaros-memory search seam must export retention scoring.");
|
||||||
|
assert.match(knowledgeStoreSource, /from "\.\/knowledge-store-core(?:\.js)?"/, "knowledge-store facade must continue routing through the core class seam.");
|
||||||
|
assert.match(knowledgeStoreCoreSource, /from "\.\/knowledge-store-core-support(?:\.js)?"/, "knowledge-store core must import the extracted support seam.");
|
||||||
|
assert.match(knowledgeStoreCoreSupportSource, /export function createKnowledgeFileEntry/, "knowledge-store support must own stored-file validation and shaping.");
|
||||||
|
assert.match(knowledgeStoreCoreSupportSource, /from "\.\/knowledge-store-core-helpers(?:\.js)?"/, "knowledge-store support must import the extracted helper seam.");
|
||||||
|
assert.match(knowledgeStoreCoreSupportSource, /export function searchKnowledgeFiles/, "knowledge-store support must own file search scoring.");
|
||||||
|
assert.match(knowledgeStoreCoreSupportSource, /export function buildRelevantKnowledgeContext/, "knowledge-store support must own relevant-context assembly.");
|
||||||
|
assert.match(knowledgeStoreCoreHelpersSource, /export function normalizeKnowledgeFileEntry/, "knowledge-store helpers must own index-entry normalization.");
|
||||||
|
assert.match(knowledgeStoreCoreHelpersSource, /export function scoreKnowledgeFile/, "knowledge-store helpers must own low-level search scoring.");
|
||||||
assert.match(promptWindowRenderSource, /from "\.\/prompt-window-render-script(?:\.js)?"/, "Prompt window render helper must import the extracted inline script seam.");
|
assert.match(promptWindowRenderSource, /from "\.\/prompt-window-render-script(?:\.js)?"/, "Prompt window render helper must import the extracted inline script seam.");
|
||||||
assert.match(promptWindowRenderScriptSource, /from "\.\/prompt-window-render-script-sections(?:\.js)?"/, "Prompt window render script seam must import the extracted script section builders.");
|
assert.match(promptWindowRenderScriptSource, /from "\.\/prompt-window-render-script-sections(?:\.js)?"/, "Prompt window render script seam must import the extracted script section builders.");
|
||||||
assert.match(promptWindowRenderScriptSource, /from "\.\/prompt-window-render-script-interactions(?:\.js)?"/, "Prompt window render script seam must import the extracted interaction builder.");
|
assert.match(promptWindowRenderScriptSource, /from "\.\/prompt-window-render-script-interactions(?:\.js)?"/, "Prompt window render script seam must import the extracted interaction builder.");
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ familiar-window preload double-click or tray/chat entry → openPromptWindow()
|
||||||
├── prompt-window-render.ts builds the inline prompt window HTML document/data URL
|
├── prompt-window-render.ts builds the inline prompt window HTML document/data URL
|
||||||
│ └── prompt-window-render-script.ts owns the injected prompt window client script
|
│ └── prompt-window-render-script.ts owns the injected prompt window client script
|
||||||
├── prompt-window-preload.cjs exposes the narrow prompt window bridge
|
├── prompt-window-preload.cjs exposes the narrow prompt window bridge
|
||||||
└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-store.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, and knowledge-store.ts handle prompt submission, long-term memory recall, credential persistence, request shaping, provider routing, history, and attachment storage
|
└── openapi-chat.ts, familiaros-memory.ts, familiaros-memory-store.ts, familiaros-memory-search.ts, openapi-chat-config-store.ts, openapi-chat-request-helpers.ts, openapi-chat-provider.ts, knowledge-store.ts, and knowledge-store-core.ts handle prompt submission, long-term memory recall, knowledge-file storage/search, credential persistence, request shaping, provider routing, history, and attachment storage
|
||||||
```
|
```
|
||||||
|
|
||||||
**Plugin Flow**:
|
**Plugin Flow**:
|
||||||
|
|
@ -219,6 +219,10 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
|
||||||
- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows
|
- `familiaros-memory.ts`: Public long-term memory API, CRUD orchestration, context shaping, and prompt-memory capture for chat, IPC, and knowledge-store flows
|
||||||
- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers
|
- `familiaros-memory-store.ts`: Extracted memory cache/persistence, JSON and markdown mirror writes, normalization, retention pruning, and mutation helpers
|
||||||
- `familiaros-memory-search.ts`: Extracted pure memory query normalization, scoring, recall-intent fallback, chat-history relevance, and context-block shaping for FamiliarOS memory
|
- `familiaros-memory-search.ts`: Extracted pure memory query normalization, scoring, recall-intent fallback, chat-history relevance, and context-block shaping for FamiliarOS memory
|
||||||
|
- `knowledge-store.ts`: Public knowledge-store facade that combines file search/storage with FamiliarOS memory search results
|
||||||
|
- `knowledge-store-core.ts`: Extracted disk-backed knowledge file store class for CRUD, index caching, and path lookup
|
||||||
|
- `knowledge-store-core-support.ts`: Extracted knowledge-store public support seam for file validation, search/context assembly, normalization, and atomic writes
|
||||||
|
- `knowledge-store-core-helpers.ts`: Low-level knowledge-store parsing, MIME/text inference, query scoring, and tokenization helpers used by the public support seam
|
||||||
- `openapi-chat-tool-loop.ts`: Extracted chat-completions tool-call loop, history seeding, tool-result threading, and tool-aware instruction helpers for prompt-window OpenAPI chat
|
- `openapi-chat-tool-loop.ts`: Extracted chat-completions tool-call loop, history seeding, tool-result threading, and tool-aware instruction helpers for prompt-window OpenAPI chat
|
||||||
- `openapi-chat-config-store.ts`: Extracted credential persistence, storage-mode selection, current/legacy config path handling, and stored-credential decode helpers for prompt-window OpenAPI chat
|
- `openapi-chat-config-store.ts`: Extracted credential persistence, storage-mode selection, current/legacy config path handling, and stored-credential decode helpers for prompt-window OpenAPI chat
|
||||||
- `openapi-chat-request-helpers.ts`: Extracted pure request-body and chat-completions history shaping for prompt-window OpenAPI chat
|
- `openapi-chat-request-helpers.ts`: Extracted pure request-body and chat-completions history shaping for prompt-window OpenAPI chat
|
||||||
|
|
|
||||||
235
apps/desktop/src/knowledge-store-core-helpers.ts
Normal file
235
apps/desktop/src/knowledge-store-core-helpers.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { TextDecoder } from "node:util";
|
||||||
|
|
||||||
|
import type { KnowledgeFileEntry } from "./knowledge-store-core-support.js";
|
||||||
|
|
||||||
|
export const maxKnowledgeFileSizeBytes = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const maxExtractedTextChars = 2_000;
|
||||||
|
const maxFileNameLength = 128;
|
||||||
|
|
||||||
|
export function normalizeKnowledgeFileEntry(
|
||||||
|
value: unknown,
|
||||||
|
): KnowledgeFileEntry | undefined {
|
||||||
|
if (
|
||||||
|
!isRecord(value) ||
|
||||||
|
typeof value.id !== "string" ||
|
||||||
|
!value.id.startsWith("kfile-")
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const name =
|
||||||
|
sanitizeFileName(value.name) ??
|
||||||
|
sanitizeFileName(value.originalName) ??
|
||||||
|
"unnamed";
|
||||||
|
const originalName = sanitizeFileName(value.originalName) ?? name;
|
||||||
|
const mimeType =
|
||||||
|
typeof value.mimeType === "string" && value.mimeType.includes("/")
|
||||||
|
? value.mimeType
|
||||||
|
: inferMimeType(originalName);
|
||||||
|
const size =
|
||||||
|
typeof value.size === "number" &&
|
||||||
|
Number.isFinite(value.size) &&
|
||||||
|
value.size > 0
|
||||||
|
? Math.floor(value.size)
|
||||||
|
: 0;
|
||||||
|
const isText = value.isText === true;
|
||||||
|
const extractedText =
|
||||||
|
isText && typeof value.extractedText === "string"
|
||||||
|
? trimExtractedText(value.extractedText)
|
||||||
|
: undefined;
|
||||||
|
const addedAt =
|
||||||
|
typeof value.addedAt === "number" &&
|
||||||
|
Number.isFinite(value.addedAt) &&
|
||||||
|
value.addedAt > 0
|
||||||
|
? Math.floor(value.addedAt)
|
||||||
|
: Date.now();
|
||||||
|
return {
|
||||||
|
id: value.id,
|
||||||
|
name,
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
size,
|
||||||
|
isText,
|
||||||
|
extractedText,
|
||||||
|
extractedTextLength: extractedText?.length ?? 0,
|
||||||
|
addedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreKnowledgeFile(
|
||||||
|
file: KnowledgeFileEntry,
|
||||||
|
queryTokens: ReadonlySet<string>,
|
||||||
|
normalizedQuery: string,
|
||||||
|
): number {
|
||||||
|
const nameTokens = tokenize(file.name);
|
||||||
|
let overlapCount = 0;
|
||||||
|
for (const token of queryTokens) {
|
||||||
|
if (nameTokens.has(token)) {
|
||||||
|
overlapCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textTokens = file.extractedText
|
||||||
|
? tokenize(file.extractedText)
|
||||||
|
: new Set<string>();
|
||||||
|
for (const token of queryTokens) {
|
||||||
|
if (textTokens.has(token)) {
|
||||||
|
overlapCount += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = normalizeKnowledgeComparisonValue(file.name);
|
||||||
|
const normalizedText = normalizeKnowledgeComparisonValue(
|
||||||
|
file.extractedText ?? "",
|
||||||
|
);
|
||||||
|
const phraseBonus =
|
||||||
|
normalizedName.includes(normalizedQuery) ||
|
||||||
|
normalizedText.includes(normalizedQuery)
|
||||||
|
? 2
|
||||||
|
: 0;
|
||||||
|
if (overlapCount === 0 && phraseBonus === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const recencyBonus =
|
||||||
|
Math.max(0, 1 - (Date.now() - file.addedAt) / (30 * 24 * 60 * 60 * 1000)) *
|
||||||
|
0.3;
|
||||||
|
const sizePenalty =
|
||||||
|
Math.max(0, file.size / maxKnowledgeFileSizeBytes) * 0.2;
|
||||||
|
return overlapCount + phraseBonus + recencyBonus - sizePenalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTextContent(data: Buffer): string {
|
||||||
|
const raw = data.toString("utf8");
|
||||||
|
const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
return trimExtractedText(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFileName(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const name = value.replace(/\\/g, "/").split("/").pop() ?? "";
|
||||||
|
const sanitized = name
|
||||||
|
.replace(/[\x00-\x1f\x7f<>|:*"?\\]/g, "_")
|
||||||
|
.trim();
|
||||||
|
if (!sanitized || sanitized.length > maxFileNameLength) return undefined;
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimFileName(name: string): string {
|
||||||
|
if (name.length <= maxFileNameLength) return name;
|
||||||
|
const extension = extname(name);
|
||||||
|
const base = name.slice(0, name.length - extension.length);
|
||||||
|
const maxBaseLength = maxFileNameLength - extension.length - 1;
|
||||||
|
return `${base.slice(0, Math.max(0, maxBaseLength))}…${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferMimeType(fileName: string): string {
|
||||||
|
const ext = extname(fileName).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case ".txt":
|
||||||
|
return "text/plain";
|
||||||
|
case ".md":
|
||||||
|
return "text/markdown";
|
||||||
|
case ".json":
|
||||||
|
return "application/json";
|
||||||
|
case ".js":
|
||||||
|
return "text/javascript";
|
||||||
|
case ".ts":
|
||||||
|
return "text/typescript";
|
||||||
|
case ".jsx":
|
||||||
|
return "text/jsx";
|
||||||
|
case ".tsx":
|
||||||
|
return "text/tsx";
|
||||||
|
case ".css":
|
||||||
|
return "text/css";
|
||||||
|
case ".html":
|
||||||
|
return "text/html";
|
||||||
|
case ".xml":
|
||||||
|
return "text/xml";
|
||||||
|
case ".yaml":
|
||||||
|
case ".yml":
|
||||||
|
return "text/yaml";
|
||||||
|
case ".csv":
|
||||||
|
return "text/csv";
|
||||||
|
case ".pdf":
|
||||||
|
return "application/pdf";
|
||||||
|
case ".png":
|
||||||
|
return "image/png";
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif";
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp";
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml";
|
||||||
|
case ".mp3":
|
||||||
|
return "audio/mpeg";
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4";
|
||||||
|
default:
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKnowledgeQuery(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKnowledgeComparisonValue(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenize(value: string): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
normalizeKnowledgeComparisonValue(value)
|
||||||
|
.split(" ")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidUtf8Buffer(data: Buffer): boolean {
|
||||||
|
const bufferConstructor = Buffer as unknown as {
|
||||||
|
isUtf8?(data: Buffer): boolean;
|
||||||
|
};
|
||||||
|
if (typeof bufferConstructor.isUtf8 === "function") {
|
||||||
|
return bufferConstructor.isUtf8(data);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(data);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortKnowledgeFiles(
|
||||||
|
files: readonly KnowledgeFileEntry[],
|
||||||
|
): readonly KnowledgeFileEntry[] {
|
||||||
|
return [...files].sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.addedAt - left.addedAt || left.name.localeCompare(right.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimExtractedText(text: string): string {
|
||||||
|
const trimmed = text.replace(/[ \t]+/g, " ").trim();
|
||||||
|
if (trimmed.length <= maxExtractedTextChars) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `${trimmed.slice(0, maxExtractedTextChars)}\n…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extname(name: string): string {
|
||||||
|
const dotIndex = name.lastIndexOf(".");
|
||||||
|
return dotIndex <= 0 ? "" : name.slice(dotIndex);
|
||||||
|
}
|
||||||
228
apps/desktop/src/knowledge-store-core-support.ts
Normal file
228
apps/desktop/src/knowledge-store-core-support.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, extname } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractTextContent,
|
||||||
|
inferMimeType,
|
||||||
|
isRecord,
|
||||||
|
isValidUtf8Buffer,
|
||||||
|
maxKnowledgeFileSizeBytes,
|
||||||
|
normalizeKnowledgeComparisonValue,
|
||||||
|
normalizeKnowledgeFileEntry as parseKnowledgeFileEntry,
|
||||||
|
normalizeKnowledgeQuery,
|
||||||
|
sanitizeFileName,
|
||||||
|
scoreKnowledgeFile,
|
||||||
|
sortKnowledgeFiles,
|
||||||
|
tokenize,
|
||||||
|
trimFileName,
|
||||||
|
} from "./knowledge-store-core-helpers.js";
|
||||||
|
|
||||||
|
export interface KnowledgeFileEntry {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly originalName: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly isText: boolean;
|
||||||
|
readonly extractedText: string | undefined;
|
||||||
|
readonly extractedTextLength: number;
|
||||||
|
readonly addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeFileSearchHit {
|
||||||
|
readonly file: KnowledgeFileEntry;
|
||||||
|
readonly score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreKnowledgeFileInput {
|
||||||
|
readonly name: string;
|
||||||
|
readonly data: Buffer;
|
||||||
|
readonly mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeStoreIndexV1 {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly files: readonly KnowledgeFileEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeStoreVersion = 1;
|
||||||
|
|
||||||
|
const maxKnowledgeFiles = 100;
|
||||||
|
const maxRelevantKnowledgeItems = 4;
|
||||||
|
const maxRelevantKnowledgeChars = 2_000;
|
||||||
|
|
||||||
|
export function clampKnowledgeLimit(value: number, fallback: number): number {
|
||||||
|
if (!Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(Math.max(Math.round(value), 1), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneKnowledgeFileEntry(
|
||||||
|
entry: KnowledgeFileEntry,
|
||||||
|
): KnowledgeFileEntry {
|
||||||
|
return { ...entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKnowledgeFileEntry(
|
||||||
|
id: string,
|
||||||
|
input: StoreKnowledgeFileInput,
|
||||||
|
addedAt = Date.now(),
|
||||||
|
): { readonly entry: KnowledgeFileEntry; readonly extension: string } {
|
||||||
|
const originalName = sanitizeFileName(input.name);
|
||||||
|
if (!originalName) {
|
||||||
|
throw new Error("File name is required.");
|
||||||
|
}
|
||||||
|
if (!Buffer.isBuffer(input.data)) {
|
||||||
|
throw new Error("File data must be a Buffer.");
|
||||||
|
}
|
||||||
|
if (input.data.length === 0) {
|
||||||
|
throw new Error("File cannot be empty.");
|
||||||
|
}
|
||||||
|
if (input.data.length > maxKnowledgeFileSizeBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`File is too large. Maximum size is ${formatBytes(maxKnowledgeFileSizeBytes)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = trimFileName(originalName);
|
||||||
|
const extension = extname(originalName).toLowerCase();
|
||||||
|
const mimeType =
|
||||||
|
input.mimeType && input.mimeType.includes("/")
|
||||||
|
? input.mimeType.trim()
|
||||||
|
: inferMimeType(originalName);
|
||||||
|
const isText = isValidUtf8Buffer(input.data) && !input.data.includes(0);
|
||||||
|
const extractedText = isText ? extractTextContent(input.data) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension,
|
||||||
|
entry: {
|
||||||
|
id,
|
||||||
|
name: displayName,
|
||||||
|
originalName,
|
||||||
|
mimeType,
|
||||||
|
size: input.data.length,
|
||||||
|
isText,
|
||||||
|
extractedText,
|
||||||
|
extractedTextLength: extractedText?.length ?? 0,
|
||||||
|
addedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchKnowledgeFiles(
|
||||||
|
files: readonly KnowledgeFileEntry[],
|
||||||
|
query: string,
|
||||||
|
limit = 10,
|
||||||
|
): readonly KnowledgeFileSearchHit[] {
|
||||||
|
const trimmedQuery = normalizeKnowledgeQuery(query);
|
||||||
|
const nextLimit = clampKnowledgeLimit(limit, 10);
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
return sortKnowledgeFiles(files)
|
||||||
|
.slice(0, nextLimit)
|
||||||
|
.map((file) => ({ file: cloneKnowledgeFileEntry(file), score: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryTokens = tokenize(trimmedQuery);
|
||||||
|
const normalizedQuery = normalizeKnowledgeComparisonValue(trimmedQuery);
|
||||||
|
return files
|
||||||
|
.map((file) => ({
|
||||||
|
file,
|
||||||
|
score: scoreKnowledgeFile(file, queryTokens, normalizedQuery),
|
||||||
|
}))
|
||||||
|
.filter((hit) => hit.score > 0)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.score - left.score ||
|
||||||
|
right.file.addedAt - left.file.addedAt ||
|
||||||
|
left.file.name.localeCompare(right.file.name),
|
||||||
|
)
|
||||||
|
.slice(0, nextLimit)
|
||||||
|
.map((hit) => ({
|
||||||
|
file: cloneKnowledgeFileEntry(hit.file),
|
||||||
|
score: Math.round(hit.score * 100) / 100,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRelevantKnowledgeContext(
|
||||||
|
files: readonly KnowledgeFileEntry[],
|
||||||
|
prompt: string,
|
||||||
|
): string | undefined {
|
||||||
|
const trimmedQuery = normalizeKnowledgeQuery(prompt);
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = searchKnowledgeFiles(
|
||||||
|
files,
|
||||||
|
trimmedQuery,
|
||||||
|
maxRelevantKnowledgeItems,
|
||||||
|
).filter((hit) => hit.score > 0);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
let usedChars = 0;
|
||||||
|
for (const hit of hits) {
|
||||||
|
const file = hit.file;
|
||||||
|
const header = `- [file: ${file.name}] (${formatBytes(file.size)}, ${file.mimeType})`;
|
||||||
|
const body =
|
||||||
|
file.isText && file.extractedText
|
||||||
|
? file.extractedText
|
||||||
|
: "[Non-text file: content not shown.]";
|
||||||
|
const entry = `${header}\n${body}`;
|
||||||
|
if (usedChars > 0 && usedChars + entry.length + 2 > maxRelevantKnowledgeChars) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lines.push(entry);
|
||||||
|
usedChars += entry.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Relevant stored files:",
|
||||||
|
...lines,
|
||||||
|
"Use the above files only when they help answer the current request.",
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKnowledgeStore(value: unknown): KnowledgeStoreIndexV1 {
|
||||||
|
const record = isRecord(value) ? value : {};
|
||||||
|
const files = Array.isArray(record.files)
|
||||||
|
? record.files
|
||||||
|
.map((file) => parseKnowledgeFileEntry(file))
|
||||||
|
.filter((file): file is KnowledgeFileEntry => Boolean(file))
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
files: pruneKnowledgeFiles(files),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneKnowledgeFiles(
|
||||||
|
files: readonly KnowledgeFileEntry[],
|
||||||
|
): readonly KnowledgeFileEntry[] {
|
||||||
|
if (files.length <= maxKnowledgeFiles) {
|
||||||
|
return [...files];
|
||||||
|
}
|
||||||
|
return sortKnowledgeFiles(files).slice(0, maxKnowledgeFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function atomicWrite(
|
||||||
|
targetPath: string,
|
||||||
|
data: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
await mkdir(dirname(targetPath), { recursive: true });
|
||||||
|
const tempPath = `${targetPath}.${process.pid}.tmp`;
|
||||||
|
await writeFile(tempPath, data);
|
||||||
|
await rename(tempPath, targetPath);
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,34 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { Buffer } from "node:buffer";
|
|
||||||
import { TextDecoder } from "node:util";
|
|
||||||
import { dirname, extname, join } from "node:path";
|
import { dirname, extname, join } from "node:path";
|
||||||
|
|
||||||
export interface KnowledgeFileEntry {
|
import {
|
||||||
readonly id: string;
|
atomicWrite,
|
||||||
readonly name: string;
|
buildRelevantKnowledgeContext,
|
||||||
readonly originalName: string;
|
clampKnowledgeLimit,
|
||||||
readonly mimeType: string;
|
cloneKnowledgeFileEntry,
|
||||||
readonly size: number;
|
createKnowledgeFileEntry,
|
||||||
readonly isText: boolean;
|
knowledgeStoreVersion,
|
||||||
readonly extractedText: string | undefined;
|
normalizeKnowledgeStore,
|
||||||
readonly extractedTextLength: number;
|
pruneKnowledgeFiles,
|
||||||
readonly addedAt: number;
|
searchKnowledgeFiles,
|
||||||
}
|
type KnowledgeFileEntry,
|
||||||
|
type KnowledgeFileSearchHit,
|
||||||
|
type KnowledgeStoreIndexV1,
|
||||||
|
type StoreKnowledgeFileInput,
|
||||||
|
} from "./knowledge-store-core-support.js";
|
||||||
|
|
||||||
export interface KnowledgeFileSearchHit {
|
export { formatBytes } from "./knowledge-store-core-support.js";
|
||||||
readonly file: KnowledgeFileEntry;
|
export type {
|
||||||
readonly score: number;
|
KnowledgeFileEntry,
|
||||||
}
|
KnowledgeFileSearchHit,
|
||||||
|
StoreKnowledgeFileInput,
|
||||||
|
} from "./knowledge-store-core-support.js";
|
||||||
|
|
||||||
export interface StoreKnowledgeFileInput {
|
|
||||||
readonly name: string;
|
|
||||||
readonly data: Buffer;
|
|
||||||
readonly mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knowledgeStoreVersion = 1;
|
|
||||||
const maxKnowledgeFiles = 100;
|
|
||||||
const maxKnowledgeFileSizeBytes = 5 * 1024 * 1024;
|
|
||||||
const maxExtractedTextChars = 2_000;
|
|
||||||
const maxRelevantKnowledgeItems = 4;
|
|
||||||
const maxRelevantKnowledgeChars = 2_000;
|
|
||||||
const maxFileNameLength = 128;
|
|
||||||
const knowledgeFilesDirName = "files";
|
const knowledgeFilesDirName = "files";
|
||||||
const knowledgeStoreIndexFileName = "knowledge-store.json";
|
const knowledgeStoreIndexFileName = "knowledge-store.json";
|
||||||
|
|
||||||
interface KnowledgeStoreIndexV1 {
|
|
||||||
readonly version: 1;
|
|
||||||
readonly files: readonly KnowledgeFileEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class KnowledgeStore {
|
export class KnowledgeStore {
|
||||||
private cachedStore: KnowledgeStoreIndexV1 | null = null;
|
private cachedStore: KnowledgeStoreIndexV1 | null = null;
|
||||||
|
|
||||||
|
|
@ -51,68 +37,23 @@ export class KnowledgeStore {
|
||||||
listFiles(limit = 50): readonly KnowledgeFileEntry[] {
|
listFiles(limit = 50): readonly KnowledgeFileEntry[] {
|
||||||
const nextLimit = clampKnowledgeLimit(limit, 50);
|
const nextLimit = clampKnowledgeLimit(limit, 50);
|
||||||
return [...this.getStore().files]
|
return [...this.getStore().files]
|
||||||
.sort((left, right) => right.addedAt - left.addedAt || left.name.localeCompare(right.name))
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.addedAt - left.addedAt || left.name.localeCompare(right.name),
|
||||||
|
)
|
||||||
.slice(0, nextLimit)
|
.slice(0, nextLimit)
|
||||||
.map(cloneKnowledgeFileEntry);
|
.map(cloneKnowledgeFileEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchFiles(query: string, limit = 10): readonly KnowledgeFileSearchHit[] {
|
searchFiles(query: string, limit = 10): readonly KnowledgeFileSearchHit[] {
|
||||||
const trimmedQuery = normalizeKnowledgeQuery(query);
|
return searchKnowledgeFiles(this.getStore().files, query, limit);
|
||||||
const nextLimit = clampKnowledgeLimit(limit, 10);
|
|
||||||
if (!trimmedQuery) {
|
|
||||||
return this.listFiles(nextLimit).map((file) => ({ file, score: 0 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryTokens = tokenize(trimmedQuery);
|
|
||||||
const normalizedQuery = normalizeKnowledgeComparisonValue(trimmedQuery);
|
|
||||||
return this.getStore().files
|
|
||||||
.map((file) => ({ file, score: scoreKnowledgeFile(file, queryTokens, normalizedQuery) }))
|
|
||||||
.filter((hit) => hit.score > 0)
|
|
||||||
.sort((left, right) => right.score - left.score || right.file.addedAt - left.file.addedAt || left.file.name.localeCompare(right.file.name))
|
|
||||||
.slice(0, nextLimit)
|
|
||||||
.map((hit) => ({ file: cloneKnowledgeFileEntry(hit.file), score: Math.round(hit.score * 100) / 100 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async storeFile(input: StoreKnowledgeFileInput): Promise<KnowledgeFileEntry> {
|
async storeFile(input: StoreKnowledgeFileInput): Promise<KnowledgeFileEntry> {
|
||||||
const originalName = sanitizeFileName(input.name);
|
|
||||||
if (!originalName) {
|
|
||||||
throw new Error("File name is required.");
|
|
||||||
}
|
|
||||||
if (!Buffer.isBuffer(input.data)) {
|
|
||||||
throw new Error("File data must be a Buffer.");
|
|
||||||
}
|
|
||||||
if (input.data.length === 0) {
|
|
||||||
throw new Error("File cannot be empty.");
|
|
||||||
}
|
|
||||||
if (input.data.length > maxKnowledgeFileSizeBytes) {
|
|
||||||
throw new Error(`File is too large. Maximum size is ${formatBytes(maxKnowledgeFileSizeBytes)}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = `kfile-${randomUUID()}`;
|
const id = `kfile-${randomUUID()}`;
|
||||||
const displayName = trimFileName(originalName);
|
const { entry, extension } = createKnowledgeFileEntry(id, input);
|
||||||
const extension = extname(originalName).toLowerCase();
|
|
||||||
const mimeType = input.mimeType && input.mimeType.includes("/")
|
|
||||||
? input.mimeType.trim()
|
|
||||||
: inferMimeType(originalName);
|
|
||||||
|
|
||||||
const isText = isValidUtf8Buffer(input.data) && !input.data.includes(0);
|
|
||||||
const extractedText = isText ? extractTextContent(input.data) : undefined;
|
|
||||||
|
|
||||||
const entry: KnowledgeFileEntry = {
|
|
||||||
id,
|
|
||||||
name: displayName,
|
|
||||||
originalName,
|
|
||||||
mimeType,
|
|
||||||
size: input.data.length,
|
|
||||||
isText,
|
|
||||||
extractedText,
|
|
||||||
extractedTextLength: extractedText?.length ?? 0,
|
|
||||||
addedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = this.getStore();
|
const store = this.getStore();
|
||||||
const filesDir = this.getFilesDir();
|
const filesDir = this.getFilesDir();
|
||||||
await mkdir(filesDir, { recursive: true });
|
|
||||||
|
|
||||||
const filePath = join(filesDir, `${id}${extension}`);
|
const filePath = join(filesDir, `${id}${extension}`);
|
||||||
await atomicWrite(filePath, input.data);
|
await atomicWrite(filePath, input.data);
|
||||||
|
|
@ -154,38 +95,7 @@ export class KnowledgeStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
buildRelevantContext(prompt: string): string | undefined {
|
buildRelevantContext(prompt: string): string | undefined {
|
||||||
const trimmedQuery = normalizeKnowledgeQuery(prompt);
|
return buildRelevantKnowledgeContext(this.getStore().files, prompt);
|
||||||
if (!trimmedQuery) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const hits = this.searchFiles(trimmedQuery, maxRelevantKnowledgeItems).filter((hit) => hit.score > 0);
|
|
||||||
if (hits.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
let usedChars = 0;
|
|
||||||
for (const hit of hits) {
|
|
||||||
const file = hit.file;
|
|
||||||
const header = `- [file: ${file.name}] (${formatBytes(file.size)}, ${file.mimeType})`;
|
|
||||||
const body = file.isText && file.extractedText ? file.extractedText : "[Non-text file: content not shown.]";
|
|
||||||
const entry = `${header}\n${body}`;
|
|
||||||
if (usedChars > 0 && usedChars + entry.length + 2 > maxRelevantKnowledgeChars) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lines.push(entry);
|
|
||||||
usedChars += entry.length + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
"Relevant stored files:",
|
|
||||||
...lines,
|
|
||||||
"Use the above files only when they help answer the current request.",
|
|
||||||
].join("\n\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getFilePath(id: string): string | null {
|
getFilePath(id: string): string | null {
|
||||||
|
|
@ -205,14 +115,14 @@ export class KnowledgeStore {
|
||||||
const raw = JSON.parse(readFileSync(this.getIndexPath(), "utf8")) as unknown;
|
const raw = JSON.parse(readFileSync(this.getIndexPath(), "utf8")) as unknown;
|
||||||
this.cachedStore = normalizeKnowledgeStore(raw);
|
this.cachedStore = normalizeKnowledgeStore(raw);
|
||||||
} catch {
|
} catch {
|
||||||
this.cachedStore = { version: 1, files: [] };
|
this.cachedStore = { version: knowledgeStoreVersion, files: [] };
|
||||||
}
|
}
|
||||||
return this.cachedStore;
|
return this.cachedStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
private commitStore(store: KnowledgeStoreIndexV1): void {
|
private commitStore(store: KnowledgeStoreIndexV1): void {
|
||||||
this.cachedStore = {
|
this.cachedStore = {
|
||||||
version: 1,
|
version: knowledgeStoreVersion,
|
||||||
files: store.files.map(cloneKnowledgeFileEntry),
|
files: store.files.map(cloneKnowledgeFileEntry),
|
||||||
};
|
};
|
||||||
const path = this.getIndexPath();
|
const path = this.getIndexPath();
|
||||||
|
|
@ -231,188 +141,3 @@ export class KnowledgeStore {
|
||||||
return join(this.baseDir, knowledgeStoreIndexFileName);
|
return join(this.baseDir, knowledgeStoreIndexFileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnowledgeStore(value: unknown): KnowledgeStoreIndexV1 {
|
|
||||||
const record = isRecord(value) ? value : {};
|
|
||||||
const files = Array.isArray(record.files)
|
|
||||||
? record.files.map((file) => normalizeKnowledgeFileEntry(file)).filter((file): file is KnowledgeFileEntry => Boolean(file))
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
files: pruneKnowledgeFiles(files),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnowledgeFileEntry(value: unknown): KnowledgeFileEntry | undefined {
|
|
||||||
if (!isRecord(value) || typeof value.id !== "string" || !value.id.startsWith("kfile-")) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const name = sanitizeFileName(value.name) ?? sanitizeFileName(value.originalName) ?? "unnamed";
|
|
||||||
const originalName = sanitizeFileName(value.originalName) ?? name;
|
|
||||||
const mimeType = typeof value.mimeType === "string" && value.mimeType.includes("/") ? value.mimeType : inferMimeType(originalName);
|
|
||||||
const size = typeof value.size === "number" && Number.isFinite(value.size) && value.size > 0 ? Math.floor(value.size) : 0;
|
|
||||||
const isText = value.isText === true;
|
|
||||||
const extractedText = isText && typeof value.extractedText === "string" ? trimExtractedText(value.extractedText) : undefined;
|
|
||||||
const addedAt = typeof value.addedAt === "number" && Number.isFinite(value.addedAt) && value.addedAt > 0 ? Math.floor(value.addedAt) : Date.now();
|
|
||||||
return {
|
|
||||||
id: value.id,
|
|
||||||
name,
|
|
||||||
originalName,
|
|
||||||
mimeType,
|
|
||||||
size,
|
|
||||||
isText,
|
|
||||||
extractedText,
|
|
||||||
extractedTextLength: extractedText?.length ?? 0,
|
|
||||||
addedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneKnowledgeFiles(files: readonly KnowledgeFileEntry[]): readonly KnowledgeFileEntry[] {
|
|
||||||
if (files.length <= maxKnowledgeFiles) {
|
|
||||||
return [...files];
|
|
||||||
}
|
|
||||||
return [...files]
|
|
||||||
.sort((left, right) => right.addedAt - left.addedAt)
|
|
||||||
.slice(0, maxKnowledgeFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreKnowledgeFile(file: KnowledgeFileEntry, queryTokens: ReadonlySet<string>, normalizedQuery: string): number {
|
|
||||||
const nameTokens = tokenize(file.name);
|
|
||||||
let overlapCount = 0;
|
|
||||||
for (const token of queryTokens) {
|
|
||||||
if (nameTokens.has(token)) {
|
|
||||||
overlapCount += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const textTokens = file.extractedText ? tokenize(file.extractedText) : new Set<string>();
|
|
||||||
for (const token of queryTokens) {
|
|
||||||
if (textTokens.has(token)) {
|
|
||||||
overlapCount += 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = normalizeKnowledgeComparisonValue(file.name);
|
|
||||||
const normalizedText = normalizeKnowledgeComparisonValue(file.extractedText ?? "");
|
|
||||||
const phraseBonus = normalizedName.includes(normalizedQuery) || normalizedText.includes(normalizedQuery) ? 2 : 0;
|
|
||||||
if (overlapCount === 0 && phraseBonus === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const recencyBonus = Math.max(0, 1 - (Date.now() - file.addedAt) / (30 * 24 * 60 * 60 * 1000)) * 0.3;
|
|
||||||
const sizePenalty = Math.max(0, file.size / maxKnowledgeFileSizeBytes) * 0.2;
|
|
||||||
return overlapCount + phraseBonus + recencyBonus - sizePenalty;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextContent(data: Buffer): string {
|
|
||||||
const raw = data.toString("utf8");
|
|
||||||
const normalized = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
||||||
return trimExtractedText(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimExtractedText(text: string): string {
|
|
||||||
const trimmed = text.replace(/[ \t]+/g, " ").trim();
|
|
||||||
if (trimmed.length <= maxExtractedTextChars) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return `${trimmed.slice(0, maxExtractedTextChars)}\n…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFileName(value: unknown): string | undefined {
|
|
||||||
if (typeof value !== "string") return undefined;
|
|
||||||
const name = value.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
||||||
const sanitized = name.replace(/[\x00-\x1f\x7f<>|:*"?\\]/g, "_").trim();
|
|
||||||
if (!sanitized || sanitized.length > maxFileNameLength) return undefined;
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimFileName(name: string): string {
|
|
||||||
if (name.length <= maxFileNameLength) return name;
|
|
||||||
const extension = extname(name);
|
|
||||||
const base = name.slice(0, name.length - extension.length);
|
|
||||||
const maxBaseLength = maxFileNameLength - extension.length - 1;
|
|
||||||
return `${base.slice(0, Math.max(0, maxBaseLength))}…${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferMimeType(fileName: string): string {
|
|
||||||
const ext = extname(fileName).toLowerCase();
|
|
||||||
switch (ext) {
|
|
||||||
case ".txt": return "text/plain";
|
|
||||||
case ".md": return "text/markdown";
|
|
||||||
case ".json": return "application/json";
|
|
||||||
case ".js": return "text/javascript";
|
|
||||||
case ".ts": return "text/typescript";
|
|
||||||
case ".jsx": return "text/jsx";
|
|
||||||
case ".tsx": return "text/tsx";
|
|
||||||
case ".css": return "text/css";
|
|
||||||
case ".html": return "text/html";
|
|
||||||
case ".xml": return "text/xml";
|
|
||||||
case ".yaml":
|
|
||||||
case ".yml": return "text/yaml";
|
|
||||||
case ".csv": return "text/csv";
|
|
||||||
case ".pdf": return "application/pdf";
|
|
||||||
case ".png": return "image/png";
|
|
||||||
case ".jpg":
|
|
||||||
case ".jpeg": return "image/jpeg";
|
|
||||||
case ".gif": return "image/gif";
|
|
||||||
case ".webp": return "image/webp";
|
|
||||||
case ".svg": return "image/svg+xml";
|
|
||||||
case ".mp3": return "audio/mpeg";
|
|
||||||
case ".mp4": return "video/mp4";
|
|
||||||
default: return "application/octet-stream";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneKnowledgeFileEntry(entry: KnowledgeFileEntry): KnowledgeFileEntry {
|
|
||||||
return { ...entry };
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampKnowledgeLimit(value: number, fallback: number): number {
|
|
||||||
if (!Number.isFinite(value)) return fallback;
|
|
||||||
return Math.min(Math.max(Math.round(value), 1), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnowledgeQuery(value: string): string {
|
|
||||||
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeKnowledgeComparisonValue(value: string): string {
|
|
||||||
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, " ").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(value: string): Set<string> {
|
|
||||||
return new Set(
|
|
||||||
normalizeKnowledgeComparisonValue(value)
|
|
||||||
.split(" ")
|
|
||||||
.map((token) => token.trim())
|
|
||||||
.filter((token) => token.length >= 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidUtf8Buffer(data: Buffer): boolean {
|
|
||||||
const bufferConstructor = Buffer as unknown as { isUtf8?(data: Buffer): boolean };
|
|
||||||
if (typeof bufferConstructor.isUtf8 === "function") {
|
|
||||||
return bufferConstructor.isUtf8(data);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function atomicWrite(targetPath: string, data: Buffer): Promise<void> {
|
|
||||||
await mkdir(dirname(targetPath), { recursive: true });
|
|
||||||
const tempPath = `${targetPath}.${process.pid}.tmp`;
|
|
||||||
await writeFile(tempPath, data);
|
|
||||||
await rename(tempPath, targetPath);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
63
apps/desktop/tests/knowledge-store-core-seam.test.ts
Normal file
63
apps/desktop/tests/knowledge-store-core-seam.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const desktopRoot =
|
||||||
|
process.env.FAMILIAROS_DESKTOP_ROOT ??
|
||||||
|
resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const knowledgeStoreCoreSource = readFileSync(
|
||||||
|
resolve(desktopRoot, "src/knowledge-store-core.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const knowledgeStoreCoreSupportSource = readFileSync(
|
||||||
|
resolve(desktopRoot, "src/knowledge-store-core-support.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const knowledgeStoreCoreHelpersSource = readFileSync(
|
||||||
|
resolve(desktopRoot, "src/knowledge-store-core-helpers.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSource,
|
||||||
|
/from "\.\/knowledge-store-core-support(?:\.js)?"/,
|
||||||
|
"knowledge-store core must import the extracted support seam.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSource,
|
||||||
|
/export class KnowledgeStore/,
|
||||||
|
"knowledge-store core must keep exporting the public KnowledgeStore class.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSupportSource,
|
||||||
|
/export function createKnowledgeFileEntry/,
|
||||||
|
"knowledge-store support must own stored-file validation and shaping.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSupportSource,
|
||||||
|
/from "\.\/knowledge-store-core-helpers(?:\.js)?"/,
|
||||||
|
"knowledge-store support must import the extracted helper seam.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSupportSource,
|
||||||
|
/export function searchKnowledgeFiles/,
|
||||||
|
"knowledge-store support must own file search scoring.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreSupportSource,
|
||||||
|
/export function buildRelevantKnowledgeContext/,
|
||||||
|
"knowledge-store support must own relevant-context assembly.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreHelpersSource,
|
||||||
|
/export function normalizeKnowledgeFileEntry/,
|
||||||
|
"knowledge-store helpers must own index-entry normalization.",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
knowledgeStoreCoreHelpersSource,
|
||||||
|
/export function scoreKnowledgeFile/,
|
||||||
|
"knowledge-store helpers must own low-level search scoring.",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.error("Knowledge store core seam validation passed.");
|
||||||
Loading…
Add table
Reference in a new issue