Some checks are pending
CI / test (map[image:macos-latest name:macos], 1, 4) (push) Waiting to run
CI / test (map[image:macos-latest name:macos], 2, 4) (push) Waiting to run
CI / test (map[image:macos-latest name:macos], 3, 4) (push) Waiting to run
CI / test (map[image:macos-latest name:macos], 4, 4) (push) Waiting to run
CI / test (map[image:windows-latest name:windows], 1, 4) (push) Waiting to run
CI / test (map[image:windows-latest name:windows], 2, 4) (push) Waiting to run
CI / test (map[image:windows-latest name:windows], 3, 4) (push) Waiting to run
CI / test (map[image:windows-latest name:windows], 4, 4) (push) Waiting to run
CI / merge-reports (push) Blocked by required conditions
212 lines
5.9 KiB
TypeScript
212 lines
5.9 KiB
TypeScript
import path from "node:path";
|
|
import { promises as fs } from "node:fs";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getUserDataPath } from "../../paths/paths";
|
|
import { estimateTokens } from "./token_utils";
|
|
|
|
export type SmartContextSource =
|
|
| { type: "message"; messageIndex?: number }
|
|
| { type: "code"; filePath: string }
|
|
| { type: "attachment"; name: string; mime?: string }
|
|
| { type: "other"; label?: string };
|
|
|
|
export interface SmartContextSnippet {
|
|
id: string;
|
|
text: string;
|
|
score?: number;
|
|
source: SmartContextSource;
|
|
ts: number; // epoch ms
|
|
tokens?: number;
|
|
}
|
|
|
|
export interface SmartContextMetaConfig {
|
|
maxSnippets?: number;
|
|
}
|
|
|
|
export interface SmartContextMeta {
|
|
entityId: string; // e.g., chatId as string
|
|
updatedAt: number;
|
|
rollingSummary?: string;
|
|
summaryTokens?: number;
|
|
config?: SmartContextMetaConfig;
|
|
}
|
|
|
|
function getThreadDir(chatId: number): string {
|
|
const base = path.join(getUserDataPath(), "smart-context", "threads");
|
|
return path.join(base, String(chatId));
|
|
}
|
|
|
|
function getMetaPath(chatId: number): string {
|
|
return path.join(getThreadDir(chatId), "meta.json");
|
|
}
|
|
|
|
function getSnippetsPath(chatId: number): string {
|
|
return path.join(getThreadDir(chatId), "snippets.jsonl");
|
|
}
|
|
|
|
async function ensureDir(dir: string): Promise<void> {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const metaPath = getMetaPath(chatId);
|
|
try {
|
|
const raw = await fs.readFile(metaPath, "utf8");
|
|
const meta = JSON.parse(raw) as SmartContextMeta;
|
|
return meta;
|
|
} catch {
|
|
const fresh: SmartContextMeta = {
|
|
entityId: String(chatId),
|
|
updatedAt: Date.now(),
|
|
rollingSummary: "",
|
|
summaryTokens: 0,
|
|
config: { maxSnippets: 400 },
|
|
};
|
|
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
|
|
return fresh;
|
|
}
|
|
}
|
|
|
|
export async function writeMeta(
|
|
chatId: number,
|
|
meta: SmartContextMeta,
|
|
): Promise<void> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const metaPath = getMetaPath(chatId);
|
|
const updated: SmartContextMeta = {
|
|
...meta,
|
|
entityId: String(chatId),
|
|
updatedAt: Date.now(),
|
|
};
|
|
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
|
|
}
|
|
|
|
export async function updateRollingSummary(
|
|
chatId: number,
|
|
summary: string,
|
|
): Promise<SmartContextMeta> {
|
|
const meta = await readMeta(chatId);
|
|
const summaryTokens = estimateTokens(summary || "");
|
|
const next: SmartContextMeta = {
|
|
...meta,
|
|
rollingSummary: summary,
|
|
summaryTokens,
|
|
};
|
|
await writeMeta(chatId, next);
|
|
return next;
|
|
}
|
|
|
|
export async function appendSnippets(
|
|
chatId: number,
|
|
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
|
|
): Promise<number> {
|
|
const dir = getThreadDir(chatId);
|
|
await ensureDir(dir);
|
|
const snippetsPath = getSnippetsPath(chatId);
|
|
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
|
|
id: randomUUID(),
|
|
ts: Date.now(),
|
|
tokens: estimateTokens(s.text),
|
|
...s,
|
|
}));
|
|
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
|
|
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
|
|
|
|
// prune if exceeded max
|
|
const meta = await readMeta(chatId);
|
|
const maxSnippets = meta.config?.maxSnippets ?? 400;
|
|
try {
|
|
const file = await fs.readFile(snippetsPath, "utf8");
|
|
const allLines = file.split("\n").filter(Boolean);
|
|
if (allLines.length > maxSnippets) {
|
|
const toKeep = allLines.slice(allLines.length - maxSnippets);
|
|
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
|
|
return toKeep.length;
|
|
}
|
|
return allLines.length;
|
|
} catch {
|
|
return withDefaults.length;
|
|
}
|
|
}
|
|
|
|
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
|
|
try {
|
|
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
|
|
return raw
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as SmartContextSnippet);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalize(value: number, min: number, max: number): number {
|
|
if (max === min) return 0;
|
|
return (value - min) / (max - min);
|
|
}
|
|
|
|
function keywordScore(text: string, query: string): number {
|
|
const toTokens = (s: string) =>
|
|
s
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_\- ]+/g, " ")
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
const qTokens = new Set(toTokens(query));
|
|
const tTokens = toTokens(text);
|
|
if (qTokens.size === 0 || tTokens.length === 0) return 0;
|
|
let hits = 0;
|
|
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
|
|
return hits / tTokens.length; // simple overlap ratio
|
|
}
|
|
|
|
export interface RetrieveContextResult {
|
|
rollingSummary?: string;
|
|
usedTokens: number;
|
|
snippets: SmartContextSnippet[];
|
|
}
|
|
|
|
export async function retrieveContext(
|
|
chatId: number,
|
|
query: string,
|
|
budgetTokens: number,
|
|
): Promise<RetrieveContextResult> {
|
|
const meta = await readMeta(chatId);
|
|
const snippets = await readAllSnippets(chatId);
|
|
const now = Date.now();
|
|
let minTs = now;
|
|
let maxTs = 0;
|
|
for (const s of snippets) {
|
|
if (s.ts < minTs) minTs = s.ts;
|
|
if (s.ts > maxTs) maxTs = s.ts;
|
|
}
|
|
const scored = snippets.map((s) => {
|
|
const recency = normalize(s.ts, minTs, maxTs);
|
|
const kw = keywordScore(s.text, query);
|
|
const base = 0.6 * kw + 0.4 * recency;
|
|
const score = base;
|
|
return { ...s, score } as SmartContextSnippet;
|
|
});
|
|
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
|
|
const picked: SmartContextSnippet[] = [];
|
|
let usedTokens = 0;
|
|
for (const s of scored) {
|
|
const t = s.tokens ?? estimateTokens(s.text);
|
|
if (usedTokens + t > budgetTokens) break;
|
|
picked.push(s);
|
|
usedTokens += t;
|
|
}
|
|
|
|
const rollingSummary = meta.rollingSummary || "";
|
|
return { rollingSummary, usedTokens, snippets: picked };
|
|
}
|
|
|
|
export async function rebuildIndex(_chatId: number): Promise<void> {
|
|
// Placeholder for future embedding/vector index rebuild.
|
|
return;
|
|
} |