recallnest 1.4.1
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/.claude-plugin/CLAUDE.md +29 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.env.example +3 -0
- package/.github/workflows/ci.yml +28 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +51 -0
- package/CLAUDE.md +42 -0
- package/DEMO.md +94 -0
- package/EVAL.md +89 -0
- package/FAILURES.md +75 -0
- package/LICENSE +21 -0
- package/OPERATIONS.md +59 -0
- package/README.md +427 -0
- package/README_CN.md +425 -0
- package/ROADMAP.md +198 -0
- package/assets/recallnest-workbench.png +0 -0
- package/assets/ui/app.js +807 -0
- package/assets/ui/index.html +154 -0
- package/assets/ui/styles.css +605 -0
- package/assets/wechat-qr.jpg +0 -0
- package/config.json.example +38 -0
- package/daily-recall.sh +87 -0
- package/daily-reflections/2026-03-05.md +15 -0
- package/daily-reflections/2026-03-06.md +15 -0
- package/daily-reflections/2026-03-07.md +7 -0
- package/daily-reflections/2026-03-08.md +34 -0
- package/daily-reflections/2026-03-09.md +35 -0
- package/daily-reflections/2026-03-10.md +36 -0
- package/daily-reflections/2026-03-11.md +35 -0
- package/daily-reflections/2026-03-12.md +36 -0
- package/daily-reflections/2026-03-13.md +35 -0
- package/daily-reflections/2026-03-14.md +35 -0
- package/daily-reflections/2026-03-15.md +35 -0
- package/daily-reflections/2026-03-16.md +41 -0
- package/daily-reflections/2026-03-17.md +10 -0
- package/daily-reflections/2026-03-18.md +10 -0
- package/daily-reflections/2026-03-19.md +10 -0
- package/daily-reflections/2026-03-20.md +10 -0
- package/daily-reflections/2026-03-21.md +10 -0
- package/daily-reflections/2026-03-22.md +10 -0
- package/daily-reflections/2026-03-23.md +10 -0
- package/daily-reflections/2026-03-24.md +10 -0
- package/daily-reflections/2026-03-25.md +32 -0
- package/daily-reflections/2026-03-26.md +37 -0
- package/daily-reflections/2026-03-27.md +35 -0
- package/daily-reflections/2026-03-28.md +35 -0
- package/daily-reflections/2026-03-29.md +35 -0
- package/daily-reflections/2026-03-30.md +36 -0
- package/daily-reflections/2026-03-31.md +35 -0
- package/daily-reflections/2026-04-01.md +35 -0
- package/docs/api-reference.md +1291 -0
- package/docs/architecture.md +168 -0
- package/docs/memory-boundary-contract.md +226 -0
- package/docs/memory-categories.md +119 -0
- package/docs/pipeline-audit-ultramemory.md +177 -0
- package/docs/plans/2026-03-08-query-expansion.md +270 -0
- package/docs/plans/2026-03-16-continuity-layer.md +196 -0
- package/docs/plans/mcp-tiering-plan.md +126 -0
- package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
- package/docs/self-evolution.md +198 -0
- package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
- package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
- package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
- package/eval/cases.json +85 -0
- package/eval/continuity/case-seeds.json +90 -0
- package/eval/continuity/cases.json +302 -0
- package/eval/continuity/memory-seeds.json +26 -0
- package/eval/continuity/pattern-seeds.json +100 -0
- package/eval/continuity/results-2026-03-16.md +117 -0
- package/eval/continuity/results-2026-03-19.md +453 -0
- package/eval/continuity/runbook.md +110 -0
- package/eval/continuity/seed-cases.md +54 -0
- package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
- package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
- package/eval/reports/2026-03-06-baseline.md +82 -0
- package/eval/reports/query-expansion.md +138 -0
- package/integrations/README.md +57 -0
- package/integrations/claude-code/README.md +133 -0
- package/integrations/claude-code/claude-md-snippet.md +31 -0
- package/integrations/claude-code/setup.sh +66 -0
- package/integrations/claude-code/smoke-continuity.sh +452 -0
- package/integrations/codex/README.md +49 -0
- package/integrations/codex/agents-md-snippet.md +31 -0
- package/integrations/codex/setup.sh +58 -0
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
- package/integrations/examples/langchain/memory-chain.py +111 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
- package/integrations/gemini-cli/README.md +57 -0
- package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
- package/integrations/gemini-cli/setup.sh +64 -0
- package/integrations/shared/install-managed-block.sh +51 -0
- package/lm +19 -0
- package/package.json +50 -0
- package/plan-v1.2-distributable.md +105 -0
- package/scripts/archive-distilled-junk.ts +116 -0
- package/scripts/backfill-l0l1l2.ts +98 -0
- package/scripts/blind-scan.ts +588 -0
- package/scripts/cleanup-step1-exact-dups.ts +117 -0
- package/scripts/cleanup-step2-template-dups.ts +113 -0
- package/scripts/cleanup-step3-stale-content.ts +149 -0
- package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
- package/scripts/delete-distilled-junk.ts +88 -0
- package/scripts/distill-facts.ts +305 -0
- package/scripts/health-check.ts +343 -0
- package/scripts/incremental-ingest.sh +71 -0
- package/scripts/mlp-health-check.ts +382 -0
- package/scripts/pull-claude-desktop.ts +515 -0
- package/scripts/re-ingest-cc.sh +66 -0
- package/scripts/rebalance-memory-health.ts +181 -0
- package/scripts/run-desktop-ingest.sh +11 -0
- package/scripts/start-server.sh +12 -0
- package/scripts/viz-dashboard.html +423 -0
- package/scripts/viz-export.ts +703 -0
- package/src/__tests__/access-tracker-novelty.test.ts +228 -0
- package/src/__tests__/auto-consolidation.test.ts +168 -0
- package/src/__tests__/auto-recall.test.ts +131 -0
- package/src/__tests__/batch-dedup.test.ts +65 -0
- package/src/__tests__/capture-engine.test.ts +1142 -0
- package/src/__tests__/capture-heuristic.test.ts +171 -0
- package/src/__tests__/cascade-forget.test.ts +210 -0
- package/src/__tests__/category-min-scores.test.ts +33 -0
- package/src/__tests__/confidence-tracker.test.ts +196 -0
- package/src/__tests__/conflict-advisor.test.ts +206 -0
- package/src/__tests__/conflict-escalation.test.ts +149 -0
- package/src/__tests__/conflict-lifecycle.test.ts +83 -0
- package/src/__tests__/conflict-output.test.ts +85 -0
- package/src/__tests__/conflict-store.test.ts +374 -0
- package/src/__tests__/consolidation-engine.test.ts +181 -0
- package/src/__tests__/context-composer-pins.test.ts +64 -0
- package/src/__tests__/context-composer-task-selection.test.ts +24 -0
- package/src/__tests__/context-renderer.test.ts +91 -0
- package/src/__tests__/continuity-eval.test.ts +275 -0
- package/src/__tests__/continuity-schema.test.ts +97 -0
- package/src/__tests__/core-recall-eval.test.ts +235 -0
- package/src/__tests__/core-summary.test.ts +109 -0
- package/src/__tests__/dedup-actions.test.ts +58 -0
- package/src/__tests__/deterministic-id.test.ts +39 -0
- package/src/__tests__/doctor.test.ts +303 -0
- package/src/__tests__/drill-down.test.ts +108 -0
- package/src/__tests__/embedder.test.ts +122 -0
- package/src/__tests__/entity-resolver.test.ts +58 -0
- package/src/__tests__/event-segmenter.test.ts +153 -0
- package/src/__tests__/hotness-blend.test.ts +76 -0
- package/src/__tests__/ingest.test.ts +360 -0
- package/src/__tests__/kg-extractor.test.ts +184 -0
- package/src/__tests__/kg-store.test.ts +198 -0
- package/src/__tests__/llm-consolidation.test.ts +226 -0
- package/src/__tests__/memory-boundaries.test.ts +237 -0
- package/src/__tests__/memory-health-rebalance.test.ts +144 -0
- package/src/__tests__/memory-output.test.ts +215 -0
- package/src/__tests__/multi-vector.test.ts +242 -0
- package/src/__tests__/ppr-traversal.test.ts +256 -0
- package/src/__tests__/preference-guard-smoke.test.ts +211 -0
- package/src/__tests__/preference-matcher.test.ts +230 -0
- package/src/__tests__/preference-slots.test.ts +106 -0
- package/src/__tests__/prospective-memory.test.ts +283 -0
- package/src/__tests__/query-entity-detector.test.ts +171 -0
- package/src/__tests__/query-expander.test.ts +34 -0
- package/src/__tests__/recall-mode.test.ts +64 -0
- package/src/__tests__/result-synthesizer.test.ts +198 -0
- package/src/__tests__/resume-context.test.ts +4743 -0
- package/src/__tests__/retrieval-trace.test.ts +213 -0
- package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
- package/src/__tests__/retroactive-boost.test.ts +223 -0
- package/src/__tests__/scope-inventory.test.ts +200 -0
- package/src/__tests__/scope-policy.test.ts +69 -0
- package/src/__tests__/session-checkpoint.test.ts +259 -0
- package/src/__tests__/session-gc.test.ts +215 -0
- package/src/__tests__/store-scope-audit.test.ts +81 -0
- package/src/__tests__/temporal-parser.test.ts +241 -0
- package/src/__tests__/term-registry.test.ts +121 -0
- package/src/__tests__/tool-output-compressor.test.ts +133 -0
- package/src/__tests__/version-manager.test.ts +213 -0
- package/src/__tests__/workflow-observation.test.ts +400 -0
- package/src/access-tracker.ts +316 -0
- package/src/adaptive-retrieval.ts +64 -0
- package/src/api-server.ts +667 -0
- package/src/asset-sync.ts +79 -0
- package/src/auto-consolidation.ts +133 -0
- package/src/auto-gc.ts +124 -0
- package/src/auto-recall.ts +95 -0
- package/src/capture-engine.ts +931 -0
- package/src/capture-heuristic.ts +177 -0
- package/src/cascade-forget.ts +135 -0
- package/src/chunker.ts +253 -0
- package/src/cli.ts +1917 -0
- package/src/confidence-tracker.ts +166 -0
- package/src/conflict-advisor.ts +433 -0
- package/src/conflict-engine.ts +181 -0
- package/src/conflict-escalation.ts +138 -0
- package/src/conflict-lifecycle.ts +67 -0
- package/src/conflict-output.ts +218 -0
- package/src/conflict-schema.ts +130 -0
- package/src/conflict-store.ts +127 -0
- package/src/consolidation-engine.ts +256 -0
- package/src/context-composer-pins.ts +183 -0
- package/src/context-composer-queries.ts +121 -0
- package/src/context-composer-scope.ts +56 -0
- package/src/context-composer-stable-selection.ts +200 -0
- package/src/context-composer-stable.ts +217 -0
- package/src/context-composer-task-fallbacks.ts +36 -0
- package/src/context-composer-task-ranking.ts +197 -0
- package/src/context-composer-task-results.ts +137 -0
- package/src/context-composer-task-selection.ts +342 -0
- package/src/context-composer-text.ts +57 -0
- package/src/context-composer.ts +300 -0
- package/src/context-renderer.ts +179 -0
- package/src/decay-engine.ts +189 -0
- package/src/doctor.ts +543 -0
- package/src/embedder.ts +557 -0
- package/src/entity-resolver.ts +131 -0
- package/src/eval.ts +632 -0
- package/src/event-segmenter.ts +216 -0
- package/src/ingest.ts +1822 -0
- package/src/kg-extractor.ts +217 -0
- package/src/kg-store.ts +353 -0
- package/src/legacy-scope-review.ts +53 -0
- package/src/llm-client.ts +629 -0
- package/src/llm-consolidation.ts +168 -0
- package/src/mcp-server.ts +1337 -0
- package/src/memory-assets.ts +475 -0
- package/src/memory-boundaries.ts +354 -0
- package/src/memory-health-rebalance.ts +220 -0
- package/src/memory-output.ts +474 -0
- package/src/memory-schema.ts +184 -0
- package/src/multi-vector.ts +234 -0
- package/src/noise-filter.ts +155 -0
- package/src/ppr-traversal.ts +279 -0
- package/src/preference-matcher.ts +174 -0
- package/src/preference-slots.ts +259 -0
- package/src/prospective-memory.ts +198 -0
- package/src/query-entity-detector.ts +207 -0
- package/src/query-expander.ts +98 -0
- package/src/result-synthesizer.ts +114 -0
- package/src/retrieval-profiles.ts +101 -0
- package/src/retrieval-trace.ts +94 -0
- package/src/retriever.ts +1338 -0
- package/src/retroactive-boost.ts +154 -0
- package/src/rif.ts +73 -0
- package/src/runtime-config.ts +172 -0
- package/src/schema-utils.ts +56 -0
- package/src/scope-inventory.ts +310 -0
- package/src/scope-policy.ts +142 -0
- package/src/session-engine.ts +168 -0
- package/src/session-output.ts +96 -0
- package/src/session-schema.ts +60 -0
- package/src/session-store.ts +293 -0
- package/src/stderr-log.ts +16 -0
- package/src/store.ts +903 -0
- package/src/temporal-parser.ts +318 -0
- package/src/term-registry.ts +791 -0
- package/src/tool-output-compressor.ts +115 -0
- package/src/tracker.ts +57 -0
- package/src/ui-server.ts +430 -0
- package/src/version-manager.ts +172 -0
- package/src/workflow-observation-engine.ts +316 -0
- package/src/workflow-observation-managed.ts +83 -0
- package/src/workflow-observation-output.ts +86 -0
- package/src/workflow-observation-schema.ts +87 -0
- package/src/workflow-observation-scope-review.ts +409 -0
- package/src/workflow-observation-store.ts +69 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LanceDB Storage Layer with Multi-Scope Support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type * as LanceDB from "@lancedb/lancedb";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync, accessSync, constants, mkdirSync, realpathSync, lstatSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { logWarn } from "./stderr-log.js";
|
|
10
|
+
import type { DurableMemoryCategory } from "./memory-schema.js";
|
|
11
|
+
import { matchesScopeFilter } from "./scope-policy.js";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Memory categories (v1.1 six-category system, inspired by OpenViking).
|
|
19
|
+
*
|
|
20
|
+
* User Memory (4):
|
|
21
|
+
* profile — 用户身份/背景(静态,合并优先)
|
|
22
|
+
* preferences — 偏好/倾向(合并优先)
|
|
23
|
+
* entities — 持续存在的名词:项目/工具/人物(合并优先)
|
|
24
|
+
* events — 发生过的事(追加,不合并)
|
|
25
|
+
*
|
|
26
|
+
* Agent Memory (2):
|
|
27
|
+
* cases — 问题→解决方案对(追加,不合并)
|
|
28
|
+
* patterns — 可复用的流程/模式(合并优先)
|
|
29
|
+
*
|
|
30
|
+
* Legacy categories kept for backward compatibility.
|
|
31
|
+
*/
|
|
32
|
+
export type MemoryCategory =
|
|
33
|
+
| DurableMemoryCategory
|
|
34
|
+
| "preference" | "fact" | "decision" | "entity" | "other";
|
|
35
|
+
|
|
36
|
+
export interface MemoryEntry {
|
|
37
|
+
id: string;
|
|
38
|
+
text: string;
|
|
39
|
+
vector: number[];
|
|
40
|
+
category: MemoryCategory;
|
|
41
|
+
scope: string;
|
|
42
|
+
importance: number;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
metadata?: string; // JSON string for extensible metadata — includes l0_abstract/l1_overview/l2_content/tier
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MemorySearchResult {
|
|
48
|
+
entry: MemoryEntry;
|
|
49
|
+
score: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StoreConfig {
|
|
53
|
+
dbPath: string;
|
|
54
|
+
vectorDim: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type LegacyScopeIssueKind = "missing" | "empty" | "global";
|
|
58
|
+
|
|
59
|
+
export interface LegacyScopeAuditSample {
|
|
60
|
+
id: string;
|
|
61
|
+
kind: LegacyScopeIssueKind;
|
|
62
|
+
scope: string | null;
|
|
63
|
+
category: MemoryEntry["category"];
|
|
64
|
+
timestamp: number;
|
|
65
|
+
text: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LegacyScopeAudit {
|
|
69
|
+
totalCount: number;
|
|
70
|
+
counts: Record<LegacyScopeIssueKind, number>;
|
|
71
|
+
samples: LegacyScopeAuditSample[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Deterministic ID
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
const RECALLNEST_NS = "recallnest:v1";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate a deterministic UUID-formatted ID from scope + text.
|
|
82
|
+
* Same inputs always produce the same ID — prevents duplicate entries
|
|
83
|
+
* with different random UUIDs.
|
|
84
|
+
*
|
|
85
|
+
* Format: 8-4-4-4-12 hex (same shape as crypto.randomUUID).
|
|
86
|
+
*/
|
|
87
|
+
export function deterministicId(scope: string, text: string): string {
|
|
88
|
+
const hash = createHash("sha256")
|
|
89
|
+
.update(`${RECALLNEST_NS}\0${scope}\0${text}`)
|
|
90
|
+
.digest("hex");
|
|
91
|
+
// Format as UUID: 8-4-4-4-12
|
|
92
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// LanceDB Dynamic Import
|
|
97
|
+
// ============================================================================
|
|
98
|
+
|
|
99
|
+
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
|
|
100
|
+
|
|
101
|
+
export const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
|
|
102
|
+
if (!lancedbImportPromise) {
|
|
103
|
+
lancedbImportPromise = import("@lancedb/lancedb");
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return await lancedbImportPromise;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`memory-lancedb-pro: failed to load LanceDB. ${String(err)}`, { cause: err });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Utility Functions
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
117
|
+
if (!Number.isFinite(value)) return min;
|
|
118
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeSqlLiteral(value: string): string {
|
|
122
|
+
return value.replace(/'/g, "''");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function classifyLegacyScope(scope: unknown): LegacyScopeIssueKind | undefined {
|
|
126
|
+
if (scope == null) {
|
|
127
|
+
return "missing";
|
|
128
|
+
}
|
|
129
|
+
if (typeof scope !== "string") {
|
|
130
|
+
return "missing";
|
|
131
|
+
}
|
|
132
|
+
const normalized = scope.trim();
|
|
133
|
+
if (normalized.length === 0) {
|
|
134
|
+
return "empty";
|
|
135
|
+
}
|
|
136
|
+
if (normalized === "global") {
|
|
137
|
+
return "global";
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================================
|
|
143
|
+
// Storage Path Validation
|
|
144
|
+
// ============================================================================
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate and prepare the storage directory before LanceDB connection.
|
|
148
|
+
* Resolves symlinks, creates missing directories, and checks write permissions.
|
|
149
|
+
* Returns the resolved absolute path on success, or throws a descriptive error.
|
|
150
|
+
*/
|
|
151
|
+
export function validateStoragePath(dbPath: string): string {
|
|
152
|
+
let resolvedPath = dbPath;
|
|
153
|
+
|
|
154
|
+
// Resolve symlinks if the path already exists
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(dbPath)) {
|
|
157
|
+
const stats = lstatSync(dbPath);
|
|
158
|
+
if (stats.isSymbolicLink()) {
|
|
159
|
+
try {
|
|
160
|
+
resolvedPath = realpathSync(dbPath);
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`dbPath "${dbPath}" is a symlink whose target does not exist.\n` +
|
|
164
|
+
` Fix: Create the target directory, or update the symlink to point to a valid path.\n` +
|
|
165
|
+
` Details: ${err.code || ""} ${err.message}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
// Re-throw our own descriptive errors
|
|
172
|
+
if (err.message.includes("symlink")) throw err;
|
|
173
|
+
// Other lstat failures — continue with original path
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create directory if it doesn't exist
|
|
177
|
+
if (!existsSync(resolvedPath)) {
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(resolvedPath, { recursive: true });
|
|
180
|
+
} catch (err: any) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Failed to create dbPath directory "${resolvedPath}".\n` +
|
|
183
|
+
` Fix: Ensure the parent directory "${dirname(resolvedPath)}" exists and is writable,\n` +
|
|
184
|
+
` or create it manually: mkdir -p "${resolvedPath}"\n` +
|
|
185
|
+
` Details: ${err.code || ""} ${err.message}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check write permissions
|
|
191
|
+
try {
|
|
192
|
+
accessSync(resolvedPath, constants.W_OK);
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`dbPath directory "${resolvedPath}" is not writable.\n` +
|
|
196
|
+
` Fix: Check permissions with: ls -la "${dirname(resolvedPath)}"\n` +
|
|
197
|
+
` Or grant write access: chmod u+w "${resolvedPath}"\n` +
|
|
198
|
+
` Details: ${err.code || ""} ${err.message}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return resolvedPath;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Memory Store
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
const TABLE_NAME = "memories";
|
|
210
|
+
|
|
211
|
+
export class MemoryStore {
|
|
212
|
+
private db: LanceDB.Connection | null = null;
|
|
213
|
+
private table: LanceDB.Table | null = null;
|
|
214
|
+
private initPromise: Promise<void> | null = null;
|
|
215
|
+
private ftsIndexCreated = false;
|
|
216
|
+
|
|
217
|
+
constructor(private readonly config: StoreConfig) {}
|
|
218
|
+
|
|
219
|
+
get dbPath(): string {
|
|
220
|
+
return this.config.dbPath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async ensureInitialized(): Promise<void> {
|
|
224
|
+
if (this.table) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (this.initPromise) {
|
|
228
|
+
return this.initPromise;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.initPromise = this.doInitialize().catch((err) => {
|
|
232
|
+
this.initPromise = null;
|
|
233
|
+
throw err;
|
|
234
|
+
});
|
|
235
|
+
return this.initPromise;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async doInitialize(): Promise<void> {
|
|
239
|
+
const lancedb = await loadLanceDB();
|
|
240
|
+
|
|
241
|
+
let db: LanceDB.Connection;
|
|
242
|
+
try {
|
|
243
|
+
db = await lancedb.connect(this.config.dbPath);
|
|
244
|
+
} catch (err: any) {
|
|
245
|
+
const code = err.code || "";
|
|
246
|
+
const message = err.message || String(err);
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Failed to open LanceDB at "${this.config.dbPath}": ${code} ${message}\n` +
|
|
249
|
+
` Fix: Verify the path exists and is writable. Check parent directory permissions.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let table: LanceDB.Table;
|
|
254
|
+
|
|
255
|
+
// Idempotent table init: try openTable first, create only if missing,
|
|
256
|
+
// and handle the race where tableNames() misses an existing table but
|
|
257
|
+
// createTable then sees it (LanceDB eventual consistency).
|
|
258
|
+
try {
|
|
259
|
+
table = await db.openTable(TABLE_NAME);
|
|
260
|
+
|
|
261
|
+
// Check if we need to add scope column for backward compatibility
|
|
262
|
+
try {
|
|
263
|
+
const sample = await table.query().limit(1).toArray();
|
|
264
|
+
if (sample.length > 0 && !("scope" in sample[0])) {
|
|
265
|
+
logWarn("Adding scope column for backward compatibility with existing data");
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
logWarn("Could not check table schema:", err);
|
|
269
|
+
}
|
|
270
|
+
} catch (_openErr) {
|
|
271
|
+
// Table doesn't exist yet — create it
|
|
272
|
+
const schemaEntry: MemoryEntry = {
|
|
273
|
+
id: "__schema__",
|
|
274
|
+
text: "",
|
|
275
|
+
vector: Array.from({ length: this.config.vectorDim }).fill(0) as number[],
|
|
276
|
+
category: "other",
|
|
277
|
+
scope: "__schema__",
|
|
278
|
+
importance: 0,
|
|
279
|
+
timestamp: 0,
|
|
280
|
+
metadata: "{}",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
table = await db.createTable(TABLE_NAME, [schemaEntry]);
|
|
285
|
+
await table.delete('id = "__schema__"');
|
|
286
|
+
} catch (createErr) {
|
|
287
|
+
// Race: another caller (or eventual consistency) created the table
|
|
288
|
+
// between our failed openTable and this createTable — just open it.
|
|
289
|
+
if (String(createErr).includes("already exists")) {
|
|
290
|
+
table = await db.openTable(TABLE_NAME);
|
|
291
|
+
} else {
|
|
292
|
+
throw createErr;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Validate vector dimensions
|
|
298
|
+
// Note: LanceDB returns Arrow Vector objects, not plain JS arrays.
|
|
299
|
+
// Array.isArray() returns false for Arrow Vectors, so use .length instead.
|
|
300
|
+
const sample = await table.query().limit(1).toArray();
|
|
301
|
+
if (sample.length > 0 && sample[0]?.vector?.length) {
|
|
302
|
+
const existingDim = sample[0].vector.length;
|
|
303
|
+
if (existingDim !== this.config.vectorDim) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Vector dimension mismatch: table=${existingDim}, config=${this.config.vectorDim}. Create a new table/dbPath or set matching embedding.dimensions.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Create FTS index for BM25 search (graceful fallback if unavailable)
|
|
311
|
+
try {
|
|
312
|
+
await this.createFtsIndex(table);
|
|
313
|
+
this.ftsIndexCreated = true;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
logWarn("Failed to create FTS index, falling back to vector-only search:", err);
|
|
316
|
+
this.ftsIndexCreated = false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.db = db;
|
|
320
|
+
this.table = table;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async createFtsIndex(table: LanceDB.Table): Promise<void> {
|
|
324
|
+
try {
|
|
325
|
+
// Check if FTS index already exists
|
|
326
|
+
const indices = await table.listIndices();
|
|
327
|
+
const hasFtsIndex = indices?.some((idx: any) =>
|
|
328
|
+
idx.indexType === "FTS" || idx.columns?.includes("text")
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (!hasFtsIndex) {
|
|
332
|
+
// LanceDB @lancedb/lancedb >=0.26: use Index.fts() config
|
|
333
|
+
const lancedb = await loadLanceDB();
|
|
334
|
+
await table.createIndex("text", {
|
|
335
|
+
config: (lancedb as any).Index.fts(),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
throw new Error(`FTS index creation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async store(entry: Omit<MemoryEntry, "id" | "timestamp"> & { id?: string }): Promise<MemoryEntry> {
|
|
344
|
+
await this.ensureInitialized();
|
|
345
|
+
|
|
346
|
+
const fullEntry: MemoryEntry = {
|
|
347
|
+
...entry,
|
|
348
|
+
id: entry.id || deterministicId(entry.scope, entry.text),
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
metadata: entry.metadata || "{}",
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await this.table!.add([fullEntry]);
|
|
355
|
+
} catch (err: any) {
|
|
356
|
+
const code = err.code || "";
|
|
357
|
+
const message = err.message || String(err);
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Failed to store memory in "${this.config.dbPath}": ${code} ${message}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return fullEntry;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Batch store multiple entries at once — much faster than individual store() calls.
|
|
367
|
+
* LanceDB handles bulk inserts efficiently with a single index update.
|
|
368
|
+
*/
|
|
369
|
+
async storeBatch(entries: Omit<MemoryEntry, "id" | "timestamp">[]): Promise<number> {
|
|
370
|
+
if (entries.length === 0) return 0;
|
|
371
|
+
await this.ensureInitialized();
|
|
372
|
+
|
|
373
|
+
const fullEntries: MemoryEntry[] = entries.map(entry => ({
|
|
374
|
+
...entry,
|
|
375
|
+
id: deterministicId(entry.scope, entry.text),
|
|
376
|
+
timestamp: Date.now(),
|
|
377
|
+
metadata: entry.metadata || "{}",
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
await this.table!.add(fullEntries);
|
|
382
|
+
} catch (err: any) {
|
|
383
|
+
const code = err.code || "";
|
|
384
|
+
const message = err.message || String(err);
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Failed to batch store ${entries.length} memories in "${this.config.dbPath}": ${code} ${message}`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return fullEntries.length;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Import a pre-built entry while preserving its id/timestamp.
|
|
394
|
+
* Used for re-embedding / migration / A/B testing across embedding models.
|
|
395
|
+
* Intentionally separate from `store()` to keep normal writes simple.
|
|
396
|
+
*/
|
|
397
|
+
async importEntry(entry: MemoryEntry): Promise<MemoryEntry> {
|
|
398
|
+
await this.ensureInitialized();
|
|
399
|
+
|
|
400
|
+
if (!entry.id || typeof entry.id !== "string") {
|
|
401
|
+
throw new Error("importEntry requires a stable id");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const vector = entry.vector || [];
|
|
405
|
+
if (!Array.isArray(vector) || vector.length !== this.config.vectorDim) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`Vector dimension mismatch: expected ${this.config.vectorDim}, got ${Array.isArray(vector) ? vector.length : 'non-array'}`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const full: MemoryEntry = {
|
|
412
|
+
...entry,
|
|
413
|
+
scope: entry.scope,
|
|
414
|
+
importance: Number.isFinite(entry.importance) ? entry.importance : 0.5,
|
|
415
|
+
timestamp: Number.isFinite(entry.timestamp) ? entry.timestamp : Date.now(),
|
|
416
|
+
metadata: entry.metadata || "{}",
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
await this.table!.add([full]);
|
|
420
|
+
return full;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async hasId(id: string): Promise<boolean> {
|
|
424
|
+
await this.ensureInitialized();
|
|
425
|
+
const safeId = escapeSqlLiteral(id);
|
|
426
|
+
const res = await this.table!.query().select(["id"]).where(`id = '${safeId}'`).limit(1).toArray();
|
|
427
|
+
return res.length > 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async vectorSearch(vector: number[], limit = 5, minScore = 0.3, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
|
|
431
|
+
await this.ensureInitialized();
|
|
432
|
+
|
|
433
|
+
const safeLimit = clampInt(limit, 1, 20);
|
|
434
|
+
const fetchLimit = Math.min(safeLimit * 10, 200); // Over-fetch for scope filtering
|
|
435
|
+
|
|
436
|
+
let query = this.table!.vectorSearch(vector).distanceType('cosine').limit(fetchLimit);
|
|
437
|
+
|
|
438
|
+
// Apply scope filter if provided
|
|
439
|
+
// Support both exact match ("cc:abc123") and prefix match ("cc")
|
|
440
|
+
if (scopeFilter && scopeFilter.length > 0) {
|
|
441
|
+
const scopeConditions = scopeFilter
|
|
442
|
+
.map(scope => {
|
|
443
|
+
const safe = escapeSqlLiteral(scope);
|
|
444
|
+
// If scope contains ":", treat as exact match; otherwise prefix match
|
|
445
|
+
return scope.includes(":")
|
|
446
|
+
? `scope = '${safe}'`
|
|
447
|
+
: `scope LIKE '${safe}%'`;
|
|
448
|
+
})
|
|
449
|
+
.join(" OR ");
|
|
450
|
+
query = query.where(`(${scopeConditions})`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const results = await query.toArray();
|
|
454
|
+
const mapped: MemorySearchResult[] = [];
|
|
455
|
+
|
|
456
|
+
for (const row of results) {
|
|
457
|
+
const distance = Number(row._distance ?? 0);
|
|
458
|
+
const score = 1 / (1 + distance);
|
|
459
|
+
|
|
460
|
+
if (score < minScore) continue;
|
|
461
|
+
|
|
462
|
+
const rowScope = (row.scope as string | undefined) ?? "";
|
|
463
|
+
|
|
464
|
+
// Double-check scope filter in application layer (prefix-aware)
|
|
465
|
+
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
mapped.push({
|
|
470
|
+
entry: {
|
|
471
|
+
id: row.id as string,
|
|
472
|
+
text: row.text as string,
|
|
473
|
+
vector: row.vector as number[],
|
|
474
|
+
category: row.category as MemoryEntry["category"],
|
|
475
|
+
scope: rowScope,
|
|
476
|
+
importance: Number(row.importance),
|
|
477
|
+
timestamp: Number(row.timestamp),
|
|
478
|
+
metadata: (row.metadata as string) || "{}",
|
|
479
|
+
},
|
|
480
|
+
score,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
if (mapped.length >= safeLimit) break;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return mapped;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async bm25Search(query: string, limit = 5, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
|
|
490
|
+
await this.ensureInitialized();
|
|
491
|
+
|
|
492
|
+
if (!this.ftsIndexCreated) {
|
|
493
|
+
return []; // Fallback to vector-only if FTS unavailable
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const safeLimit = clampInt(limit, 1, 20);
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
// Use FTS query type explicitly
|
|
500
|
+
let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
|
|
501
|
+
|
|
502
|
+
// Apply scope filter if provided (prefix-aware, same as vectorSearch)
|
|
503
|
+
if (scopeFilter && scopeFilter.length > 0) {
|
|
504
|
+
const scopeConditions = scopeFilter
|
|
505
|
+
.map(scope => {
|
|
506
|
+
const safe = escapeSqlLiteral(scope);
|
|
507
|
+
return scope.includes(":")
|
|
508
|
+
? `scope = '${safe}'`
|
|
509
|
+
: `scope LIKE '${safe}%'`;
|
|
510
|
+
})
|
|
511
|
+
.join(" OR ");
|
|
512
|
+
searchQuery = searchQuery.where(`(${scopeConditions})`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const results = await searchQuery.toArray();
|
|
516
|
+
const mapped: MemorySearchResult[] = [];
|
|
517
|
+
|
|
518
|
+
for (const row of results) {
|
|
519
|
+
const rowScope = (row.scope as string | undefined) ?? "";
|
|
520
|
+
|
|
521
|
+
// Double-check scope filter in application layer (prefix-aware)
|
|
522
|
+
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// LanceDB FTS _score is raw BM25 (unbounded). Normalize with sigmoid.
|
|
527
|
+
// LanceDB may return BigInt for numeric columns; coerce safely.
|
|
528
|
+
const rawScore = (row._score != null) ? Number(row._score) : 0;
|
|
529
|
+
const normalizedScore = rawScore > 0 ? 1 / (1 + Math.exp(-rawScore / 5)) : 0.5;
|
|
530
|
+
|
|
531
|
+
mapped.push({
|
|
532
|
+
entry: {
|
|
533
|
+
id: row.id as string,
|
|
534
|
+
text: row.text as string,
|
|
535
|
+
vector: row.vector as number[],
|
|
536
|
+
category: row.category as MemoryEntry["category"],
|
|
537
|
+
scope: rowScope,
|
|
538
|
+
importance: Number(row.importance),
|
|
539
|
+
timestamp: Number(row.timestamp),
|
|
540
|
+
metadata: (row.metadata as string) || "{}",
|
|
541
|
+
},
|
|
542
|
+
score: normalizedScore,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return mapped;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
logWarn("BM25 search failed, falling back to empty results:", err);
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async delete(id: string, scopeFilter?: string[]): Promise<boolean> {
|
|
554
|
+
await this.ensureInitialized();
|
|
555
|
+
|
|
556
|
+
// Support both full UUID and short prefix (8+ hex chars)
|
|
557
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
558
|
+
const prefixRegex = /^[0-9a-f]{8,}$/i;
|
|
559
|
+
const isFullId = uuidRegex.test(id);
|
|
560
|
+
const isPrefix = !isFullId && prefixRegex.test(id);
|
|
561
|
+
|
|
562
|
+
if (!isFullId && !isPrefix) {
|
|
563
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let candidates: any[];
|
|
567
|
+
if (isFullId) {
|
|
568
|
+
candidates = await this.table!.query().where(`id = '${id}'`).limit(1).toArray();
|
|
569
|
+
} else {
|
|
570
|
+
// Prefix match: fetch candidates and filter in app layer
|
|
571
|
+
const all = await this.table!.query().select(["id", "scope"]).limit(1000).toArray();
|
|
572
|
+
candidates = all.filter((r: any) => (r.id as string).startsWith(id));
|
|
573
|
+
if (candidates.length > 1) {
|
|
574
|
+
throw new Error(`Ambiguous prefix "${id}" matches ${candidates.length} memories. Use a longer prefix or full ID.`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (candidates.length === 0) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const resolvedId = candidates[0].id as string;
|
|
582
|
+
const rowScope = (candidates[0].scope as string | undefined) ?? "";
|
|
583
|
+
|
|
584
|
+
// Check scope permissions
|
|
585
|
+
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
586
|
+
throw new Error(`Memory ${resolvedId} is outside accessible scopes`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await this.table!.delete(`id = '${resolvedId}'`);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async list(scopeFilter?: string[], category?: string, limit = 20, offset = 0): Promise<MemoryEntry[]> {
|
|
594
|
+
await this.ensureInitialized();
|
|
595
|
+
|
|
596
|
+
let query = this.table!.query();
|
|
597
|
+
|
|
598
|
+
// Build where conditions
|
|
599
|
+
const conditions: string[] = [];
|
|
600
|
+
|
|
601
|
+
if (scopeFilter && scopeFilter.length > 0) {
|
|
602
|
+
const scopeConditions = scopeFilter
|
|
603
|
+
.map(scope => {
|
|
604
|
+
const safe = escapeSqlLiteral(scope);
|
|
605
|
+
return scope.includes(":")
|
|
606
|
+
? `scope = '${safe}'`
|
|
607
|
+
: `scope LIKE '${safe}%'`;
|
|
608
|
+
})
|
|
609
|
+
.join(" OR ");
|
|
610
|
+
conditions.push(`((${scopeConditions}))`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (category) {
|
|
614
|
+
conditions.push(`category = '${escapeSqlLiteral(category)}'`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (conditions.length > 0) {
|
|
618
|
+
query = query.where(conditions.join(" AND "));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Fetch all matching rows (no pre-limit) so app-layer sort is correct across full dataset
|
|
622
|
+
const results = await query
|
|
623
|
+
.select(["id", "text", "category", "scope", "importance", "timestamp", "metadata"])
|
|
624
|
+
.toArray();
|
|
625
|
+
|
|
626
|
+
return results
|
|
627
|
+
.map((row): MemoryEntry => ({
|
|
628
|
+
id: row.id as string,
|
|
629
|
+
text: row.text as string,
|
|
630
|
+
vector: [], // Don't include vectors in list results for performance
|
|
631
|
+
category: row.category as MemoryEntry["category"],
|
|
632
|
+
scope: (row.scope as string | undefined) ?? "",
|
|
633
|
+
importance: Number(row.importance),
|
|
634
|
+
timestamp: Number(row.timestamp),
|
|
635
|
+
metadata: (row.metadata as string) || "{}",
|
|
636
|
+
}))
|
|
637
|
+
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
|
|
638
|
+
.slice(offset, offset + limit);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async get(id: string, scopeFilter?: string[]): Promise<MemoryEntry | null> {
|
|
642
|
+
await this.ensureInitialized();
|
|
643
|
+
|
|
644
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
645
|
+
const prefixRegex = /^[0-9a-f]{8,}$/i;
|
|
646
|
+
const isFullId = uuidRegex.test(id);
|
|
647
|
+
const isPrefix = !isFullId && prefixRegex.test(id);
|
|
648
|
+
|
|
649
|
+
if (!isFullId && !isPrefix) {
|
|
650
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let rows: any[];
|
|
654
|
+
if (isFullId) {
|
|
655
|
+
const safeId = escapeSqlLiteral(id);
|
|
656
|
+
rows = await this.table!
|
|
657
|
+
.query()
|
|
658
|
+
.select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
|
|
659
|
+
.where(`id = '${safeId}'`)
|
|
660
|
+
.limit(1)
|
|
661
|
+
.toArray();
|
|
662
|
+
} else {
|
|
663
|
+
const all = await this.table!
|
|
664
|
+
.query()
|
|
665
|
+
.select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
|
|
666
|
+
.toArray();
|
|
667
|
+
rows = all.filter((r: any) => (r.id as string).startsWith(id));
|
|
668
|
+
if (rows.length > 1) {
|
|
669
|
+
throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (rows.length === 0) return null;
|
|
674
|
+
|
|
675
|
+
const row = rows[0];
|
|
676
|
+
const rowScope = (row.scope as string | undefined) ?? "";
|
|
677
|
+
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
678
|
+
throw new Error(`Memory ${id} is outside accessible scopes`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
id: row.id as string,
|
|
683
|
+
text: row.text as string,
|
|
684
|
+
vector: Array.from(row.vector as Iterable<number>),
|
|
685
|
+
category: row.category as MemoryEntry["category"],
|
|
686
|
+
scope: rowScope,
|
|
687
|
+
importance: Number(row.importance),
|
|
688
|
+
timestamp: Number(row.timestamp),
|
|
689
|
+
metadata: (row.metadata as string) || "{}",
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Get a single entry by exact ID without scope filtering.
|
|
695
|
+
* Lightweight read used by AccessTracker for metadata updates.
|
|
696
|
+
*/
|
|
697
|
+
async getById(id: string): Promise<MemoryEntry | null> {
|
|
698
|
+
await this.ensureInitialized();
|
|
699
|
+
const safeId = escapeSqlLiteral(id);
|
|
700
|
+
const rows = await this.table!.query()
|
|
701
|
+
.select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"])
|
|
702
|
+
.where(`id = '${safeId}'`)
|
|
703
|
+
.limit(1)
|
|
704
|
+
.toArray();
|
|
705
|
+
if (rows.length === 0) return null;
|
|
706
|
+
const row = rows[0];
|
|
707
|
+
return {
|
|
708
|
+
id: row.id as string,
|
|
709
|
+
text: row.text as string,
|
|
710
|
+
vector: Array.from(row.vector as Iterable<number>),
|
|
711
|
+
category: row.category as MemoryEntry["category"],
|
|
712
|
+
scope: (row.scope as string | undefined) ?? "",
|
|
713
|
+
importance: Number(row.importance),
|
|
714
|
+
timestamp: Number(row.timestamp),
|
|
715
|
+
metadata: (row.metadata as string) || "{}",
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async stats(scopeFilter?: string[]): Promise<{
|
|
720
|
+
totalCount: number;
|
|
721
|
+
scopeCounts: Record<string, number>;
|
|
722
|
+
categoryCounts: Record<string, number>
|
|
723
|
+
}> {
|
|
724
|
+
await this.ensureInitialized();
|
|
725
|
+
|
|
726
|
+
let query = this.table!.query();
|
|
727
|
+
|
|
728
|
+
if (scopeFilter && scopeFilter.length > 0) {
|
|
729
|
+
const scopeConditions = scopeFilter
|
|
730
|
+
.map(scope => {
|
|
731
|
+
const safe = escapeSqlLiteral(scope);
|
|
732
|
+
return scope.includes(":")
|
|
733
|
+
? `scope = '${safe}'`
|
|
734
|
+
: `scope LIKE '${safe}%'`;
|
|
735
|
+
})
|
|
736
|
+
.join(" OR ");
|
|
737
|
+
query = query.where(`((${scopeConditions}))`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const results = await query.select(["scope", "category"]).toArray();
|
|
741
|
+
|
|
742
|
+
const scopeCounts: Record<string, number> = {};
|
|
743
|
+
const categoryCounts: Record<string, number> = {};
|
|
744
|
+
|
|
745
|
+
for (const row of results) {
|
|
746
|
+
const scope = (row.scope as string | undefined) ?? "";
|
|
747
|
+
const category = row.category as string;
|
|
748
|
+
|
|
749
|
+
scopeCounts[scope] = (scopeCounts[scope] || 0) + 1;
|
|
750
|
+
categoryCounts[category] = (categoryCounts[category] || 0) + 1;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
totalCount: results.length,
|
|
755
|
+
scopeCounts,
|
|
756
|
+
categoryCounts,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async auditLegacyScopes(limit = 20): Promise<LegacyScopeAudit> {
|
|
761
|
+
await this.ensureInitialized();
|
|
762
|
+
|
|
763
|
+
const safeLimit = clampInt(limit, 1, 200);
|
|
764
|
+
const rows = await this.table!
|
|
765
|
+
.query()
|
|
766
|
+
.select(["id", "text", "category", "scope", "timestamp"])
|
|
767
|
+
.toArray();
|
|
768
|
+
|
|
769
|
+
const counts: Record<LegacyScopeIssueKind, number> = {
|
|
770
|
+
missing: 0,
|
|
771
|
+
empty: 0,
|
|
772
|
+
global: 0,
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const samples = rows
|
|
776
|
+
.map((row) => {
|
|
777
|
+
const scopeValue = (row.scope as string | null | undefined) ?? null;
|
|
778
|
+
const kind = classifyLegacyScope(scopeValue);
|
|
779
|
+
if (!kind) return null;
|
|
780
|
+
counts[kind] += 1;
|
|
781
|
+
return {
|
|
782
|
+
id: row.id as string,
|
|
783
|
+
kind,
|
|
784
|
+
scope: scopeValue,
|
|
785
|
+
category: row.category as MemoryEntry["category"],
|
|
786
|
+
timestamp: Number(row.timestamp),
|
|
787
|
+
text: row.text as string,
|
|
788
|
+
} satisfies LegacyScopeAuditSample;
|
|
789
|
+
})
|
|
790
|
+
.filter((row): row is LegacyScopeAuditSample => row !== null)
|
|
791
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
792
|
+
.slice(0, safeLimit);
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
totalCount: counts.missing + counts.empty + counts.global,
|
|
796
|
+
counts,
|
|
797
|
+
samples,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async update(
|
|
802
|
+
id: string,
|
|
803
|
+
updates: { text?: string; vector?: number[]; importance?: number; category?: MemoryEntry["category"]; metadata?: string; timestamp?: number },
|
|
804
|
+
scopeFilter?: string[]
|
|
805
|
+
): Promise<MemoryEntry | null> {
|
|
806
|
+
await this.ensureInitialized();
|
|
807
|
+
|
|
808
|
+
// Support both full UUID and short prefix (8+ hex chars), same as delete()
|
|
809
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
810
|
+
const prefixRegex = /^[0-9a-f]{8,}$/i;
|
|
811
|
+
const isFullId = uuidRegex.test(id);
|
|
812
|
+
const isPrefix = !isFullId && prefixRegex.test(id);
|
|
813
|
+
|
|
814
|
+
if (!isFullId && !isPrefix) {
|
|
815
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
let rows: any[];
|
|
819
|
+
if (isFullId) {
|
|
820
|
+
const safeId = escapeSqlLiteral(id);
|
|
821
|
+
rows = await this.table!.query().where(`id = '${safeId}'`).limit(1).toArray();
|
|
822
|
+
} else {
|
|
823
|
+
// Prefix match
|
|
824
|
+
const all = await this.table!.query().select(["id", "text", "vector", "category", "scope", "importance", "timestamp", "metadata"]).toArray();
|
|
825
|
+
rows = all.filter((r: any) => (r.id as string).startsWith(id));
|
|
826
|
+
if (rows.length > 1) {
|
|
827
|
+
throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (rows.length === 0) return null;
|
|
832
|
+
|
|
833
|
+
const row = rows[0];
|
|
834
|
+
const rowScope = (row.scope as string | undefined) ?? "";
|
|
835
|
+
|
|
836
|
+
// Check scope permissions
|
|
837
|
+
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
838
|
+
throw new Error(`Memory ${id} is outside accessible scopes`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Build updated entry, preserving original timestamp
|
|
842
|
+
const updated: MemoryEntry = {
|
|
843
|
+
id: row.id as string,
|
|
844
|
+
text: updates.text ?? (row.text as string),
|
|
845
|
+
vector: updates.vector ?? (Array.from(row.vector as Iterable<number>)),
|
|
846
|
+
category: updates.category ?? (row.category as MemoryEntry["category"]),
|
|
847
|
+
scope: rowScope,
|
|
848
|
+
importance: updates.importance ?? Number(row.importance),
|
|
849
|
+
timestamp: updates.timestamp ?? Number(row.timestamp),
|
|
850
|
+
metadata: updates.metadata ?? ((row.metadata as string) || "{}"),
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// LanceDB doesn't support in-place update; delete + re-add
|
|
854
|
+
const resolvedId = escapeSqlLiteral(row.id as string);
|
|
855
|
+
await this.table!.delete(`id = '${resolvedId}'`);
|
|
856
|
+
await this.table!.add([updated]);
|
|
857
|
+
|
|
858
|
+
return updated;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
|
|
862
|
+
await this.ensureInitialized();
|
|
863
|
+
|
|
864
|
+
const conditions: string[] = [];
|
|
865
|
+
|
|
866
|
+
if (scopeFilter.length > 0) {
|
|
867
|
+
const scopeConditions = scopeFilter
|
|
868
|
+
.map(scope => {
|
|
869
|
+
const safe = escapeSqlLiteral(scope);
|
|
870
|
+
return scope.includes(":")
|
|
871
|
+
? `scope = '${safe}'`
|
|
872
|
+
: `scope LIKE '${safe}%'`;
|
|
873
|
+
})
|
|
874
|
+
.join(" OR ");
|
|
875
|
+
conditions.push(`(${scopeConditions})`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (beforeTimestamp) {
|
|
879
|
+
conditions.push(`timestamp < ${beforeTimestamp}`);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (conditions.length === 0) {
|
|
883
|
+
throw new Error("Bulk delete requires at least scope or timestamp filter for safety");
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const whereClause = conditions.join(" AND ");
|
|
887
|
+
|
|
888
|
+
// Count first
|
|
889
|
+
const countResults = await this.table!.query().where(whereClause).toArray();
|
|
890
|
+
const deleteCount = countResults.length;
|
|
891
|
+
|
|
892
|
+
// Then delete
|
|
893
|
+
if (deleteCount > 0) {
|
|
894
|
+
await this.table!.delete(whereClause);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return deleteCount;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
get hasFtsSupport(): boolean {
|
|
901
|
+
return this.ftsIndexCreated;
|
|
902
|
+
}
|
|
903
|
+
}
|