pi-memory-evolution 0.2.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/CHANGELOG.md +246 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/docs/conversation-recall.md +94 -0
- package/docs/core-quality.md +224 -0
- package/docs/design.md +328 -0
- package/docs/progress-pipeline.md +188 -0
- package/docs/quality-validation.md +85 -0
- package/docs/review-0.2.md +82 -0
- package/docs/testing.md +102 -0
- package/docs/usage.md +386 -0
- package/package.json +61 -0
- package/src/adapter/operations.ts +95 -0
- package/src/adapter/pi-api.ts +24 -0
- package/src/adapter/progress-observation.ts +83 -0
- package/src/adapter/session-context.ts +36 -0
- package/src/child-process.ts +8 -0
- package/src/index.ts +256 -0
- package/src/injector/digest.ts +29 -0
- package/src/memory/evolution.ts +64 -0
- package/src/memory/extractor.ts +63 -0
- package/src/memory/feedback.ts +11 -0
- package/src/memory/learning.ts +24 -0
- package/src/memory/legacy.ts +92 -0
- package/src/memory/memory-store.ts +502 -0
- package/src/memory/privacy.ts +51 -0
- package/src/memory/progress-targets.ts +52 -0
- package/src/memory/quality.ts +81 -0
- package/src/memory/query.ts +87 -0
- package/src/memory/recovery.ts +23 -0
- package/src/memory/retriever.ts +181 -0
- package/src/memory/search.ts +105 -0
- package/src/memory/sqlite.ts +7 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DurableMemory } from './memory-store.ts';
|
|
2
|
+
import type { OperationResource } from '../adapter/operations.ts';
|
|
3
|
+
import { queryFeatures, queryText, type RecallInput } from './query.ts';
|
|
4
|
+
import { features } from './search.ts';
|
|
5
|
+
import { clipBytes, redact } from './privacy.ts';
|
|
6
|
+
|
|
7
|
+
const GENERIC_NAMES = new Set(['work','home','tmp','src','test','tests','docs','project','projects','repo','repository','node_modules']);
|
|
8
|
+
const ESCAPE = /[.*+?^${}()|[\]\\]/gu;
|
|
9
|
+
const genericTopic = new Set(['优化','完善','排查','任务','工作','实现','完成','进行','继续','接着','core','requirements','requirement','implement','optimize','finish']);
|
|
10
|
+
const pending = /待|尚未|未完成|未提交|未推送|未验证|进行中|仍在|\b(?:pending|not (?:yet |final |fully )?(?:committed|pushed|accepted|verified|complete)|in (?:progress|validation)|validation\/tuning)\b/iu;
|
|
11
|
+
function names(text: string, name: string): boolean {
|
|
12
|
+
return name.length >= 3 && !GENERIC_NAMES.has(name.toLowerCase())
|
|
13
|
+
&& new RegExp(`(?<![\\p{L}\\p{N}_-])${name.replace(ESCAPE,'\\$&')}(?![\\p{L}\\p{N}_-])`, 'iu').test(text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Update nomination is deliberately separate from answering a question. It retains
|
|
17
|
+
* affected/stale state candidates, not only top-2 near-duplicate answer snippets.
|
|
18
|
+
* Resource identity nominates; it never verifies success or authorizes cross-origin writes. */
|
|
19
|
+
export function nominateProgress(memories: readonly DurableMemory[], input: { scope: string; query: RecallInput; resources: readonly OperationResource[] }, limit = 8) {
|
|
20
|
+
const topic = new Set([...queryFeatures(queryText(input.query))].filter(w => !genericTopic.has(w)));
|
|
21
|
+
const resources = [...new Map(input.resources.map(r => [r.path, r])).values()].slice(0,16);
|
|
22
|
+
const rows = memories.map(memory => {
|
|
23
|
+
let reason = memory.scope !== input.scope ? 'other-origin' : memory.kind !== 'project_state' ? 'not-project-state'
|
|
24
|
+
: memory.layer === 'pinned' ? 'pinned' : ['forgotten','conflicted'].includes(memory.status) || memory.feedback?.accuracy?.verdict === 'incorrect' ? 'suppressed' : '';
|
|
25
|
+
const body = features(memory.content), aliases = features((memory.searchTerms ?? []).join(' '));
|
|
26
|
+
let resourceScore = 0, resourceReason = '', resourceConflict = false;
|
|
27
|
+
for (const resource of resources) {
|
|
28
|
+
const path = resource.path.toLowerCase();
|
|
29
|
+
const literals = [...body].filter(w => w.startsWith('literal:/')).map(w => w.slice(8));
|
|
30
|
+
const exact = literals.some(l => l === path || (resource.kind === 'directory' && l.startsWith(path + '/')));
|
|
31
|
+
const named = resource.kind === 'directory' && names(memory.content, resource.name);
|
|
32
|
+
// Equal basenames of explicitly different absolute resources are not identity.
|
|
33
|
+
const conflictingPath = literals.some(l => names(l, resource.name) && l !== path && !l.startsWith(path + '/'));
|
|
34
|
+
resourceConflict ||= conflictingPath;
|
|
35
|
+
const score = exact ? 60 : named && !conflictingPath ? 50 : 0;
|
|
36
|
+
if (score > resourceScore) { resourceScore = score; resourceReason = exact ? 'operation-resource' : 'explicit-project-name'; }
|
|
37
|
+
}
|
|
38
|
+
const matched = [...topic].filter(w => body.has(w) || aliases.has(w));
|
|
39
|
+
const coverage = topic.size ? matched.length / topic.size : 0;
|
|
40
|
+
const topical = matched.length >= 2 || (topic.size === 1 && matched.length === 1);
|
|
41
|
+
const relevant = resourceScore > 0 || (!resourceConflict && topical && coverage >= 0.3);
|
|
42
|
+
if (!reason) reason = relevant ? resourceReason || 'user-topic' : resourceConflict ? 'resource-conflict' : 'unrelated';
|
|
43
|
+
const eligible = relevant && ['operation-resource','explicit-project-name','user-topic'].includes(reason);
|
|
44
|
+
const score = resourceScore + Math.min(matched.length, 8) * 2 + (eligible && pending.test(memory.content) ? 20 : 0);
|
|
45
|
+
return { memory, reason, eligible, score };
|
|
46
|
+
});
|
|
47
|
+
rows.sort((a,b) => Number(b.eligible)-Number(a.eligible) || b.score-a.score
|
|
48
|
+
|| Date.parse(a.memory.updatedAt)-Date.parse(b.memory.updatedAt) || a.memory.id.localeCompare(b.memory.id));
|
|
49
|
+
const selected = rows.filter(r => r.eligible).slice(0,Math.max(0,Math.min(8,limit))).map(r => r.memory.id);
|
|
50
|
+
return { targets: selected, diagnostics: { mode: 'operation-and-topic', resources: resources.map(r => ({ path: clipBytes(redact(r.path),160), kind:r.kind })),
|
|
51
|
+
eligible: rows.filter(r=>r.eligible).length, selected, candidates: rows.slice(0,16).map(r=>({id:clipBytes(redact(r.memory.id),120),score:r.score,reason:r.eligible&&!selected.includes(r.memory.id)?'candidate-limit':r.reason})) } };
|
|
52
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { DurableMemory, MemoryKind, Source } from "./memory-store.ts";
|
|
2
|
+
|
|
3
|
+
export type EvidenceBasis = "summary" | "user_statement" | "tool_observation" | "manual_correction";
|
|
4
|
+
export interface Evidence {
|
|
5
|
+
basis: EvidenceBasis;
|
|
6
|
+
method: "local" | "model" | "manual";
|
|
7
|
+
sourceId: string;
|
|
8
|
+
at: string;
|
|
9
|
+
}
|
|
10
|
+
export type FeedbackVerdict = "useful" | "unhelpful" | "accurate" | "incorrect";
|
|
11
|
+
export interface FeedbackSignal { verdict: FeedbackVerdict; at: string; sourceId: string }
|
|
12
|
+
export interface MemoryFeedback { utility?: FeedbackSignal; accuracy?: FeedbackSignal }
|
|
13
|
+
export const FEEDBACK_VERDICTS = new Set<FeedbackVerdict>(["useful", "unhelpful", "accurate", "incorrect"]);
|
|
14
|
+
const date = (v: unknown) => typeof v === "string" && Number.isFinite(Date.parse(v));
|
|
15
|
+
const identifier = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 512 && !/[\u0000-\u001f]/u.test(v);
|
|
16
|
+
|
|
17
|
+
export function validEvidence(value: unknown): value is Evidence | undefined {
|
|
18
|
+
if (value === undefined) return true;
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
20
|
+
const e = value as Evidence;
|
|
21
|
+
return Object.keys(e).every(k => ["basis", "method", "sourceId", "at"].includes(k))
|
|
22
|
+
&& ["summary", "user_statement", "tool_observation", "manual_correction"].includes(e.basis)
|
|
23
|
+
&& ["local", "model", "manual"].includes(e.method) && identifier(e.sourceId) && date(e.at)
|
|
24
|
+
&& (e.basis === "manual_correction" ? e.method === "manual"
|
|
25
|
+
: e.basis === "summary" ? e.method !== "manual" : e.method === "model");
|
|
26
|
+
}
|
|
27
|
+
export function validFeedback(value: unknown): value is MemoryFeedback | undefined {
|
|
28
|
+
if (value === undefined) return true;
|
|
29
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
30
|
+
return Object.entries(value).every(([key, signal]) => {
|
|
31
|
+
if (!["utility", "accuracy"].includes(key) || !signal || typeof signal !== "object" || Array.isArray(signal)) return false;
|
|
32
|
+
const s = signal as FeedbackSignal;
|
|
33
|
+
return Object.keys(s).every(k => ["verdict", "at", "sourceId"].includes(k)) && date(s.at) && identifier(s.sourceId)
|
|
34
|
+
&& (key === "utility" ? ["useful", "unhelpful"].includes(s.verdict) : ["accurate", "incorrect"].includes(s.verdict));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function sourceEvidence(source: Source, method: "local" | "model"): Evidence {
|
|
38
|
+
return { basis: source.kind === "user" ? "user_statement" : source.kind === "progress" ? "tool_observation" : "summary",
|
|
39
|
+
method, sourceId: source.id, at: source.createdAt };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An ordinal evidence policy, NOT a probability, verification or model self-confidence.
|
|
43
|
+
* User statements are especially appropriate for preferences, not proof of execution. */
|
|
44
|
+
export function evidencePriority(kind: MemoryKind, basis?: EvidenceBasis): number {
|
|
45
|
+
if (basis === "manual_correction") return 4;
|
|
46
|
+
if (basis === "user_statement") return kind === "preference" || kind === "decision" ? 3 : 2;
|
|
47
|
+
if (basis === "tool_observation") return kind === "project_state" ? 3 : 1;
|
|
48
|
+
return basis === "summary" ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
export function mayReplace(old: DurableMemory, incoming: Evidence): boolean {
|
|
51
|
+
const priority = evidencePriority(old.kind, old.evidence?.basis);
|
|
52
|
+
const protectedPriority = old.status === "confirmed" || old.feedback?.accuracy?.verdict === "accurate" ? Math.max(4, priority) : priority;
|
|
53
|
+
// Explicit current user corrections can replace prior manual corrections; summaries cannot.
|
|
54
|
+
return incoming.basis === "user_statement" || (old.kind === "project_state" && incoming.basis === "tool_observation")
|
|
55
|
+
|| evidencePriority(old.kind, incoming.basis) >= protectedPriority;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DAY = 86400_000;
|
|
59
|
+
/** No new expiry for stable facts/preferences/decisions and no revival of old states.
|
|
60
|
+
* Floors preserve old useful knowledge; project states retain their seven-day safety cap. */
|
|
61
|
+
export const AGING: Record<MemoryKind, { halfLifeDays: number; floor: number; expiresDays?: number }> = {
|
|
62
|
+
project_state: { halfLifeDays: 3, floor: 0.5, expiresDays: 7 },
|
|
63
|
+
fact: { halfLifeDays: 90, floor: 0.75 },
|
|
64
|
+
decision: { halfLifeDays: 180, floor: 0.85 },
|
|
65
|
+
preference: { halfLifeDays: 365, floor: 0.95 },
|
|
66
|
+
};
|
|
67
|
+
export function memoryQuality(memory: DurableMemory, now = Date.now()) {
|
|
68
|
+
const policy = AGING[memory.kind];
|
|
69
|
+
const ageDays = Math.max(0, (now - Date.parse(memory.updatedAt)) / DAY);
|
|
70
|
+
const pinned = memory.layer === "pinned";
|
|
71
|
+
const freshness = pinned ? 1 : policy.floor + (1 - policy.floor) * 2 ** (-ageDays / policy.halfLifeDays);
|
|
72
|
+
const expired = !pinned && policy.expiresDays !== undefined && ageDays > policy.expiresDays;
|
|
73
|
+
const basis = memory.evidence?.basis ?? "unknown";
|
|
74
|
+
const priority = evidencePriority(memory.kind, memory.evidence?.basis);
|
|
75
|
+
const evidenceWeight = 1 + priority * 0.04;
|
|
76
|
+
// Last explicit verdict wins, not frequency. Usefulness never increases evidence priority.
|
|
77
|
+
const utility = memory.feedback?.utility?.verdict === "useful" ? 1.05 : memory.feedback?.utility?.verdict === "unhelpful" ? 0.9 : 1;
|
|
78
|
+
const accuracy = memory.feedback?.accuracy?.verdict === "accurate" ? 1.05 : memory.feedback?.accuracy?.verdict === "incorrect" ? 0.5 : 1;
|
|
79
|
+
return { basis, method: memory.evidence?.method ?? "unknown", ageDays, freshness, expired,
|
|
80
|
+
evidenceWeight, utility, accuracy, factor: freshness * evidenceWeight * utility * accuracy };
|
|
81
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { clipBytes, redact } from './privacy.ts';
|
|
2
|
+
import { features, transformProse } from './search.ts';
|
|
3
|
+
|
|
4
|
+
export interface RecallQuery {
|
|
5
|
+
query: string;
|
|
6
|
+
/** Supporting subject only: the current query remains mandatory when refining it. */
|
|
7
|
+
context?: string;
|
|
8
|
+
mode: 'direct' | 'followup' | 'empty' | 'reset';
|
|
9
|
+
}
|
|
10
|
+
export type RecallInput = string | RecallQuery;
|
|
11
|
+
const RESET = /换个话题|新话题|从头开始|不是那个|不是这个|\b(?:new topic|start over|forget that|not that|not this)\b/iu;
|
|
12
|
+
const FOLLOWUP = /继续|接着|接上|这个|那个|它|其|呢[??。.!]*$|\b(?:this|that|its?|their|continue|resume|what about|how about)\b/iu;
|
|
13
|
+
const FILLER = /为什么|什么|多少|这个|那个|这些|那些|这里|那里|换个话题|新话题|从头开始|不是那个|不是这个|不对|不行|有错|错误|好的|好吧|没错|没问题|有没有|会不会|是不是|能不能|需不需要|还能|你要|我要|记得|回忆|接着|接上|未完成|未完|没完成|刚才|以后|看下|做完|开干|开始|讨论|聊聊|帮我|一下|事情|怎么说|说过|[我你它的了呢吗吧啊呀么那这]|\b(?:go ahead|proceed|okay|ok|thanks|thank you|start over|new topic|forget that)\b/giu;
|
|
14
|
+
// These express the act of asking, not a subject. Query-only: stored evidence is intact.
|
|
15
|
+
const DISCOURSE = new Set(`有 无 还 好 也 能 会 要 是 很 都 请 先 再 与 就 对 嗯 哦 啊 说 相关 有关 还有 其他 这里 那里 自动 不会 能够 不能 系统 具体 详细 详情 信息 内容 记录 历史 当时 previously still related relevant regarding discussion discussed conversation conversations talked talking said remind reminder reminders details detail information history historical matter matters earlier already exactly know known tell us let's lets please could would does did can you our your about`.split(/\s+/u));
|
|
16
|
+
const WEAK = new Set(['配置', '设置', 'config', 'configuration', 'settings', 'setup']);
|
|
17
|
+
// Facets can refine an established subject; they cannot make a new named subject inherit
|
|
18
|
+
// unrelated context. This is a grammatical/attribute vocabulary, not a domain allowlist.
|
|
19
|
+
export const FACETS = new Set([...features('端口 认证 进度 版本 超时 价格 状态 路径 输出 输入 安装 连接 性能 错误 port auth progress version timeout price status path output input installation connection performance error')]);
|
|
20
|
+
const clean = (text: string) => clipBytes(redact(text), 2048).trim();
|
|
21
|
+
|
|
22
|
+
/** Asking to retrieve an existing memory is not an instruction to learn a new one. */
|
|
23
|
+
export function isRecallQuestion(text: string): boolean {
|
|
24
|
+
return /记得|回忆|\b(?:you\s+(?:still\s+)?(?:remember|recall)|remind\s+me|memories\s+(?:about|of|on|related))\b/iu.test(text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function queryFeatures(text: string): Set<string> {
|
|
28
|
+
const normalized = transformProse(clean(text), prose => {
|
|
29
|
+
// Remove recall-request framing, not technical subjects such as memory recall systems.
|
|
30
|
+
let value = prose
|
|
31
|
+
.replace(/(?:相关|有关)(?:的)?记忆/gu, ' ')
|
|
32
|
+
.replace(/关于([\s\S]+?)的(?:记忆|印象)/gu, '$1')
|
|
33
|
+
.replace(/\bmemories\s+(?:(?:related|relevant)\s+to|about|of|on|regarding)\b/giu, ' ')
|
|
34
|
+
.replace(/\b(?:do|can|could|would|will)?\s*you\s+(?:still\s+)?recall\b/giu, ' ')
|
|
35
|
+
.replace(/\bremind\s+me\b/giu, ' ');
|
|
36
|
+
if (/记得|回忆/u.test(value) && !/记忆(?:系统|库|召回|检索|注入|算法)/u.test(value)) value = value.replace(/记忆/gu, ' ');
|
|
37
|
+
return value.replace(FILLER, ' ');
|
|
38
|
+
});
|
|
39
|
+
// Keep unindexed single-character subjects on the query side as barriers rather
|
|
40
|
+
// than mistaking them for a topic-less continuation. They earn no fragment matches.
|
|
41
|
+
const result = features(normalized, true);
|
|
42
|
+
for (const word of result) {
|
|
43
|
+
if (DISCOURSE.has(word) || WEAK.has(word)) result.delete(word);
|
|
44
|
+
// A qualified path is one constraint, not a second vote for its shared basename.
|
|
45
|
+
if (word.startsWith('literal:') && word.includes('/')) result.delete(`literal:${word.split('/').at(-1)}`);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function advance(current: string, previous?: RecallQuery): RecallQuery {
|
|
51
|
+
const terms = queryFeatures(current);
|
|
52
|
+
if (RESET.test(current)) return { query: terms.size ? current : '', mode: 'reset' };
|
|
53
|
+
if (!terms.size) return previous?.query ? { ...previous, mode: 'followup' } : { query: '', mode: 'empty' };
|
|
54
|
+
if (previous?.query && FOLLOWUP.test(current)) {
|
|
55
|
+
const prior = queryFeatures(queryText(previous));
|
|
56
|
+
const subject = [...terms].filter(term => !FACETS.has(term));
|
|
57
|
+
if (subject.every(term => prior.has(term))) {
|
|
58
|
+
// Keep the established subject, not a growing trail of older attribute questions.
|
|
59
|
+
const context = previous.context ?? previous.query;
|
|
60
|
+
if (queryFeatures(context).size && context !== current) return { query: current, context, mode: 'followup' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { query: current, mode: 'direct' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Replay only bounded active-user context, oldest first, so chained refinements retain
|
|
67
|
+
* their subject and explicit new/reset topics form barriers. No corpus or assistant text. */
|
|
68
|
+
export function resolveRecallQuery(prompt: string, recentUsers: readonly string[] = []): RecallQuery {
|
|
69
|
+
const current = clean(prompt);
|
|
70
|
+
let previous: RecallQuery | undefined;
|
|
71
|
+
for (const text of recentUsers.slice(-6)) {
|
|
72
|
+
const value = clean(text);
|
|
73
|
+
// Pi may already include this turn in its active context.
|
|
74
|
+
if (value !== current) previous = advance(value, previous);
|
|
75
|
+
}
|
|
76
|
+
return advance(current, previous);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function queryText(input: RecallInput): string {
|
|
80
|
+
return typeof input === 'string' ? input : [input.query, input.context].filter(Boolean).join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** String facade retained for callers that only need the resolved topic text. Runtime
|
|
84
|
+
* retrieval uses the structured plan to keep current focus separate from context. */
|
|
85
|
+
export function recallQuery(prompt: string, recentUsers: readonly string[] = []): string {
|
|
86
|
+
return queryText(resolveRecallQuery(prompt, recentUsers));
|
|
87
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Bounded background work; retries are persisted by MemoryStore, not session timers. */
|
|
2
|
+
export const EVOLUTION_TIMEOUT_MS = 120_000;
|
|
3
|
+
export const EVOLUTION_MAX_TOKENS = 8192;
|
|
4
|
+
export const RECOVERY_POLL_MS = 15_000;
|
|
5
|
+
export const LEASE_GRACE_MS = 30_000;
|
|
6
|
+
export const MAX_FAILURES = 5;
|
|
7
|
+
const RETRY_DELAYS_MS = [60_000, 300_000, 900_000, 3_600_000];
|
|
8
|
+
|
|
9
|
+
export const FAILURE_CODES = ["timeout", "cancelled", "output_limit", "invalid_output", "stale", "write_rejected", "unavailable", "provider", "interrupted", "unknown"] as const;
|
|
10
|
+
export type FailureCode = typeof FAILURE_CODES[number];
|
|
11
|
+
|
|
12
|
+
/** Never persist raw exception messages/provider bodies (they may contain secrets). */
|
|
13
|
+
export class EvolutionError extends Error {
|
|
14
|
+
readonly code: FailureCode;
|
|
15
|
+
constructor(code: FailureCode) { super(`Memory evolution: ${code}`); this.code = code; }
|
|
16
|
+
}
|
|
17
|
+
export function failureCode(error: unknown, signal?: AbortSignal): FailureCode {
|
|
18
|
+
if (signal?.aborted) return signal.reason?.name === "TimeoutError" ? "timeout" : "cancelled";
|
|
19
|
+
return error instanceof EvolutionError ? error.code : "unknown";
|
|
20
|
+
}
|
|
21
|
+
export function retryAt(failures: number, now: number): number {
|
|
22
|
+
return failures >= MAX_FAILURES ? 0 : now + RETRY_DELAYS_MS[Math.max(0, failures - 1)]!;
|
|
23
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { DurableMemory } from "./memory-store.ts";
|
|
2
|
+
import { clipBytes, fingerprint, redact } from "./privacy.ts";
|
|
3
|
+
import { features, featureOffset } from "./search.ts";
|
|
4
|
+
import { memoryQuality } from "./quality.ts";
|
|
5
|
+
import { FACETS, queryFeatures, resolveRecallQuery, type RecallInput } from "./query.ts";
|
|
6
|
+
export { recallQuery, resolveRecallQuery } from "./query.ts";
|
|
7
|
+
|
|
8
|
+
function overlap(text: string, query: Set<string>): number {
|
|
9
|
+
const tokens = features(text);
|
|
10
|
+
return [...query].filter((word) => tokens.has(word)).length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Relevance scores are NOT confidence/truth scores. No authority bonus for cwd,
|
|
14
|
+
* legacy labels, source IDs or dates. Metadata can only help an explicit origin query. */
|
|
15
|
+
type RecallOptions = { includeExpiredProjectState?: boolean };
|
|
16
|
+
type RankedMemory = { memory: DurableMemory; score: number; rankScore: number; quality: ReturnType<typeof memoryQuality>; coverage: number; matches: string[]; reason?: string };
|
|
17
|
+
export interface RecallDiagnostics {
|
|
18
|
+
mode: string;
|
|
19
|
+
query: string[];
|
|
20
|
+
context: string[];
|
|
21
|
+
eligible: number;
|
|
22
|
+
excluded: number;
|
|
23
|
+
matched: number;
|
|
24
|
+
selected: string[];
|
|
25
|
+
candidates: { id: string; score: number; rankScore: number; quality: ReturnType<typeof memoryQuality>; coverage: number; matches: string[]; reason: string }[];
|
|
26
|
+
exclusions?: { id: string; reason: string }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function evaluate(memories: readonly DurableMemory[], prompt: RecallInput, now: number, options: RecallOptions) {
|
|
30
|
+
const plan = typeof prompt === 'string' ? resolveRecallQuery(prompt) : prompt;
|
|
31
|
+
const query = queryFeatures(plan.query);
|
|
32
|
+
const context = queryFeatures(plan.context ?? '');
|
|
33
|
+
for (const word of query) context.delete(word);
|
|
34
|
+
const qualities = new Map(memories.map(m => [m.id, memoryQuality(m, now)]));
|
|
35
|
+
const excludedReason = (m: DurableMemory) => ["forgotten", "conflicted"].includes(m.status) ? m.status
|
|
36
|
+
: m.feedback?.accuracy?.verdict === "incorrect" ? "disputed"
|
|
37
|
+
: !options.includeExpiredProjectState && qualities.get(m.id)!.expired ? "expired-project-state" : undefined;
|
|
38
|
+
const active = memories.filter(m => !excludedReason(m));
|
|
39
|
+
const diagnostics: RecallDiagnostics = { mode: plan.mode, query: [...query].slice(0, 32), context: [...context].slice(0, 32),
|
|
40
|
+
eligible: active.length, excluded: memories.length - active.length, matched: 0, selected: [], candidates: [],
|
|
41
|
+
exclusions: memories.filter(m => excludedReason(m)).slice(0, 10).map(m => ({ id: clipBytes(redact(m.id), 120), reason: excludedReason(m)! })) };
|
|
42
|
+
if (!query.size) return { ranked: [] as RankedMemory[], diagnostics };
|
|
43
|
+
// Repeated origins/aliases (and duplicate legacy text) need segmentation only once
|
|
44
|
+
// per query. No persistent cache of user queries or credential-bearing input.
|
|
45
|
+
const cache = new Map<string, Set<string>>();
|
|
46
|
+
const tokenize = (text: string) => {
|
|
47
|
+
let result = cache.get(text);
|
|
48
|
+
if (!result) { result = features(text); cache.set(text, result); }
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
const documents = active.map((memory) => ({ memory,
|
|
52
|
+
// A quoted question in an incident/replay note is a mention, not its answer.
|
|
53
|
+
body: tokenize(memory.content.replace(/“[^”\n]*[??]”|「[^」\n]*[??]」|"[^"\n]*[??]"/gu, ' ')),
|
|
54
|
+
mentions: tokenize(memory.content),
|
|
55
|
+
aliases: tokenize((memory.searchTerms ?? []).join(" ")),
|
|
56
|
+
origin: new Set(memory.scope === "legacy" ? [] : [...tokenize(memory.scope),
|
|
57
|
+
...tokenize(memory.scope.split(/[\\/]/u).at(-1) ?? "")].filter((word) => !word.startsWith("concept:"))) }));
|
|
58
|
+
const unknown = new Set<string>();
|
|
59
|
+
const weights = new Map([...query, ...context].map((word) => {
|
|
60
|
+
const df = documents.filter((d) => d.mentions.has(word) || d.aliases.has(word) || d.origin.has(word)).length;
|
|
61
|
+
if (!df) unknown.add(word);
|
|
62
|
+
// No evidence is not rare evidence: unseen question words must not receive
|
|
63
|
+
// the largest IDF. Exact resource constraints and thin-match gates still apply.
|
|
64
|
+
return [word, (word.startsWith("literal:") ? 2 : 1) * (df ? 1 + Math.log((documents.length + 1) / (df + 1)) : 1)];
|
|
65
|
+
}));
|
|
66
|
+
const total = [...query].reduce((sum, word) => sum + weights.get(word)!, 0);
|
|
67
|
+
const literals = [...query, ...context].filter(word => word.startsWith('literal:'));
|
|
68
|
+
const subjects = [...context].filter(word => !FACETS.has(word));
|
|
69
|
+
const subjectWeight = subjects.reduce((sum, word) => sum + weights.get(word)!, 0);
|
|
70
|
+
const namedSubjects = subjects.filter(word => !word.startsWith('concept:'));
|
|
71
|
+
// A short named-subject attribute query must not substitute another subject or
|
|
72
|
+
// another attribute when its best answer has been forgotten/quarantined.
|
|
73
|
+
// Generic status/progress words describe the request, not a required answer token.
|
|
74
|
+
const directNames = [...query].filter(word => !word.startsWith('concept:') && !word.startsWith('literal:') && !/^\d/u.test(word) && !FACETS.has(word));
|
|
75
|
+
const directFacets = [...query].filter(word => FACETS.has(word) && !['concept:status', 'concept:progress', 'error', '错误'].includes(word));
|
|
76
|
+
const focusedDirect = !context.size && query.size <= 4 && directNames.length === 1 && directFacets.length > 0;
|
|
77
|
+
const evaluated: RankedMemory[] = documents.map(({ memory, body, mentions, aliases, origin }) => {
|
|
78
|
+
let score = 0, covered = 0, focusMatches = 0, evidenceMatches = 0;
|
|
79
|
+
const matches: string[] = [];
|
|
80
|
+
for (const [word, weight] of weights) {
|
|
81
|
+
const factor = body.has(word) ? 1 : aliases.has(word) ? 0.8 : mentions.has(word) ? 0.25 : origin.has(word) ? 0.2 : 0;
|
|
82
|
+
if (factor) {
|
|
83
|
+
score += weight * factor * (query.has(word) ? 1 : 0.35);
|
|
84
|
+
if (query.has(word)) {
|
|
85
|
+
covered += weight; focusMatches++;
|
|
86
|
+
if (body.has(word) || aliases.has(word) || origin.has(word)) evidenceMatches++;
|
|
87
|
+
}
|
|
88
|
+
matches.push(word);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Mild length normalization rewards focused evidence without erasing useful
|
|
92
|
+
// long claims or letting brevity overcome a missing subject/constraint.
|
|
93
|
+
score *= 0.8 + 0.2 * Math.min(1, 12 / Math.max(1, body.size));
|
|
94
|
+
const coverage = covered / total;
|
|
95
|
+
const reason = literals.some(word => !matches.includes(word)) ? 'resource-mismatch'
|
|
96
|
+
: !focusMatches ? 'no-focus-match'
|
|
97
|
+
: !evidenceMatches ? 'question-only'
|
|
98
|
+
: focusedDirect && (directNames.some(word => !body.has(word) && !aliases.has(word) && !origin.has(word))
|
|
99
|
+
|| directFacets.some(word => !body.has(word) && !aliases.has(word))) ? 'subject-attribute-mismatch'
|
|
100
|
+
: (namedSubjects.length ? namedSubjects.some(word => !matches.includes(word))
|
|
101
|
+
: subjects.length && subjects.filter(word => matches.includes(word)).reduce((sum, word) => sum + weights.get(word)!, 0) / subjectWeight < 0.6) ? 'context-mismatch'
|
|
102
|
+
: coverage < 0.45 ? 'low-coverage'
|
|
103
|
+
: (query.size >= 3 && focusMatches < 2) || (focusMatches === 1 && [...query].some(word => unknown.has(word) && !FACETS.has(word))) ? 'thin-match'
|
|
104
|
+
: undefined;
|
|
105
|
+
const quality = qualities.get(memory.id)!;
|
|
106
|
+
return { memory, score, rankScore: score * quality.factor, quality, coverage, matches, reason };
|
|
107
|
+
});
|
|
108
|
+
const best = evaluated.reduce((best, r) => !r.reason ? Math.max(best, r.score) : best, 0);
|
|
109
|
+
// Quality only orders already-relevant evidence. It cannot rescue weak matches.
|
|
110
|
+
for (const item of evaluated) if (!item.reason && item.score < best * 0.75) item.reason = 'relative-cutoff';
|
|
111
|
+
evaluated.sort((a,b) => b.rankScore-a.rankScore || Number(b.memory.layer === "pinned")-Number(a.memory.layer === "pinned")
|
|
112
|
+
|| Date.parse(b.memory.updatedAt)-Date.parse(a.memory.updatedAt) || a.memory.id.localeCompare(b.memory.id));
|
|
113
|
+
const ranked = evaluated.filter(r => !r.reason);
|
|
114
|
+
diagnostics.matched = ranked.length;
|
|
115
|
+
diagnostics.candidates = evaluated.filter(r => r.score > 0).slice(0, 10).map(r => ({ id: clipBytes(redact(r.memory.id), 120),
|
|
116
|
+
score: Number(r.score.toFixed(3)), rankScore: Number(r.rankScore.toFixed(3)), quality: r.quality,
|
|
117
|
+
coverage: Number(r.coverage.toFixed(3)), matches: r.matches.slice(0, 16), reason: r.reason ?? 'eligible' }));
|
|
118
|
+
return { ranked, diagnostics };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function rankMemories(memories: readonly DurableMemory[], prompt: RecallInput, now = Date.now(), options: RecallOptions = {}) {
|
|
122
|
+
return evaluate(memories, prompt, now, options).ranked;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function selectRanked(ranked: RankedMemory[], limit: number): DurableMemory[] {
|
|
126
|
+
if (limit <= 0) return [];
|
|
127
|
+
const selected: DurableMemory[] = [];
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
const covered = new Map<string, Set<string>>();
|
|
130
|
+
for (const { memory, matches } of ranked) {
|
|
131
|
+
const key = fingerprint(JSON.stringify([memory.scope, memory.content]));
|
|
132
|
+
if (seen.has(key)) continue;
|
|
133
|
+
// For specific multi-feature questions, don't spend another slot repeating the
|
|
134
|
+
// same matched facets from the same origin. Different origins remain distinct.
|
|
135
|
+
// A progress note mentioning a question must not hide a preference/fact that
|
|
136
|
+
// answers it. Facet diversity is tracked separately for each evidence kind.
|
|
137
|
+
const facetKey = JSON.stringify([memory.scope, memory.kind]);
|
|
138
|
+
const previous = covered.get(facetKey) ?? new Set<string>();
|
|
139
|
+
if (matches.length >= 2 && ranked[0].matches.length >= 3 && matches.every((term) => previous.has(term))) continue;
|
|
140
|
+
seen.add(key); matches.forEach((term) => previous.add(term)); covered.set(facetKey, previous);
|
|
141
|
+
selected.push(memory);
|
|
142
|
+
if (selected.length >= limit) break;
|
|
143
|
+
}
|
|
144
|
+
return selected;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One evaluation for both injection and transient diagnostics; never persists queries. */
|
|
148
|
+
export function retrieveMemories(memories: readonly DurableMemory[], prompt: RecallInput, limit = 3, now = Date.now(), options: RecallOptions = {}) {
|
|
149
|
+
const { ranked, diagnostics } = evaluate(memories, prompt, now, options);
|
|
150
|
+
const selected = selectRanked(ranked, limit);
|
|
151
|
+
diagnostics.selected = selected.map(m => clipBytes(redact(m.id), 120));
|
|
152
|
+
for (const item of diagnostics.candidates) if (item.reason === 'eligible') {
|
|
153
|
+
item.reason = diagnostics.selected.includes(item.id) ? 'selected' : 'selection-limit-or-redundancy';
|
|
154
|
+
}
|
|
155
|
+
return { selected, diagnostics };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function selectRelevantMemories(memories: readonly DurableMemory[], prompt: RecallInput, limit = 3, now = Date.now(), options: RecallOptions = {}): DurableMemory[] {
|
|
159
|
+
return retrieveMemories(memories, prompt, limit, now, options).selected;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Use the matching sentence instead of blindly cutting off the beginning. */
|
|
163
|
+
export function excerpt(content: string, prompt: RecallInput, budget = 400): string {
|
|
164
|
+
const clean = redact(content).trim();
|
|
165
|
+
if (Buffer.byteLength(clean) <= budget) return clean;
|
|
166
|
+
if (budget <= 3) return clipBytes(clean, Math.max(0, budget));
|
|
167
|
+
const query = queryFeatures(typeof prompt === 'string' ? prompt : prompt.query);
|
|
168
|
+
const context = queryFeatures(typeof prompt === 'string' ? '' : prompt.context ?? '');
|
|
169
|
+
const sentences = clean.split(/(?<=[。!?!?])\s*|(?<=\.)\s+|\n+/u).filter(Boolean);
|
|
170
|
+
sentences.sort((a,b) => overlap(b, query)-overlap(a, query) || overlap(b, context)-overlap(a, context));
|
|
171
|
+
const best = sentences[0] ?? clean;
|
|
172
|
+
if (Buffer.byteLength(best) <= budget - 3) return best + "…";
|
|
173
|
+
const positions = [...query].map((word) => featureOffset(best, word)).filter((index) => index >= 0);
|
|
174
|
+
const offset = positions.length ? Math.min(...positions) : 0;
|
|
175
|
+
// Keep a little preceding context, cutting only at code-point boundaries.
|
|
176
|
+
const reversed = [...best.slice(0, offset)].reverse().join("");
|
|
177
|
+
const prefix = [...clipBytes(reversed, Math.floor((budget - 6) / 3))].reverse().join("");
|
|
178
|
+
const start = offset - prefix.length;
|
|
179
|
+
const lead = start > 0 && budget >= 6 ? "…" : "";
|
|
180
|
+
return lead + clipBytes(best.slice(start), budget - Buffer.byteLength(lead) - 3) + "…";
|
|
181
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { redact } from "./privacy.ts";
|
|
2
|
+
|
|
3
|
+
// Small, explicit bilingual bootstrap for existing records, not a general translator.
|
|
4
|
+
// New model-derived searchTerms extend recall beyond this vocabulary without model calls
|
|
5
|
+
// on the recall path. Synonyms form ONE feature, not several independent votes.
|
|
6
|
+
const CONCEPTS: [string, RegExp][] = [
|
|
7
|
+
["memory", /记忆|\bmemor(?:y|ies)\b/giu],
|
|
8
|
+
["session", /会话|\bsessions?\b/giu],
|
|
9
|
+
["project", /项目|\bprojects?\b/giu],
|
|
10
|
+
["directory", /目录|文件夹|\b(?:director(?:y|ies)|folders?|cwd)\b/giu],
|
|
11
|
+
["cross-context", /跨会话|跨项目|跨目录|限定|限制|不限于?|\b(?:across|regardless|restrict(?:ed|ion|ions)?|limit(?:ed|ation|ations)?|cross-session|cross-project|cross-directory)\b/giu],
|
|
12
|
+
["recall", /召回|检索|\b(?:recall|retrieval|retrieve|search)\b/giu],
|
|
13
|
+
["injection", /注入|\binject(?:ion|ed|ing)?\b/giu],
|
|
14
|
+
["preference", /偏好|\bprefer(?:ence|ences|red)?\b/giu],
|
|
15
|
+
["model", /模型|\bmodels?\b/giu],
|
|
16
|
+
["auth", /认证|鉴权|\b(?:auth|authentication|authorization)\b/giu],
|
|
17
|
+
["reuse", /复用|重用|\breus(?:e|ed|ing)\b/giu],
|
|
18
|
+
["database", /数据库|\bdatabases?\b/giu],
|
|
19
|
+
["port", /端口|\bports?\b/giu],
|
|
20
|
+
["network", /网络|\bnetwork(?:s|ing)?\b/giu],
|
|
21
|
+
["bluetooth", /蓝牙|\bbluetooth\b/giu],
|
|
22
|
+
["audio", /音响|音频|\b(?:audio|speakers?)\b/giu],
|
|
23
|
+
["review", /审查|审阅|\breview(?:ed|ing)?\b/giu],
|
|
24
|
+
["commit", /提交|\bcommit(?:s|ted|ting)?\b/giu],
|
|
25
|
+
["push", /推送|\bpush(?:ed|ing)?\b/giu],
|
|
26
|
+
["progress", /进度|进展|\bprogress\b/giu],
|
|
27
|
+
["pending", /尚未|仍在|待完成|未完成|\b(?:pending|not yet|in progress)\b/giu],
|
|
28
|
+
["done", /已完成|完成了|\b(?:completed|finished|done)\b/giu],
|
|
29
|
+
["test", /测试|\btests?(?:ing|ed)?\b/giu],
|
|
30
|
+
["verification", /验证|校验|\b(?:verify|verified|verification|validation|validate)\b/giu],
|
|
31
|
+
// General conversational attributes, including short CJK words ICU may split.
|
|
32
|
+
["timeout", /超时|\btime[ -]?outs?\b/giu],
|
|
33
|
+
["version", /版本|\bversions?\b/giu],
|
|
34
|
+
["price", /价格|价钱|\b(?:prices?|pricing|costs?)\b/giu],
|
|
35
|
+
["status", /状态|\bstatus\b/giu],
|
|
36
|
+
["path", /路径|\bpaths?\b/giu],
|
|
37
|
+
["output", /输出|\boutputs?\b/giu],
|
|
38
|
+
["input", /输入|\binputs?\b/giu],
|
|
39
|
+
["installation", /安装|\binstall(?:ation|ed|ing)?\b/giu],
|
|
40
|
+
["connection", /连接|\bconnect(?:ion|ions|ed|ing)?\b/giu],
|
|
41
|
+
["performance", /性能|\bperformance\b/giu],
|
|
42
|
+
];
|
|
43
|
+
const STOP = new Set(`的 了 是 在 有 没有 现在 目前 当前 这个 那个 这些 那些 什么 哪些 哪个 为什么 怎样 如何 怎么 是否 可以 需要 问题 看看 一下 我们 你们 然后 但是 以及 关于 帮我 谢谢 应该 还是 继续 之前 上次 修复 修改 检查 处理 记住
|
|
44
|
+
the and for with continue resume previous this that these those it its they them their we our you your i me my a an of to in on at is are was were be been do does did has have had no not without now current currently what which who how why can could should would please help check look see any there here also just again about other anything something else one problem problems issue issues wrong broken use used using work home src tmp user users fix change changes need remember discuss show describe explain tell where`.split(/\s+/u));
|
|
45
|
+
const WORDS = new Intl.Segmenter("zh", { granularity: "word" });
|
|
46
|
+
// Paths and filenames are an exact-literal channel. Their components must not turn a
|
|
47
|
+
// repository named pi-memory-evolution into evidence about the meaning of "memory".
|
|
48
|
+
const LITERALS = /(?<![\p{L}\p{N}_])(?:~?\/|\.\.?\/)[^\s`"'<>,。!?,;!?]+|(?<![\w./-])(?:[\w.-]+\/)*[\w-]+\.(?:[cm]?[jt]sx?|json|md|sqlite|toml|ya?ml|sh|py|go|rs)\b|(?<![a-z0-9-])[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}/giu;
|
|
49
|
+
|
|
50
|
+
/** Query-language cleanup must never rewrite literal paths/filenames. */
|
|
51
|
+
export function transformProse(text: string, transform: (prose: string) => string): string {
|
|
52
|
+
let result = "", start = 0;
|
|
53
|
+
for (const match of text.matchAll(LITERALS)) {
|
|
54
|
+
result += transform(text.slice(start, match.index)) + match[0];
|
|
55
|
+
start = match.index + match[0].length;
|
|
56
|
+
}
|
|
57
|
+
return result + transform(text.slice(start));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function features(text: string, includeSingle = false): Set<string> {
|
|
61
|
+
const result = new Set<string>();
|
|
62
|
+
let prose = redact(text).replace(/\[REDACTED[^\]\n]*\]/gu, " ");
|
|
63
|
+
prose = prose.replace(LITERALS, (literal) => {
|
|
64
|
+
const clean = literal.replace(/[.:]+$/u, "").toLowerCase();
|
|
65
|
+
result.add(`literal:${clean}`);
|
|
66
|
+
result.add(`literal:${clean.split("/").at(-1)}`);
|
|
67
|
+
return " ";
|
|
68
|
+
});
|
|
69
|
+
// Detect all concepts on the original prose so overlapping phrases such as
|
|
70
|
+
// 跨会话 contribute session + cross-context, but not duplicated English synonyms.
|
|
71
|
+
const masked = prose.split("");
|
|
72
|
+
for (const [name, pattern] of CONCEPTS) {
|
|
73
|
+
pattern.lastIndex = 0;
|
|
74
|
+
for (const match of prose.matchAll(pattern)) {
|
|
75
|
+
result.add(`concept:${name}`);
|
|
76
|
+
for (let i = match.index; i < match.index + match[0].length; i++) masked[i] = " ";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
prose = masked.join("");
|
|
80
|
+
prose = prose.replace(/([a-z])([A-Z])/gu, "$1 $2").toLowerCase();
|
|
81
|
+
for (const word of WORDS.segment(prose)) {
|
|
82
|
+
if (!word.isWordLike || (!includeSingle && word.segment.length < 2) || STOP.has(word.segment)) continue;
|
|
83
|
+
result.add(word.segment);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function featureOffset(text: string, feature: string): number {
|
|
89
|
+
// Preserve offsets while applying the same literal/prose boundary as indexing.
|
|
90
|
+
const prose = feature.startsWith("literal:") ? text : text.replace(LITERALS, (literal) => " ".repeat(literal.length));
|
|
91
|
+
if (feature.startsWith("concept:")) {
|
|
92
|
+
const pattern = CONCEPTS.find(([name]) => `concept:${name}` === feature)?.[1];
|
|
93
|
+
if (!pattern) return -1;
|
|
94
|
+
pattern.lastIndex = 0;
|
|
95
|
+
return pattern.exec(prose)?.index ?? -1;
|
|
96
|
+
}
|
|
97
|
+
return prose.toLowerCase().indexOf(feature.replace(/^literal:/u, ""));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validSearchTerms(value: unknown): value is string[] | undefined {
|
|
101
|
+
return value === undefined || (Array.isArray(value) && value.length <= 8 && value.every((term) =>
|
|
102
|
+
typeof term === "string" && term.trim() === term && term.length >= 2 && term.length <= 64
|
|
103
|
+
&& !term.includes("[REDACTED") && redact(term) === term && !/[\r\n]/u.test(term))
|
|
104
|
+
&& Buffer.byteLength(JSON.stringify(value)) <= 1024);
|
|
105
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
/** Pi's standalone binary uses Bun; npm Pi and tests use Node. Both bundle SQLite. */
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
export type Database = import("node:sqlite").DatabaseSync;
|
|
6
|
+
export const Database: typeof import("node:sqlite").DatabaseSync =
|
|
7
|
+
"Bun" in globalThis ? require("bun:sqlite").Database : require("node:sqlite").DatabaseSync;
|