claude-memory-layer 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/.claude-plugin/commands/memory-forget.md +42 -0
- package/.claude-plugin/commands/memory-history.md +34 -0
- package/.claude-plugin/commands/memory-import.md +56 -0
- package/.claude-plugin/commands/memory-list.md +37 -0
- package/.claude-plugin/commands/memory-search.md +36 -0
- package/.claude-plugin/commands/memory-stats.md +34 -0
- package/.claude-plugin/hooks.json +59 -0
- package/.claude-plugin/plugin.json +24 -0
- package/.history/package_20260201112328.json +45 -0
- package/.history/package_20260201113602.json +45 -0
- package/.history/package_20260201113713.json +45 -0
- package/.history/package_20260201114110.json +45 -0
- package/Memo.txt +558 -0
- package/README.md +520 -0
- package/context.md +636 -0
- package/dist/.claude-plugin/commands/memory-forget.md +42 -0
- package/dist/.claude-plugin/commands/memory-history.md +34 -0
- package/dist/.claude-plugin/commands/memory-import.md +56 -0
- package/dist/.claude-plugin/commands/memory-list.md +37 -0
- package/dist/.claude-plugin/commands/memory-search.md +36 -0
- package/dist/.claude-plugin/commands/memory-stats.md +34 -0
- package/dist/.claude-plugin/hooks.json +59 -0
- package/dist/.claude-plugin/plugin.json +24 -0
- package/dist/cli/index.js +3539 -0
- package/dist/cli/index.js.map +7 -0
- package/dist/core/index.js +4408 -0
- package/dist/core/index.js.map +7 -0
- package/dist/hooks/session-end.js +2971 -0
- package/dist/hooks/session-end.js.map +7 -0
- package/dist/hooks/session-start.js +2969 -0
- package/dist/hooks/session-start.js.map +7 -0
- package/dist/hooks/stop.js +3123 -0
- package/dist/hooks/stop.js.map +7 -0
- package/dist/hooks/user-prompt-submit.js +2960 -0
- package/dist/hooks/user-prompt-submit.js.map +7 -0
- package/dist/services/memory-service.js +2931 -0
- package/dist/services/memory-service.js.map +7 -0
- package/package.json +45 -0
- package/plan.md +1642 -0
- package/scripts/build.ts +102 -0
- package/spec.md +624 -0
- package/specs/citations-system/context.md +243 -0
- package/specs/citations-system/plan.md +495 -0
- package/specs/citations-system/spec.md +371 -0
- package/specs/endless-mode/context.md +305 -0
- package/specs/endless-mode/plan.md +620 -0
- package/specs/endless-mode/spec.md +455 -0
- package/specs/entity-edge-model/context.md +401 -0
- package/specs/entity-edge-model/plan.md +459 -0
- package/specs/entity-edge-model/spec.md +391 -0
- package/specs/evidence-aligner-v2/context.md +401 -0
- package/specs/evidence-aligner-v2/plan.md +303 -0
- package/specs/evidence-aligner-v2/spec.md +312 -0
- package/specs/mcp-desktop-integration/context.md +278 -0
- package/specs/mcp-desktop-integration/plan.md +550 -0
- package/specs/mcp-desktop-integration/spec.md +494 -0
- package/specs/post-tool-use-hook/context.md +319 -0
- package/specs/post-tool-use-hook/plan.md +469 -0
- package/specs/post-tool-use-hook/spec.md +364 -0
- package/specs/private-tags/context.md +288 -0
- package/specs/private-tags/plan.md +412 -0
- package/specs/private-tags/spec.md +345 -0
- package/specs/progressive-disclosure/context.md +346 -0
- package/specs/progressive-disclosure/plan.md +663 -0
- package/specs/progressive-disclosure/spec.md +415 -0
- package/specs/task-entity-system/context.md +297 -0
- package/specs/task-entity-system/plan.md +301 -0
- package/specs/task-entity-system/spec.md +314 -0
- package/specs/vector-outbox-v2/context.md +470 -0
- package/specs/vector-outbox-v2/plan.md +562 -0
- package/specs/vector-outbox-v2/spec.md +466 -0
- package/specs/web-viewer-ui/context.md +384 -0
- package/specs/web-viewer-ui/plan.md +797 -0
- package/specs/web-viewer-ui/spec.md +516 -0
- package/src/cli/index.ts +570 -0
- package/src/core/canonical-key.ts +186 -0
- package/src/core/citation-generator.ts +63 -0
- package/src/core/consolidated-store.ts +279 -0
- package/src/core/consolidation-worker.ts +384 -0
- package/src/core/context-formatter.ts +276 -0
- package/src/core/continuity-manager.ts +336 -0
- package/src/core/edge-repo.ts +324 -0
- package/src/core/embedder.ts +124 -0
- package/src/core/entity-repo.ts +342 -0
- package/src/core/event-store.ts +672 -0
- package/src/core/evidence-aligner.ts +635 -0
- package/src/core/graduation.ts +365 -0
- package/src/core/index.ts +32 -0
- package/src/core/matcher.ts +210 -0
- package/src/core/metadata-extractor.ts +203 -0
- package/src/core/privacy/filter.ts +179 -0
- package/src/core/privacy/index.ts +20 -0
- package/src/core/privacy/tag-parser.ts +145 -0
- package/src/core/progressive-retriever.ts +415 -0
- package/src/core/retriever.ts +235 -0
- package/src/core/task/blocker-resolver.ts +325 -0
- package/src/core/task/index.ts +9 -0
- package/src/core/task/task-matcher.ts +238 -0
- package/src/core/task/task-projector.ts +345 -0
- package/src/core/task/task-resolver.ts +414 -0
- package/src/core/types.ts +841 -0
- package/src/core/vector-outbox.ts +295 -0
- package/src/core/vector-store.ts +182 -0
- package/src/core/vector-worker.ts +488 -0
- package/src/core/working-set-store.ts +244 -0
- package/src/hooks/post-tool-use.ts +127 -0
- package/src/hooks/session-end.ts +78 -0
- package/src/hooks/session-start.ts +57 -0
- package/src/hooks/stop.ts +78 -0
- package/src/hooks/user-prompt-submit.ts +54 -0
- package/src/mcp/handlers.ts +212 -0
- package/src/mcp/index.ts +47 -0
- package/src/mcp/tools.ts +78 -0
- package/src/server/api/citations.ts +101 -0
- package/src/server/api/events.ts +101 -0
- package/src/server/api/index.ts +18 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/sessions.ts +111 -0
- package/src/server/api/stats.ts +97 -0
- package/src/server/index.ts +91 -0
- package/src/services/memory-service.ts +626 -0
- package/src/services/session-history-importer.ts +367 -0
- package/tests/canonical-key.test.ts +101 -0
- package/tests/evidence-aligner.test.ts +152 -0
- package/tests/matcher.test.ts +112 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocker Resolver - Resolve blocker texts to entity references
|
|
3
|
+
* AXIOMMIND: No stub task creation, fallback to condition
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Database } from 'duckdb';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import type { BlockerRef, BlockerKind, Entity } from '../types.js';
|
|
9
|
+
import { makeEntityCanonicalKey, makeArtifactKey } from '../canonical-key.js';
|
|
10
|
+
import { TaskMatcher } from './task-matcher.js';
|
|
11
|
+
|
|
12
|
+
export interface BlockerResolverConfig {
|
|
13
|
+
project?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Patterns for artifact detection
|
|
17
|
+
const URL_PATTERN = /^https?:\/\/.+/;
|
|
18
|
+
const JIRA_PATTERN = /^[A-Z]+-\d+$/;
|
|
19
|
+
const GITHUB_ISSUE_PATTERN = /^[^\/]+\/[^#]+#\d+$/;
|
|
20
|
+
const TASK_ID_PATTERN = /^task:[^:]+:[^:]+$/;
|
|
21
|
+
|
|
22
|
+
export class BlockerResolver {
|
|
23
|
+
private taskMatcher: TaskMatcher;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private db: Database,
|
|
27
|
+
private config: BlockerResolverConfig = {}
|
|
28
|
+
) {
|
|
29
|
+
this.taskMatcher = new TaskMatcher(db);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a single blocker text to entity reference
|
|
34
|
+
* Rules:
|
|
35
|
+
* 1. Strong ID/URL/key pattern → artifact
|
|
36
|
+
* 2. Explicit task_id → task
|
|
37
|
+
* 3. Task title match (strict only) → task
|
|
38
|
+
* 4. Fallback → condition (no stub task creation)
|
|
39
|
+
*/
|
|
40
|
+
async resolveBlocker(
|
|
41
|
+
text: string,
|
|
42
|
+
sourceEntryId?: string
|
|
43
|
+
): Promise<BlockerRef> {
|
|
44
|
+
const trimmedText = text.trim();
|
|
45
|
+
|
|
46
|
+
// Rule 1: Check for artifact patterns
|
|
47
|
+
const artifactRef = await this.tryResolveAsArtifact(trimmedText);
|
|
48
|
+
if (artifactRef) {
|
|
49
|
+
return artifactRef;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Rule 2: Check for explicit task_id pattern
|
|
53
|
+
if (TASK_ID_PATTERN.test(trimmedText)) {
|
|
54
|
+
const taskRef = await this.tryResolveAsTaskId(trimmedText);
|
|
55
|
+
if (taskRef) {
|
|
56
|
+
return taskRef;
|
|
57
|
+
}
|
|
58
|
+
// Task ID not found, fall through to condition
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Rule 3: Try task title matching (strict only)
|
|
62
|
+
const taskMatch = await this.taskMatcher.match(trimmedText, this.config.project);
|
|
63
|
+
|
|
64
|
+
if (taskMatch.confidence === 'high' && taskMatch.match) {
|
|
65
|
+
// Strict match found
|
|
66
|
+
return {
|
|
67
|
+
kind: 'task',
|
|
68
|
+
entityId: taskMatch.match.entityId,
|
|
69
|
+
rawText: trimmedText,
|
|
70
|
+
confidence: taskMatch.score
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Rule 4: Fallback to condition (get-or-create)
|
|
75
|
+
// Also store candidates if any
|
|
76
|
+
const conditionRef = await this.createConditionBlocker(
|
|
77
|
+
trimmedText,
|
|
78
|
+
taskMatch.candidates
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return conditionRef;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve multiple blocker texts
|
|
86
|
+
*/
|
|
87
|
+
async resolveBlockers(
|
|
88
|
+
texts: string[],
|
|
89
|
+
sourceEntryId?: string
|
|
90
|
+
): Promise<BlockerRef[]> {
|
|
91
|
+
const results: BlockerRef[] = [];
|
|
92
|
+
|
|
93
|
+
for (const text of texts) {
|
|
94
|
+
const ref = await this.resolveBlocker(text, sourceEntryId);
|
|
95
|
+
results.push(ref);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Try to resolve as artifact (URL, JIRA, GitHub)
|
|
103
|
+
*/
|
|
104
|
+
private async tryResolveAsArtifact(text: string): Promise<BlockerRef | null> {
|
|
105
|
+
// Check patterns
|
|
106
|
+
if (!URL_PATTERN.test(text) && !JIRA_PATTERN.test(text) && !GITHUB_ISSUE_PATTERN.test(text)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const canonicalKey = makeArtifactKey(text);
|
|
111
|
+
|
|
112
|
+
// Find or create artifact
|
|
113
|
+
const existing = await this.db.all<Array<Record<string, unknown>>>(
|
|
114
|
+
`SELECT entity_id FROM entities
|
|
115
|
+
WHERE entity_type = 'artifact' AND canonical_key = ?`,
|
|
116
|
+
[canonicalKey]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
let entityId: string;
|
|
120
|
+
|
|
121
|
+
if (existing.length > 0) {
|
|
122
|
+
entityId = existing[0].entity_id as string;
|
|
123
|
+
} else {
|
|
124
|
+
// Create artifact entity via event
|
|
125
|
+
entityId = await this.declareArtifact(text, canonicalKey);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
kind: 'artifact',
|
|
130
|
+
entityId,
|
|
131
|
+
rawText: text,
|
|
132
|
+
confidence: 1.0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Try to resolve as explicit task ID
|
|
138
|
+
*/
|
|
139
|
+
private async tryResolveAsTaskId(taskId: string): Promise<BlockerRef | null> {
|
|
140
|
+
// taskId format: task:project:identifier
|
|
141
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
142
|
+
`SELECT entity_id FROM entities
|
|
143
|
+
WHERE entity_type = 'task' AND canonical_key = ?
|
|
144
|
+
AND status = 'active'`,
|
|
145
|
+
[taskId]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (rows.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
kind: 'task',
|
|
154
|
+
entityId: rows[0].entity_id as string,
|
|
155
|
+
rawText: taskId,
|
|
156
|
+
confidence: 1.0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create condition blocker (get-or-create)
|
|
162
|
+
*/
|
|
163
|
+
private async createConditionBlocker(
|
|
164
|
+
text: string,
|
|
165
|
+
candidates?: Entity[]
|
|
166
|
+
): Promise<BlockerRef> {
|
|
167
|
+
const canonicalKey = makeEntityCanonicalKey('condition', text, {
|
|
168
|
+
project: this.config.project
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Find existing condition
|
|
172
|
+
const existing = await this.db.all<Array<Record<string, unknown>>>(
|
|
173
|
+
`SELECT entity_id FROM entities
|
|
174
|
+
WHERE entity_type = 'condition' AND canonical_key = ?`,
|
|
175
|
+
[canonicalKey]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
let entityId: string;
|
|
179
|
+
|
|
180
|
+
if (existing.length > 0) {
|
|
181
|
+
entityId = existing[0].entity_id as string;
|
|
182
|
+
} else {
|
|
183
|
+
// Create condition entity via event
|
|
184
|
+
entityId = await this.declareCondition(text, canonicalKey, candidates);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
kind: 'condition',
|
|
189
|
+
entityId,
|
|
190
|
+
rawText: text,
|
|
191
|
+
confidence: 0.5,
|
|
192
|
+
candidates: candidates?.map(c => c.entityId)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Declare a new condition entity
|
|
198
|
+
*/
|
|
199
|
+
private async declareCondition(
|
|
200
|
+
text: string,
|
|
201
|
+
canonicalKey: string,
|
|
202
|
+
candidates?: Entity[]
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
const entityId = randomUUID();
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
|
|
207
|
+
const currentJson = {
|
|
208
|
+
text,
|
|
209
|
+
resolved: false,
|
|
210
|
+
candidates: candidates?.map(c => ({
|
|
211
|
+
entityId: c.entityId,
|
|
212
|
+
title: c.title
|
|
213
|
+
}))
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
await this.db.run(
|
|
217
|
+
`INSERT INTO entities (
|
|
218
|
+
entity_id, entity_type, canonical_key, title, stage, status,
|
|
219
|
+
current_json, title_norm, search_text, created_at, updated_at
|
|
220
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
221
|
+
[
|
|
222
|
+
entityId,
|
|
223
|
+
'condition',
|
|
224
|
+
canonicalKey,
|
|
225
|
+
text,
|
|
226
|
+
'raw',
|
|
227
|
+
'active',
|
|
228
|
+
JSON.stringify(currentJson),
|
|
229
|
+
text.toLowerCase().trim(),
|
|
230
|
+
text,
|
|
231
|
+
now,
|
|
232
|
+
now
|
|
233
|
+
]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Create alias
|
|
237
|
+
await this.db.run(
|
|
238
|
+
`INSERT INTO entity_aliases (entity_type, canonical_key, entity_id, is_primary)
|
|
239
|
+
VALUES (?, ?, ?, TRUE)
|
|
240
|
+
ON CONFLICT (entity_type, canonical_key) DO NOTHING`,
|
|
241
|
+
['condition', canonicalKey, entityId]
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return entityId;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Declare a new artifact entity
|
|
249
|
+
*/
|
|
250
|
+
private async declareArtifact(
|
|
251
|
+
identifier: string,
|
|
252
|
+
canonicalKey: string
|
|
253
|
+
): Promise<string> {
|
|
254
|
+
const entityId = randomUUID();
|
|
255
|
+
const now = new Date().toISOString();
|
|
256
|
+
|
|
257
|
+
// Determine artifact type
|
|
258
|
+
let artifactType = 'generic';
|
|
259
|
+
if (URL_PATTERN.test(identifier)) {
|
|
260
|
+
artifactType = 'url';
|
|
261
|
+
} else if (JIRA_PATTERN.test(identifier)) {
|
|
262
|
+
artifactType = 'jira';
|
|
263
|
+
} else if (GITHUB_ISSUE_PATTERN.test(identifier)) {
|
|
264
|
+
artifactType = 'github_issue';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const currentJson = {
|
|
268
|
+
identifier,
|
|
269
|
+
artifactType
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
await this.db.run(
|
|
273
|
+
`INSERT INTO entities (
|
|
274
|
+
entity_id, entity_type, canonical_key, title, stage, status,
|
|
275
|
+
current_json, title_norm, search_text, created_at, updated_at
|
|
276
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
277
|
+
[
|
|
278
|
+
entityId,
|
|
279
|
+
'artifact',
|
|
280
|
+
canonicalKey,
|
|
281
|
+
identifier,
|
|
282
|
+
'raw',
|
|
283
|
+
'active',
|
|
284
|
+
JSON.stringify(currentJson),
|
|
285
|
+
identifier.toLowerCase(),
|
|
286
|
+
identifier,
|
|
287
|
+
now,
|
|
288
|
+
now
|
|
289
|
+
]
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Create alias
|
|
293
|
+
await this.db.run(
|
|
294
|
+
`INSERT INTO entity_aliases (entity_type, canonical_key, entity_id, is_primary)
|
|
295
|
+
VALUES (?, ?, ?, TRUE)
|
|
296
|
+
ON CONFLICT (entity_type, canonical_key) DO NOTHING`,
|
|
297
|
+
['artifact', canonicalKey, entityId]
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
return entityId;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create unknown placeholder condition
|
|
305
|
+
* Used when task is blocked but no blocker text provided
|
|
306
|
+
*/
|
|
307
|
+
async createUnknownPlaceholder(taskTitle: string): Promise<BlockerRef> {
|
|
308
|
+
const text = `Unknown blocker for: ${taskTitle}`;
|
|
309
|
+
|
|
310
|
+
const ref = await this.createConditionBlocker(text);
|
|
311
|
+
|
|
312
|
+
// Mark as auto placeholder
|
|
313
|
+
await this.db.run(
|
|
314
|
+
`UPDATE entities
|
|
315
|
+
SET current_json = json_set(current_json, '$.auto_placeholder', true)
|
|
316
|
+
WHERE entity_id = ?`,
|
|
317
|
+
[ref.entityId]
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
...ref,
|
|
322
|
+
confidence: 0.0
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Matcher - Find existing tasks by title similarity
|
|
3
|
+
* AXIOMMIND: strict matching (≥0.92, gap≥0.03)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Database } from 'duckdb';
|
|
7
|
+
import type { Entity, MatchConfidence } from '../types.js';
|
|
8
|
+
import { makeEntityCanonicalKey } from '../canonical-key.js';
|
|
9
|
+
import { MATCH_THRESHOLDS } from '../types.js';
|
|
10
|
+
|
|
11
|
+
export interface TaskMatchResult {
|
|
12
|
+
match: Entity | null;
|
|
13
|
+
confidence: MatchConfidence;
|
|
14
|
+
score: number;
|
|
15
|
+
gap?: number;
|
|
16
|
+
candidates?: Entity[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskMatcherConfig {
|
|
20
|
+
minCombinedScore: number;
|
|
21
|
+
minGap: number;
|
|
22
|
+
suggestionThreshold: number;
|
|
23
|
+
maxCandidates: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONFIG: TaskMatcherConfig = {
|
|
27
|
+
minCombinedScore: MATCH_THRESHOLDS.minCombinedScore,
|
|
28
|
+
minGap: MATCH_THRESHOLDS.minGap,
|
|
29
|
+
suggestionThreshold: MATCH_THRESHOLDS.suggestionThreshold,
|
|
30
|
+
maxCandidates: 5
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class TaskMatcher {
|
|
34
|
+
private readonly config: TaskMatcherConfig;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private db: Database,
|
|
38
|
+
config?: Partial<TaskMatcherConfig>
|
|
39
|
+
) {
|
|
40
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Find task by exact canonical key match
|
|
45
|
+
*/
|
|
46
|
+
async findExact(title: string, project?: string): Promise<Entity | null> {
|
|
47
|
+
const canonicalKey = makeEntityCanonicalKey('task', title, { project });
|
|
48
|
+
|
|
49
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
50
|
+
`SELECT * FROM entities
|
|
51
|
+
WHERE entity_type = 'task' AND canonical_key = ?
|
|
52
|
+
AND status = 'active'`,
|
|
53
|
+
[canonicalKey]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (rows.length === 0) return null;
|
|
57
|
+
return this.rowToEntity(rows[0]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find task by alias
|
|
62
|
+
*/
|
|
63
|
+
async findByAlias(title: string, project?: string): Promise<Entity | null> {
|
|
64
|
+
const canonicalKey = makeEntityCanonicalKey('task', title, { project });
|
|
65
|
+
|
|
66
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(
|
|
67
|
+
`SELECT e.* FROM entities e
|
|
68
|
+
JOIN entity_aliases a ON e.entity_id = a.entity_id
|
|
69
|
+
WHERE a.entity_type = 'task' AND a.canonical_key = ?
|
|
70
|
+
AND e.status = 'active'`,
|
|
71
|
+
[canonicalKey]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (rows.length === 0) return null;
|
|
75
|
+
return this.rowToEntity(rows[0]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Search tasks by text (FTS-like)
|
|
80
|
+
*/
|
|
81
|
+
async searchByText(query: string, project?: string): Promise<Array<{ entity: Entity; score: number }>> {
|
|
82
|
+
const searchPattern = `%${query.toLowerCase()}%`;
|
|
83
|
+
|
|
84
|
+
let sql = `
|
|
85
|
+
SELECT *,
|
|
86
|
+
CASE
|
|
87
|
+
WHEN title_norm = ? THEN 1.0
|
|
88
|
+
WHEN title_norm LIKE ? THEN 0.9
|
|
89
|
+
ELSE 0.7
|
|
90
|
+
END as match_score
|
|
91
|
+
FROM entities
|
|
92
|
+
WHERE entity_type = 'task'
|
|
93
|
+
AND status = 'active'
|
|
94
|
+
AND (title_norm LIKE ? OR search_text LIKE ?)
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
const normalizedQuery = query.toLowerCase().trim();
|
|
98
|
+
const params: unknown[] = [normalizedQuery, `%${normalizedQuery}%`, searchPattern, searchPattern];
|
|
99
|
+
|
|
100
|
+
if (project) {
|
|
101
|
+
sql += ` AND json_extract(current_json, '$.project') = ?`;
|
|
102
|
+
params.push(project);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sql += ` ORDER BY match_score DESC, updated_at DESC LIMIT ?`;
|
|
106
|
+
params.push(this.config.maxCandidates);
|
|
107
|
+
|
|
108
|
+
const rows = await this.db.all<Array<Record<string, unknown>>>(sql, params);
|
|
109
|
+
|
|
110
|
+
return rows.map(row => ({
|
|
111
|
+
entity: this.rowToEntity(row),
|
|
112
|
+
score: row.match_score as number
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Match task with confidence classification
|
|
118
|
+
* Returns high confidence only if score ≥ 0.92 AND gap ≥ 0.03
|
|
119
|
+
*/
|
|
120
|
+
async match(title: string, project?: string): Promise<TaskMatchResult> {
|
|
121
|
+
// Step 1: Try exact match
|
|
122
|
+
const exactMatch = await this.findExact(title, project);
|
|
123
|
+
if (exactMatch) {
|
|
124
|
+
return {
|
|
125
|
+
match: exactMatch,
|
|
126
|
+
confidence: 'high',
|
|
127
|
+
score: 1.0
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Step 2: Try alias match
|
|
132
|
+
const aliasMatch = await this.findByAlias(title, project);
|
|
133
|
+
if (aliasMatch) {
|
|
134
|
+
return {
|
|
135
|
+
match: aliasMatch,
|
|
136
|
+
confidence: 'high',
|
|
137
|
+
score: 0.98
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Step 3: Try text search
|
|
142
|
+
const searchResults = await this.searchByText(title, project);
|
|
143
|
+
if (searchResults.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
match: null,
|
|
146
|
+
confidence: 'none',
|
|
147
|
+
score: 0
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const topResult = searchResults[0];
|
|
152
|
+
const secondScore = searchResults.length > 1 ? searchResults[1].score : null;
|
|
153
|
+
|
|
154
|
+
// Calculate gap
|
|
155
|
+
const gap = secondScore !== null ? topResult.score - secondScore : Infinity;
|
|
156
|
+
|
|
157
|
+
// Classify confidence
|
|
158
|
+
const confidence = this.classifyConfidence(topResult.score, gap);
|
|
159
|
+
|
|
160
|
+
// For strict matching, only return high confidence if criteria met
|
|
161
|
+
if (confidence === 'high') {
|
|
162
|
+
return {
|
|
163
|
+
match: topResult.entity,
|
|
164
|
+
confidence: 'high',
|
|
165
|
+
score: topResult.score,
|
|
166
|
+
gap
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// For suggested, return candidates
|
|
171
|
+
if (confidence === 'suggested') {
|
|
172
|
+
return {
|
|
173
|
+
match: null,
|
|
174
|
+
confidence: 'suggested',
|
|
175
|
+
score: topResult.score,
|
|
176
|
+
gap,
|
|
177
|
+
candidates: searchResults.slice(0, this.config.maxCandidates).map(r => r.entity)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
match: null,
|
|
183
|
+
confidence: 'none',
|
|
184
|
+
score: topResult.score
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Classify confidence based on AXIOMMIND thresholds
|
|
190
|
+
*/
|
|
191
|
+
private classifyConfidence(score: number, gap: number): MatchConfidence {
|
|
192
|
+
const { minCombinedScore, minGap, suggestionThreshold } = this.config;
|
|
193
|
+
|
|
194
|
+
// High confidence: score ≥ 0.92 AND gap ≥ 0.03
|
|
195
|
+
if (score >= minCombinedScore && gap >= minGap) {
|
|
196
|
+
return 'high';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Suggested: score ≥ 0.75
|
|
200
|
+
if (score >= suggestionThreshold) {
|
|
201
|
+
return 'suggested';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return 'none';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get suggestion candidates (for condition fallback)
|
|
209
|
+
*/
|
|
210
|
+
async getSuggestionCandidates(title: string, project?: string): Promise<Entity[]> {
|
|
211
|
+
const searchResults = await this.searchByText(title, project);
|
|
212
|
+
return searchResults
|
|
213
|
+
.filter(r => r.score >= this.config.suggestionThreshold)
|
|
214
|
+
.slice(0, this.config.maxCandidates)
|
|
215
|
+
.map(r => r.entity);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convert database row to Entity
|
|
220
|
+
*/
|
|
221
|
+
private rowToEntity(row: Record<string, unknown>): Entity {
|
|
222
|
+
return {
|
|
223
|
+
entityId: row.entity_id as string,
|
|
224
|
+
entityType: row.entity_type as 'task' | 'condition' | 'artifact',
|
|
225
|
+
canonicalKey: row.canonical_key as string,
|
|
226
|
+
title: row.title as string,
|
|
227
|
+
stage: row.stage as 'raw' | 'working' | 'candidate' | 'verified' | 'certified',
|
|
228
|
+
status: row.status as 'active' | 'contested' | 'deprecated' | 'superseded',
|
|
229
|
+
currentJson: typeof row.current_json === 'string'
|
|
230
|
+
? JSON.parse(row.current_json)
|
|
231
|
+
: row.current_json as Record<string, unknown>,
|
|
232
|
+
titleNorm: row.title_norm as string | undefined,
|
|
233
|
+
searchText: row.search_text as string | undefined,
|
|
234
|
+
createdAt: new Date(row.created_at as string),
|
|
235
|
+
updatedAt: new Date(row.updated_at as string)
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|