pi-recollect 1.0.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/index.ts +10 -0
- package/package.json +54 -0
- package/skills/compound-note/skill.md +32 -0
- package/skills/memory-search/skill.md +24 -0
- package/skills/memory-store/skill.md +26 -0
- package/src/compound/analyzer.ts +170 -0
- package/src/compound/dedup.ts +111 -0
- package/src/compound/extractor.ts +110 -0
- package/src/compound/router.ts +82 -0
- package/src/compound/writer.ts +110 -0
- package/src/config.ts +114 -0
- package/src/continuity/compaction-hook.ts +32 -0
- package/src/continuity/resumer.ts +104 -0
- package/src/continuity/tracker.ts +36 -0
- package/src/extension/register.ts +279 -0
- package/src/memory/hierarchical.ts +122 -0
- package/src/memory/mental-models.ts +141 -0
- package/src/memory/recall.ts +110 -0
- package/src/memory/reflect.ts +32 -0
- package/src/memory/retain.ts +77 -0
- package/src/store/events.ts +61 -0
- package/src/store/fts5-index.ts +32 -0
- package/src/store/schema.ts +113 -0
- package/src/store/search.ts +222 -0
- package/src/store/sqlite.ts +53 -0
- package/src/store/vocabulary.ts +34 -0
- package/src/tools/memory-recall.ts +17 -0
- package/src/tools/memory-search.ts +56 -0
- package/src/tools/memory-status.ts +113 -0
- package/src/tools/memory-store.ts +30 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface SearchResult {
|
|
6
|
+
id: string;
|
|
7
|
+
score: number;
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
category: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchOptions {
|
|
14
|
+
maxResults?: number;
|
|
15
|
+
rrfK?: number;
|
|
16
|
+
proximityBoost?: number;
|
|
17
|
+
scope?: string;
|
|
18
|
+
detail?: "compact" | "medium" | "full";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Porter stemmer search ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function porterSearch(
|
|
24
|
+
db: Database.Database,
|
|
25
|
+
query: string,
|
|
26
|
+
limit: number,
|
|
27
|
+
): SearchResult[] {
|
|
28
|
+
const sql = `
|
|
29
|
+
SELECT source_id, title, content, category, rank
|
|
30
|
+
FROM chunks
|
|
31
|
+
WHERE chunks MATCH ?
|
|
32
|
+
ORDER BY bm25(chunks)
|
|
33
|
+
LIMIT ?
|
|
34
|
+
`;
|
|
35
|
+
try {
|
|
36
|
+
const rows = db.prepare(sql).all(query, limit) as Array<{
|
|
37
|
+
source_id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
content: string;
|
|
40
|
+
category: string;
|
|
41
|
+
rank: number;
|
|
42
|
+
}>;
|
|
43
|
+
return rows.map((r) => ({
|
|
44
|
+
id: r.source_id,
|
|
45
|
+
score: -r.rank,
|
|
46
|
+
title: r.title ?? "",
|
|
47
|
+
content: r.content ?? "",
|
|
48
|
+
category: r.category ?? "",
|
|
49
|
+
}));
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Trigram search ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export function trigramSearch(
|
|
58
|
+
db: Database.Database,
|
|
59
|
+
query: string,
|
|
60
|
+
limit: number,
|
|
61
|
+
): SearchResult[] {
|
|
62
|
+
// Trigram FTS5 requires wrapping query in quotes for phrase matching
|
|
63
|
+
const trigramQuery = `"${query.replace(/"/g, '""')}"`;
|
|
64
|
+
const sql = `
|
|
65
|
+
SELECT source_id, content, rank
|
|
66
|
+
FROM chunks_trigram
|
|
67
|
+
WHERE chunks_trigram MATCH ?
|
|
68
|
+
ORDER BY bm25(chunks_trigram)
|
|
69
|
+
LIMIT ?
|
|
70
|
+
`;
|
|
71
|
+
try {
|
|
72
|
+
const rows = db.prepare(sql).all(trigramQuery, limit) as Array<{
|
|
73
|
+
source_id: string;
|
|
74
|
+
content: string;
|
|
75
|
+
rank: number;
|
|
76
|
+
}>;
|
|
77
|
+
return rows.map((r) => ({
|
|
78
|
+
id: r.source_id,
|
|
79
|
+
score: -r.rank,
|
|
80
|
+
title: "",
|
|
81
|
+
content: r.content ?? "",
|
|
82
|
+
category: "",
|
|
83
|
+
}));
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Reciprocal Rank Fusion ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function rrf(
|
|
92
|
+
porterResults: SearchResult[],
|
|
93
|
+
trigramResults: SearchResult[],
|
|
94
|
+
K: number = 60,
|
|
95
|
+
): SearchResult[] {
|
|
96
|
+
const scores = new Map<string, { score: number; title: string; content: string; category: string }>();
|
|
97
|
+
|
|
98
|
+
for (const [rank, result] of porterResults.entries()) {
|
|
99
|
+
const existing = scores.get(result.id);
|
|
100
|
+
const addedScore = 1 / (K + rank + 1);
|
|
101
|
+
if (existing) {
|
|
102
|
+
existing.score += addedScore;
|
|
103
|
+
} else {
|
|
104
|
+
scores.set(result.id, {
|
|
105
|
+
score: addedScore,
|
|
106
|
+
title: result.title,
|
|
107
|
+
content: result.content,
|
|
108
|
+
category: result.category,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const [rank, result] of trigramResults.entries()) {
|
|
114
|
+
const existing = scores.get(result.id);
|
|
115
|
+
const addedScore = 1 / (K + rank + 1);
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.score += addedScore;
|
|
118
|
+
// Keep porter title if available
|
|
119
|
+
if (!existing.title && result.title) existing.title = result.title;
|
|
120
|
+
} else {
|
|
121
|
+
scores.set(result.id, {
|
|
122
|
+
score: addedScore,
|
|
123
|
+
title: result.title,
|
|
124
|
+
content: result.content,
|
|
125
|
+
category: result.category,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...scores.entries()]
|
|
131
|
+
.sort((a, b) => b[1].score - a[1].score)
|
|
132
|
+
.map(([id, data]) => ({
|
|
133
|
+
id,
|
|
134
|
+
score: data.score,
|
|
135
|
+
title: data.title,
|
|
136
|
+
content: data.content,
|
|
137
|
+
category: data.category,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Proximity reranking ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function proximityRerank(
|
|
144
|
+
results: SearchResult[],
|
|
145
|
+
query: string,
|
|
146
|
+
boost: number = 1.5,
|
|
147
|
+
): SearchResult[] {
|
|
148
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
149
|
+
if (terms.length <= 1) return results;
|
|
150
|
+
|
|
151
|
+
return results.map((result) => {
|
|
152
|
+
const lower = result.content.toLowerCase();
|
|
153
|
+
const positions: number[][] = terms.map((term) => {
|
|
154
|
+
const pos: number[] = [];
|
|
155
|
+
let idx = 0;
|
|
156
|
+
while (true) {
|
|
157
|
+
const found = lower.indexOf(term, idx);
|
|
158
|
+
if (found === -1) break;
|
|
159
|
+
pos.push(found);
|
|
160
|
+
idx = found + 1;
|
|
161
|
+
}
|
|
162
|
+
return pos;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// If any term not found, no proximity bonus
|
|
166
|
+
if (positions.some((p) => p.length === 0)) return result;
|
|
167
|
+
|
|
168
|
+
// Find minimum span covering one occurrence of each term
|
|
169
|
+
let minSpan = Infinity;
|
|
170
|
+
for (const pos0 of positions[0]!) {
|
|
171
|
+
const current: number[] = [pos0];
|
|
172
|
+
for (let t = 1; t < positions.length; t++) {
|
|
173
|
+
// Find closest position in term t to the last added position
|
|
174
|
+
const lastPos = current[current.length - 1]!;
|
|
175
|
+
let closest = positions[t]![0]!;
|
|
176
|
+
for (const p of positions[t]!) {
|
|
177
|
+
if (Math.abs(p - lastPos) < Math.abs(closest - lastPos)) {
|
|
178
|
+
closest = p;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
current.push(closest);
|
|
182
|
+
}
|
|
183
|
+
const span = Math.max(...current) - Math.min(...current);
|
|
184
|
+
minSpan = Math.min(minSpan, span);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Boost inversely proportional to span (closer terms = higher boost)
|
|
188
|
+
const contentLength = Math.max(result.content.length, 1);
|
|
189
|
+
const proximityScore = minSpan < contentLength ? boost * (1 - minSpan / contentLength) : 0;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...result,
|
|
193
|
+
score: result.score + proximityScore,
|
|
194
|
+
};
|
|
195
|
+
}).sort((a, b) => b.score - a.score);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Unified search ─────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export function search(
|
|
201
|
+
db: Database.Database,
|
|
202
|
+
query: string,
|
|
203
|
+
opts: SearchOptions = {},
|
|
204
|
+
): SearchResult[] {
|
|
205
|
+
const maxResults = opts.maxResults ?? 5;
|
|
206
|
+
const rrfK = opts.rrfK ?? 60;
|
|
207
|
+
const boost = opts.proximityBoost ?? 1.5;
|
|
208
|
+
const fetchLimit = maxResults * 4; // fetch more for reranking
|
|
209
|
+
|
|
210
|
+
const porter = porterSearch(db, query, fetchLimit);
|
|
211
|
+
const trigram = trigramSearch(db, query, fetchLimit);
|
|
212
|
+
|
|
213
|
+
let fused = rrf(porter, trigram, rrfK);
|
|
214
|
+
fused = proximityRerank(fused, query, boost);
|
|
215
|
+
|
|
216
|
+
// Scope filter
|
|
217
|
+
if (opts.scope && opts.scope !== "all") {
|
|
218
|
+
fused = fused.filter((r) => r.category === opts.scope);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return fused.slice(0, maxResults);
|
|
222
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
import { initSchema } from "./schema.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages the SQLite database connection for pi-recall.
|
|
9
|
+
* Database is stored at <cwd>/.pi-recall/memory.db.
|
|
10
|
+
*/
|
|
11
|
+
export class MemoryDB {
|
|
12
|
+
public readonly cwd: string;
|
|
13
|
+
public readonly dbPath: string;
|
|
14
|
+
private db: Database.Database | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(cwd: string) {
|
|
17
|
+
this.cwd = cwd;
|
|
18
|
+
const dir = path.join(cwd, ".pi-recall");
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
this.dbPath = path.join(dir, "memory.db");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Open (or return existing) database connection with full schema. */
|
|
24
|
+
open(): Database.Database {
|
|
25
|
+
if (this.db) return this.db;
|
|
26
|
+
this.db = new BetterSqlite3(this.dbPath) as unknown as Database.Database;
|
|
27
|
+
initSchema(this.db);
|
|
28
|
+
return this.db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Get the current open connection (throws if not open). */
|
|
32
|
+
getConnection(): Database.Database {
|
|
33
|
+
if (!this.db) throw new Error("MemoryDB not opened. Call open() first.");
|
|
34
|
+
return this.db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Close the database connection. */
|
|
38
|
+
close(): void {
|
|
39
|
+
if (this.db) {
|
|
40
|
+
try {
|
|
41
|
+
this.db.close();
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore close errors
|
|
44
|
+
}
|
|
45
|
+
this.db = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Check if the database is currently open. */
|
|
50
|
+
get isOpen(): boolean {
|
|
51
|
+
return this.db !== null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
export interface VocabStats {
|
|
4
|
+
docCount: number;
|
|
5
|
+
totalCount: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Update vocabulary term frequencies for a list of terms.
|
|
10
|
+
* Increments doc_count (unique docs) and total_count (total occurrences).
|
|
11
|
+
*/
|
|
12
|
+
export function updateVocabulary(db: Database.Database, terms: string[]): void {
|
|
13
|
+
const upsert = db.prepare(`
|
|
14
|
+
INSERT INTO vocabulary (term, doc_count, total_count)
|
|
15
|
+
VALUES (?, 1, 1)
|
|
16
|
+
ON CONFLICT(term) DO UPDATE SET
|
|
17
|
+
doc_count = doc_count + 1,
|
|
18
|
+
total_count = total_count + 1
|
|
19
|
+
`);
|
|
20
|
+
for (const term of terms) {
|
|
21
|
+
upsert.run(term.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get vocabulary statistics for a specific term.
|
|
27
|
+
*/
|
|
28
|
+
export function getVocabStats(db: Database.Database, term: string): VocabStats | null {
|
|
29
|
+
const row = db.prepare(
|
|
30
|
+
`SELECT doc_count, total_count FROM vocabulary WHERE term = ?`,
|
|
31
|
+
).get(term.toLowerCase()) as { doc_count: number; total_count: number } | undefined;
|
|
32
|
+
if (!row) return null;
|
|
33
|
+
return { docCount: row.doc_count, totalCount: row.total_count };
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { recallMemories, wrapInAntiFeedbackTags } from "../memory/recall.ts";
|
|
3
|
+
|
|
4
|
+
export interface MemoryRecallInput {
|
|
5
|
+
context: string;
|
|
6
|
+
budget?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle the memory_recall tool invocation.
|
|
11
|
+
*/
|
|
12
|
+
export function handleMemoryRecall(db: Database.Database, input: MemoryRecallInput): string {
|
|
13
|
+
const budget = input.budget ?? 2048;
|
|
14
|
+
const result = recallMemories(db, input.context, budget);
|
|
15
|
+
const wrapped = wrapInAntiFeedbackTags(result.content);
|
|
16
|
+
return `Recalled ${result.resultCount} memories (detail: ${result.detailLevel}, budget: ${budget} bytes):\n\n${wrapped}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { search, type SearchOptions } from "../store/search.ts";
|
|
3
|
+
|
|
4
|
+
export interface MemorySearchInput {
|
|
5
|
+
query: string;
|
|
6
|
+
maxResults?: number;
|
|
7
|
+
scope?: "all" | "solutions" | "decisions" | "gotchas" | "conventions";
|
|
8
|
+
detail?: "compact" | "medium" | "full";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MemorySearchResult {
|
|
12
|
+
results: Array<{
|
|
13
|
+
title: string;
|
|
14
|
+
content: string;
|
|
15
|
+
category: string;
|
|
16
|
+
score: number;
|
|
17
|
+
}>;
|
|
18
|
+
totalCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatResult(title: string, content: string, category: string, score: number, detail: string): string {
|
|
22
|
+
switch (detail) {
|
|
23
|
+
case "compact":
|
|
24
|
+
return `${title} (${category}, score: ${score.toFixed(3)})`;
|
|
25
|
+
case "medium":
|
|
26
|
+
return `${title} (${category}): ${content.slice(0, 200)}`;
|
|
27
|
+
case "full":
|
|
28
|
+
return `## ${title}\nCategory: ${category}\n\n${content}`;
|
|
29
|
+
default:
|
|
30
|
+
return `${title}: ${content.slice(0, 200)}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle the memory_search tool invocation.
|
|
36
|
+
*/
|
|
37
|
+
export function handleMemorySearch(db: Database.Database, input: MemorySearchInput): string {
|
|
38
|
+
const opts: SearchOptions = {
|
|
39
|
+
maxResults: input.maxResults ?? 5,
|
|
40
|
+
scope: input.scope ?? "all",
|
|
41
|
+
detail: input.detail ?? "compact",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const results = search(db, input.query, opts);
|
|
45
|
+
|
|
46
|
+
if (results.length === 0) {
|
|
47
|
+
return `No results found for "${input.query}".`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const detail = input.detail ?? "compact";
|
|
51
|
+
const formatted = results
|
|
52
|
+
.map((r) => formatResult(r.title, r.content, r.category, r.score, detail))
|
|
53
|
+
.join("\n\n");
|
|
54
|
+
|
|
55
|
+
return `Found ${results.length} result(s) for "${input.query}":\n\n${formatted}`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
export interface MemoryStatusInput {
|
|
5
|
+
action: "status" | "stats" | "reindex" | "compact" | "export";
|
|
6
|
+
format?: "text" | "json";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface MemoryStats {
|
|
10
|
+
solutions: number;
|
|
11
|
+
sources: number;
|
|
12
|
+
events: number;
|
|
13
|
+
mentalModels: number;
|
|
14
|
+
dbSize: number;
|
|
15
|
+
lastUpdated: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getStats(db: Database.Database, dbPath: string): MemoryStats {
|
|
19
|
+
const solutions = (db.prepare(`SELECT COUNT(*) as c FROM solutions`).get() as { c: number }).c;
|
|
20
|
+
const sources = (db.prepare(`SELECT COUNT(*) as c FROM sources`).get() as { c: number }).c;
|
|
21
|
+
const events = (db.prepare(`SELECT COUNT(*) as c FROM events`).get() as { c: number }).c;
|
|
22
|
+
const mentalModels = (db.prepare(`SELECT COUNT(*) as c FROM mental_models`).get() as { c: number }).c;
|
|
23
|
+
|
|
24
|
+
let dbSize = 0;
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.statSync(dbPath);
|
|
27
|
+
dbSize = stat.size;
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
|
|
30
|
+
const lastSolution = db.prepare(
|
|
31
|
+
`SELECT updated_at FROM solutions ORDER BY updated_at DESC LIMIT 1`,
|
|
32
|
+
).get() as { updated_at: string } | undefined;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
solutions,
|
|
36
|
+
sources,
|
|
37
|
+
events,
|
|
38
|
+
mentalModels,
|
|
39
|
+
dbSize,
|
|
40
|
+
lastUpdated: lastSolution?.updated_at ?? null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatBytes(bytes: number): string {
|
|
45
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
46
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
47
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle the memory_status tool invocation.
|
|
52
|
+
*/
|
|
53
|
+
export function handleMemoryStatus(db: Database.Database, dbPath: string, input: MemoryStatusInput): string {
|
|
54
|
+
const format = input.format ?? "text";
|
|
55
|
+
|
|
56
|
+
switch (input.action) {
|
|
57
|
+
case "status":
|
|
58
|
+
case "stats": {
|
|
59
|
+
const stats = getStats(db, dbPath);
|
|
60
|
+
if (format === "json") {
|
|
61
|
+
return JSON.stringify(stats, null, 2);
|
|
62
|
+
}
|
|
63
|
+
return [
|
|
64
|
+
`pi-recall Status:`,
|
|
65
|
+
` Solutions stored: ${stats.solutions}`,
|
|
66
|
+
` Sources indexed: ${stats.sources}`,
|
|
67
|
+
` Session events: ${stats.events}`,
|
|
68
|
+
` Mental models: ${stats.mentalModels}`,
|
|
69
|
+
` Index size: ${formatBytes(stats.dbSize)}`,
|
|
70
|
+
` Last updated: ${stats.lastUpdated ?? "never"}`,
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
case "reindex": {
|
|
74
|
+
// Delete and rebuild FTS5 indexes from sources
|
|
75
|
+
db.exec(`DELETE FROM chunks`);
|
|
76
|
+
db.exec(`DELETE FROM chunks_trigram`);
|
|
77
|
+
const sources = db.prepare(`SELECT id, title, category, metadata FROM sources`).all() as Array<{
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
category: string;
|
|
81
|
+
metadata: string;
|
|
82
|
+
}>;
|
|
83
|
+
// Re-insert content from solutions and sources
|
|
84
|
+
const insertChunk = db.prepare(
|
|
85
|
+
`INSERT INTO chunks (source_id, title, content, category) VALUES (?, ?, ?, ?)`,
|
|
86
|
+
);
|
|
87
|
+
const insertTrigram = db.prepare(
|
|
88
|
+
`INSERT INTO chunks_trigram (source_id, content) VALUES (?, ?)`,
|
|
89
|
+
);
|
|
90
|
+
for (const source of sources) {
|
|
91
|
+
const content = source.title; // Use title as content for reindex
|
|
92
|
+
insertChunk.run(source.id, source.title, content, source.category);
|
|
93
|
+
insertTrigram.run(source.id, content);
|
|
94
|
+
}
|
|
95
|
+
return `Reindexed ${sources.length} sources.`;
|
|
96
|
+
}
|
|
97
|
+
case "compact": {
|
|
98
|
+
// VACUUM the database to reclaim space
|
|
99
|
+
db.exec(`VACUUM`);
|
|
100
|
+
const stats = getStats(db, dbPath);
|
|
101
|
+
return `Database compacted. Current size: ${formatBytes(stats.dbSize)}.`;
|
|
102
|
+
}
|
|
103
|
+
case "export": {
|
|
104
|
+
const stats = getStats(db, dbPath);
|
|
105
|
+
if (format === "json") {
|
|
106
|
+
return JSON.stringify({ stats }, null, 2);
|
|
107
|
+
}
|
|
108
|
+
return `Export:\n${JSON.stringify(stats, null, 2)}`;
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
return `Unknown action: ${input.action}`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { storeMemory, type StoreMemoryOpts } from "../memory/retain.ts";
|
|
3
|
+
|
|
4
|
+
export interface MemoryStoreInput {
|
|
5
|
+
category: "gotcha" | "convention" | "decision" | "pattern" | "architecture";
|
|
6
|
+
title: string;
|
|
7
|
+
content: string;
|
|
8
|
+
metadata?: {
|
|
9
|
+
files?: string[];
|
|
10
|
+
tags?: string[];
|
|
11
|
+
severity?: "low" | "medium" | "high" | "critical";
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Handle the memory_store tool invocation.
|
|
17
|
+
*/
|
|
18
|
+
export function handleMemoryStore(db: Database.Database, cwd: string, input: MemoryStoreInput): string {
|
|
19
|
+
const opts: StoreMemoryOpts = {
|
|
20
|
+
category: input.category,
|
|
21
|
+
title: input.title,
|
|
22
|
+
content: input.content,
|
|
23
|
+
files: input.metadata?.files,
|
|
24
|
+
tags: input.metadata?.tags,
|
|
25
|
+
severity: input.metadata?.severity,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const id = storeMemory(db, cwd, opts);
|
|
29
|
+
return `Stored memory "${input.title}" (${input.category}) with id ${id}.`;
|
|
30
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noImplicitAny": true,
|
|
8
|
+
"exactOptionalPropertyTypes": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"*.ts",
|
|
16
|
+
"src/**/*.ts",
|
|
17
|
+
"test/**/*.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|