engrm 0.1.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/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* save_observation MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: detect project → scrub secrets → score quality →
|
|
5
|
+
* check dedup → insert → add to outbox
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { relative, isAbsolute } from "node:path";
|
|
9
|
+
import type { Config } from "../config.js";
|
|
10
|
+
import { scrubSecrets, containsSecrets } from "../capture/scrubber.js";
|
|
11
|
+
import { scoreQuality, meetsQualityThreshold } from "../capture/quality.js";
|
|
12
|
+
import { findDuplicate, type DedupCandidate } from "../capture/dedup.js";
|
|
13
|
+
import { detectProject } from "../storage/projects.js";
|
|
14
|
+
import type { MemDatabase, ObservationRow } from "../storage/sqlite.js";
|
|
15
|
+
import { composeEmbeddingText, embedText } from "../embeddings/embedder.js";
|
|
16
|
+
|
|
17
|
+
const VALID_TYPES = [
|
|
18
|
+
"bugfix",
|
|
19
|
+
"discovery",
|
|
20
|
+
"decision",
|
|
21
|
+
"pattern",
|
|
22
|
+
"change",
|
|
23
|
+
"feature",
|
|
24
|
+
"refactor",
|
|
25
|
+
"digest",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
type ObservationType = (typeof VALID_TYPES)[number];
|
|
29
|
+
|
|
30
|
+
export interface SaveObservationInput {
|
|
31
|
+
type: string;
|
|
32
|
+
title: string;
|
|
33
|
+
narrative?: string;
|
|
34
|
+
facts?: string[];
|
|
35
|
+
concepts?: string[];
|
|
36
|
+
files_read?: string[];
|
|
37
|
+
files_modified?: string[];
|
|
38
|
+
sensitivity?: "shared" | "personal" | "secret";
|
|
39
|
+
session_id?: string;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
agent?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SaveObservationResult {
|
|
45
|
+
success: boolean;
|
|
46
|
+
observation_id?: number;
|
|
47
|
+
quality_score?: number;
|
|
48
|
+
merged_into?: number;
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Save an observation through the full capture pipeline.
|
|
54
|
+
*/
|
|
55
|
+
export async function saveObservation(
|
|
56
|
+
db: MemDatabase,
|
|
57
|
+
config: Config,
|
|
58
|
+
input: SaveObservationInput
|
|
59
|
+
): Promise<SaveObservationResult> {
|
|
60
|
+
// Validate type
|
|
61
|
+
if (!VALID_TYPES.includes(input.type as ObservationType)) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
reason: `Invalid type '${input.type}'. Must be one of: ${VALID_TYPES.join(", ")}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Validate title
|
|
69
|
+
if (!input.title || input.title.trim().length === 0) {
|
|
70
|
+
return { success: false, reason: "Title is required" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect project from cwd
|
|
74
|
+
const cwd = input.cwd ?? process.cwd();
|
|
75
|
+
const detected = detectProject(cwd);
|
|
76
|
+
const project = db.upsertProject({
|
|
77
|
+
canonical_id: detected.canonical_id,
|
|
78
|
+
name: detected.name,
|
|
79
|
+
local_path: detected.local_path,
|
|
80
|
+
remote_url: detected.remote_url,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Scrub secrets from all text fields
|
|
84
|
+
const customPatterns = config.scrubbing.enabled
|
|
85
|
+
? config.scrubbing.custom_patterns
|
|
86
|
+
: [];
|
|
87
|
+
|
|
88
|
+
const title = config.scrubbing.enabled
|
|
89
|
+
? scrubSecrets(input.title, customPatterns)
|
|
90
|
+
: input.title;
|
|
91
|
+
|
|
92
|
+
const narrative = input.narrative
|
|
93
|
+
? config.scrubbing.enabled
|
|
94
|
+
? scrubSecrets(input.narrative, customPatterns)
|
|
95
|
+
: input.narrative
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
const factsJson = input.facts
|
|
99
|
+
? config.scrubbing.enabled
|
|
100
|
+
? scrubSecrets(JSON.stringify(input.facts), customPatterns)
|
|
101
|
+
: JSON.stringify(input.facts)
|
|
102
|
+
: null;
|
|
103
|
+
|
|
104
|
+
const conceptsJson = input.concepts
|
|
105
|
+
? JSON.stringify(input.concepts)
|
|
106
|
+
: null;
|
|
107
|
+
|
|
108
|
+
// Convert absolute paths to project-relative for cross-machine portability
|
|
109
|
+
const filesRead = input.files_read
|
|
110
|
+
? input.files_read.map((f) => toRelativePath(f, cwd))
|
|
111
|
+
: null;
|
|
112
|
+
|
|
113
|
+
const filesModified = input.files_modified
|
|
114
|
+
? input.files_modified.map((f) => toRelativePath(f, cwd))
|
|
115
|
+
: null;
|
|
116
|
+
|
|
117
|
+
const filesReadJson = filesRead ? JSON.stringify(filesRead) : null;
|
|
118
|
+
const filesModifiedJson = filesModified ? JSON.stringify(filesModified) : null;
|
|
119
|
+
|
|
120
|
+
// Determine sensitivity
|
|
121
|
+
let sensitivity = input.sensitivity ?? config.scrubbing.default_sensitivity;
|
|
122
|
+
if (
|
|
123
|
+
config.scrubbing.enabled &&
|
|
124
|
+
containsSecrets(
|
|
125
|
+
[input.title, input.narrative, JSON.stringify(input.facts)]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join(" "),
|
|
128
|
+
customPatterns
|
|
129
|
+
)
|
|
130
|
+
) {
|
|
131
|
+
// Upgrade to 'personal' if secrets detected (even after scrubbing, flag it)
|
|
132
|
+
if (sensitivity === "shared") {
|
|
133
|
+
sensitivity = "personal";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check deduplication against last 24h
|
|
138
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
139
|
+
const recentObs = db.getRecentObservations(project.id, oneDayAgo);
|
|
140
|
+
const candidates: DedupCandidate[] = recentObs.map((o) => ({
|
|
141
|
+
id: o.id,
|
|
142
|
+
title: o.title,
|
|
143
|
+
}));
|
|
144
|
+
const duplicate = findDuplicate(title, candidates);
|
|
145
|
+
|
|
146
|
+
// Score quality
|
|
147
|
+
const qualityInput = {
|
|
148
|
+
type: input.type,
|
|
149
|
+
title,
|
|
150
|
+
narrative,
|
|
151
|
+
facts: factsJson,
|
|
152
|
+
concepts: conceptsJson,
|
|
153
|
+
filesRead: filesRead,
|
|
154
|
+
filesModified: filesModified,
|
|
155
|
+
isDuplicate: duplicate !== null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const qualityScore = scoreQuality(qualityInput);
|
|
159
|
+
|
|
160
|
+
if (!meetsQualityThreshold(qualityInput)) {
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
quality_score: qualityScore,
|
|
164
|
+
reason: `Quality score ${qualityScore.toFixed(2)} below threshold`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If duplicate found, report merge (future: update existing observation)
|
|
169
|
+
if (duplicate) {
|
|
170
|
+
return {
|
|
171
|
+
success: true,
|
|
172
|
+
merged_into: duplicate.id,
|
|
173
|
+
quality_score: qualityScore,
|
|
174
|
+
reason: `Merged into existing observation #${duplicate.id}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Insert observation
|
|
179
|
+
const obs = db.insertObservation({
|
|
180
|
+
session_id: input.session_id ?? null,
|
|
181
|
+
project_id: project.id,
|
|
182
|
+
type: input.type,
|
|
183
|
+
title,
|
|
184
|
+
narrative,
|
|
185
|
+
facts: factsJson,
|
|
186
|
+
concepts: conceptsJson,
|
|
187
|
+
files_read: filesReadJson,
|
|
188
|
+
files_modified: filesModifiedJson,
|
|
189
|
+
quality: qualityScore,
|
|
190
|
+
lifecycle: "active",
|
|
191
|
+
sensitivity,
|
|
192
|
+
user_id: config.user_id,
|
|
193
|
+
device_id: config.device_id,
|
|
194
|
+
agent: input.agent ?? "claude-code",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Add to sync outbox
|
|
198
|
+
db.addToOutbox("observation", obs.id);
|
|
199
|
+
|
|
200
|
+
// Embed for local vector search (best-effort, non-blocking on failure)
|
|
201
|
+
if (db.vecAvailable) {
|
|
202
|
+
try {
|
|
203
|
+
const text = composeEmbeddingText(obs);
|
|
204
|
+
const embedding = await embedText(text);
|
|
205
|
+
if (embedding) {
|
|
206
|
+
db.vecInsert(obs.id, embedding);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Embedding failure is non-fatal — FTS5 still works
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
success: true,
|
|
215
|
+
observation_id: obs.id,
|
|
216
|
+
quality_score: qualityScore,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Convert an absolute file path to a project-relative path.
|
|
222
|
+
* Already-relative paths are returned as-is.
|
|
223
|
+
* Paths outside the project root are returned as-is (no way to make relative).
|
|
224
|
+
*/
|
|
225
|
+
function toRelativePath(filePath: string, projectRoot: string): string {
|
|
226
|
+
if (!isAbsolute(filePath)) return filePath;
|
|
227
|
+
const rel = relative(projectRoot, filePath);
|
|
228
|
+
// If relative() returns a path starting with "..", the file is outside project root
|
|
229
|
+
if (rel.startsWith("..")) return filePath;
|
|
230
|
+
return rel;
|
|
231
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeResults } from "./search.js";
|
|
3
|
+
import type { FtsMatchRow, VecMatchRow } from "../storage/sqlite.js";
|
|
4
|
+
|
|
5
|
+
describe("mergeResults (RRF)", () => {
|
|
6
|
+
test("merges FTS-only results", () => {
|
|
7
|
+
const fts: FtsMatchRow[] = [
|
|
8
|
+
{ id: 1, rank: -5.0 },
|
|
9
|
+
{ id: 2, rank: -3.0 },
|
|
10
|
+
{ id: 3, rank: -1.0 },
|
|
11
|
+
];
|
|
12
|
+
const vec: VecMatchRow[] = [];
|
|
13
|
+
|
|
14
|
+
const merged = mergeResults(fts, vec, 10);
|
|
15
|
+
expect(merged.length).toBe(3);
|
|
16
|
+
expect(merged[0]!.id).toBe(1); // rank 0 in FTS = highest RRF
|
|
17
|
+
expect(merged[1]!.id).toBe(2);
|
|
18
|
+
expect(merged[2]!.id).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("merges vec-only results", () => {
|
|
22
|
+
const fts: FtsMatchRow[] = [];
|
|
23
|
+
const vec: VecMatchRow[] = [
|
|
24
|
+
{ observation_id: 10, distance: 0.1 },
|
|
25
|
+
{ observation_id: 20, distance: 0.5 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const merged = mergeResults(fts, vec, 10);
|
|
29
|
+
expect(merged.length).toBe(2);
|
|
30
|
+
expect(merged[0]!.id).toBe(10);
|
|
31
|
+
expect(merged[1]!.id).toBe(20);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("boosts items appearing in both lists", () => {
|
|
35
|
+
const fts: FtsMatchRow[] = [
|
|
36
|
+
{ id: 1, rank: -5.0 },
|
|
37
|
+
{ id: 2, rank: -3.0 },
|
|
38
|
+
];
|
|
39
|
+
const vec: VecMatchRow[] = [
|
|
40
|
+
{ observation_id: 2, distance: 0.1 },
|
|
41
|
+
{ observation_id: 3, distance: 0.2 },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const merged = mergeResults(fts, vec, 10);
|
|
45
|
+
// ID 2 appears in both — should be boosted to top
|
|
46
|
+
expect(merged[0]!.id).toBe(2);
|
|
47
|
+
expect(merged[0]!.score).toBeGreaterThan(merged[1]!.score);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("respects limit", () => {
|
|
51
|
+
const fts: FtsMatchRow[] = [
|
|
52
|
+
{ id: 1, rank: -5 },
|
|
53
|
+
{ id: 2, rank: -4 },
|
|
54
|
+
{ id: 3, rank: -3 },
|
|
55
|
+
];
|
|
56
|
+
const vec: VecMatchRow[] = [
|
|
57
|
+
{ observation_id: 4, distance: 0.1 },
|
|
58
|
+
{ observation_id: 5, distance: 0.2 },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const merged = mergeResults(fts, vec, 3);
|
|
62
|
+
expect(merged.length).toBe(3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("handles empty inputs", () => {
|
|
66
|
+
const merged = mergeResults([], [], 10);
|
|
67
|
+
expect(merged.length).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_observations MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* Hybrid search: FTS5 (keyword) + sqlite-vec (semantic),
|
|
5
|
+
* merged via Reciprocal Rank Fusion. Falls back to FTS5-only
|
|
6
|
+
* when embeddings or sqlite-vec are unavailable.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { detectProject } from "../storage/projects.js";
|
|
10
|
+
import type {
|
|
11
|
+
MemDatabase,
|
|
12
|
+
FtsMatchRow,
|
|
13
|
+
VecMatchRow,
|
|
14
|
+
} from "../storage/sqlite.js";
|
|
15
|
+
import { embedText } from "../embeddings/embedder.js";
|
|
16
|
+
|
|
17
|
+
export interface SearchInput {
|
|
18
|
+
query: string;
|
|
19
|
+
project_scoped?: boolean;
|
|
20
|
+
limit?: number;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchResult {
|
|
25
|
+
observations: SearchResultEntry[];
|
|
26
|
+
total: number;
|
|
27
|
+
project?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchResultEntry {
|
|
31
|
+
id: number;
|
|
32
|
+
type: string;
|
|
33
|
+
title: string;
|
|
34
|
+
narrative: string | null;
|
|
35
|
+
facts: string | null;
|
|
36
|
+
concepts: string | null;
|
|
37
|
+
files_modified: string | null;
|
|
38
|
+
quality: number;
|
|
39
|
+
lifecycle: string;
|
|
40
|
+
created_at: string;
|
|
41
|
+
rank: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hybrid search: FTS5 keywords + sqlite-vec semantic, merged via RRF.
|
|
46
|
+
*/
|
|
47
|
+
export async function searchObservations(
|
|
48
|
+
db: MemDatabase,
|
|
49
|
+
input: SearchInput
|
|
50
|
+
): Promise<SearchResult> {
|
|
51
|
+
const query = input.query.trim();
|
|
52
|
+
if (!query) {
|
|
53
|
+
return { observations: [], total: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const limit = input.limit ?? 10;
|
|
57
|
+
const projectScoped = input.project_scoped !== false;
|
|
58
|
+
|
|
59
|
+
let projectId: number | null = null;
|
|
60
|
+
let projectName: string | undefined;
|
|
61
|
+
|
|
62
|
+
if (projectScoped) {
|
|
63
|
+
const cwd = input.cwd ?? process.cwd();
|
|
64
|
+
const detected = detectProject(cwd);
|
|
65
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
66
|
+
if (project) {
|
|
67
|
+
projectId = project.id;
|
|
68
|
+
projectName = project.name;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// FTS5 keyword search
|
|
73
|
+
const safeQuery = sanitizeFtsQuery(query);
|
|
74
|
+
const ftsResults = safeQuery
|
|
75
|
+
? db.searchFts(safeQuery, projectId, undefined, limit * 2)
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
// Vec semantic search (if available)
|
|
79
|
+
let vecResults: VecMatchRow[] = [];
|
|
80
|
+
const queryEmbedding = await embedText(query);
|
|
81
|
+
if (queryEmbedding && db.vecAvailable) {
|
|
82
|
+
vecResults = db.searchVec(
|
|
83
|
+
queryEmbedding,
|
|
84
|
+
projectId,
|
|
85
|
+
["active", "aging", "pinned"],
|
|
86
|
+
limit * 2
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Merge via RRF if we have both, otherwise use whichever is available
|
|
91
|
+
const merged = mergeResults(ftsResults, vecResults, limit);
|
|
92
|
+
|
|
93
|
+
if (merged.length === 0) {
|
|
94
|
+
return { observations: [], total: 0, project: projectName };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ids = merged.map((r) => r.id);
|
|
98
|
+
const scoreMap = new Map(merged.map((r) => [r.id, r.score]));
|
|
99
|
+
const observations = db.getObservationsByIds(ids);
|
|
100
|
+
|
|
101
|
+
// Filter out superseded observations
|
|
102
|
+
const active = observations.filter((obs) => obs.superseded_by === null);
|
|
103
|
+
|
|
104
|
+
// Apply lifecycle weighting
|
|
105
|
+
const entries: SearchResultEntry[] = active.map((obs) => {
|
|
106
|
+
const baseScore = scoreMap.get(obs.id) ?? 0;
|
|
107
|
+
const lifecycleWeight = obs.lifecycle === "aging" ? 0.7 : 1.0;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
id: obs.id,
|
|
111
|
+
type: obs.type,
|
|
112
|
+
title: obs.title,
|
|
113
|
+
narrative: obs.narrative,
|
|
114
|
+
facts: obs.facts,
|
|
115
|
+
concepts: obs.concepts,
|
|
116
|
+
files_modified: obs.files_modified,
|
|
117
|
+
quality: obs.quality,
|
|
118
|
+
lifecycle: obs.lifecycle,
|
|
119
|
+
created_at: obs.created_at,
|
|
120
|
+
rank: baseScore * lifecycleWeight,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Sort by score (higher = better match)
|
|
125
|
+
entries.sort((a, b) => b.rank - a.rank);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
observations: entries,
|
|
129
|
+
total: entries.length,
|
|
130
|
+
project: projectName,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Reciprocal Rank Fusion ---
|
|
135
|
+
|
|
136
|
+
const RRF_K = 60;
|
|
137
|
+
|
|
138
|
+
interface ScoredId {
|
|
139
|
+
id: number;
|
|
140
|
+
score: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Merge FTS5 and vec results using Reciprocal Rank Fusion.
|
|
145
|
+
* RRF is rank-based, so no score normalization needed.
|
|
146
|
+
* Items appearing in both lists get boosted.
|
|
147
|
+
*/
|
|
148
|
+
export function mergeResults(
|
|
149
|
+
ftsResults: FtsMatchRow[],
|
|
150
|
+
vecResults: VecMatchRow[],
|
|
151
|
+
limit: number
|
|
152
|
+
): ScoredId[] {
|
|
153
|
+
const scores = new Map<number, number>();
|
|
154
|
+
|
|
155
|
+
// FTS results sorted by rank (more negative = better match)
|
|
156
|
+
for (let rank = 0; rank < ftsResults.length; rank++) {
|
|
157
|
+
const id = ftsResults[rank]!.id;
|
|
158
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Vec results sorted by distance (lower = closer)
|
|
162
|
+
for (let rank = 0; rank < vecResults.length; rank++) {
|
|
163
|
+
const id = vecResults[rank]!.observation_id;
|
|
164
|
+
scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Array.from(scores.entries())
|
|
168
|
+
.map(([id, score]) => ({ id, score }))
|
|
169
|
+
.sort((a, b) => b.score - a.score)
|
|
170
|
+
.slice(0, limit);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sanitize a query for FTS5.
|
|
175
|
+
*/
|
|
176
|
+
function sanitizeFtsQuery(query: string): string {
|
|
177
|
+
let safe = query.replace(/[{}()[\]^~*:]/g, " ");
|
|
178
|
+
safe = safe.replace(/\s+/g, " ").trim();
|
|
179
|
+
if (!safe) return "";
|
|
180
|
+
return safe;
|
|
181
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* get_timeline MCP tool.
|
|
3
|
+
*
|
|
4
|
+
* Returns chronological observations around an anchor point,
|
|
5
|
+
* providing temporal context for understanding sequences of events.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { detectProject } from "../storage/projects.js";
|
|
9
|
+
import type { MemDatabase, ObservationRow } from "../storage/sqlite.js";
|
|
10
|
+
|
|
11
|
+
export interface TimelineInput {
|
|
12
|
+
anchor_id: number;
|
|
13
|
+
depth_before?: number;
|
|
14
|
+
depth_after?: number;
|
|
15
|
+
project_scoped?: boolean;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TimelineResult {
|
|
20
|
+
observations: ObservationRow[];
|
|
21
|
+
anchor_index: number;
|
|
22
|
+
project?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get a timeline of observations around an anchor.
|
|
27
|
+
*/
|
|
28
|
+
export function getTimeline(
|
|
29
|
+
db: MemDatabase,
|
|
30
|
+
input: TimelineInput
|
|
31
|
+
): TimelineResult {
|
|
32
|
+
const depthBefore = input.depth_before ?? 3;
|
|
33
|
+
const depthAfter = input.depth_after ?? 3;
|
|
34
|
+
const projectScoped = input.project_scoped !== false;
|
|
35
|
+
|
|
36
|
+
let projectId: number | null = null;
|
|
37
|
+
let projectName: string | undefined;
|
|
38
|
+
|
|
39
|
+
if (projectScoped) {
|
|
40
|
+
const cwd = input.cwd ?? process.cwd();
|
|
41
|
+
const detected = detectProject(cwd);
|
|
42
|
+
const project = db.getProjectByCanonicalId(detected.canonical_id);
|
|
43
|
+
if (project) {
|
|
44
|
+
projectId = project.id;
|
|
45
|
+
projectName = project.name;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const observations = db.getTimeline(
|
|
50
|
+
input.anchor_id,
|
|
51
|
+
projectId,
|
|
52
|
+
depthBefore,
|
|
53
|
+
depthAfter
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Find the anchor's position in the result
|
|
57
|
+
const anchorIndex = observations.findIndex((o) => o.id === input.anchor_id);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
observations,
|
|
61
|
+
anchor_index: anchorIndex >= 0 ? anchorIndex : 0,
|
|
62
|
+
project: projectName,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"types": ["bun-types"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"outDir": "dist",
|
|
17
|
+
"rootDir": "src",
|
|
18
|
+
"lib": ["ESNext"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|