linksee-memory 0.3.0 → 0.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/README.md +48 -28
- package/dist/bin/import-sessions.js +10 -4
- package/dist/bin/setup.d.ts +2 -0
- package/dist/bin/setup.js +220 -0
- package/dist/db/migrate.js +98 -0
- package/dist/db/schema.sql +6 -4
- package/dist/lib/normalize.d.ts +25 -0
- package/dist/lib/normalize.js +43 -0
- package/dist/lib/session-extractor.js +164 -38
- package/dist/mcp/server.js +23 -4
- package/dist/skill/SKILL.md +276 -19
- package/package.json +7 -4
|
@@ -3,6 +3,54 @@
|
|
|
3
3
|
// to its intent context. A memory like "edited server.ts" becomes
|
|
4
4
|
// "edited server.ts BECAUSE the user wanted the FTS5 + LIKE merge fix".
|
|
5
5
|
import { isMetaOrNoise, isAutomatedSession, isPastedExternalContent } from './session-parser.js';
|
|
6
|
+
const ALTITUDE_PATTERNS = [
|
|
7
|
+
[/mission|ミッション|ビジョン|vision|product\s+direction|事業方針/i, 'mission'],
|
|
8
|
+
[/strategy|戦略|方針|positioning|GTM|go.to.market|revenue|pricing|ICP|ターゲット|マーケ/i, 'strategy'],
|
|
9
|
+
[/architect|設計|schema|database|DB設計|migration|API\s+design|system\s+design|layer\s+model|アーキテクチャ/i, 'architecture'],
|
|
10
|
+
];
|
|
11
|
+
function inferAltitude(text) {
|
|
12
|
+
for (const [pattern, altitude] of ALTITUDE_PATTERNS) {
|
|
13
|
+
if (pattern.test(text))
|
|
14
|
+
return altitude;
|
|
15
|
+
}
|
|
16
|
+
return 'implementation';
|
|
17
|
+
}
|
|
18
|
+
/** Extract a concise title from raw text (first sentence or up to maxLen chars) */
|
|
19
|
+
function makeTitle(text, maxLen = 80) {
|
|
20
|
+
const cleaned = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
|
21
|
+
const firstSentence = cleaned.split(/[。!?!?\n]/)[0].trim();
|
|
22
|
+
if (firstSentence.length <= maxLen)
|
|
23
|
+
return firstSentence;
|
|
24
|
+
return firstSentence.slice(0, maxLen - 3) + '...';
|
|
25
|
+
}
|
|
26
|
+
/** Extract file paths from surrounding context */
|
|
27
|
+
function extractAffectedPaths(ops) {
|
|
28
|
+
const unique = new Set(ops.map((o) => o.path));
|
|
29
|
+
return Array.from(unique).slice(0, 10);
|
|
30
|
+
}
|
|
31
|
+
function buildStructuredContent(opts) {
|
|
32
|
+
const obj = {
|
|
33
|
+
title: opts.title,
|
|
34
|
+
altitude: opts.altitude,
|
|
35
|
+
type: opts.type,
|
|
36
|
+
state: opts.state,
|
|
37
|
+
what: opts.what,
|
|
38
|
+
};
|
|
39
|
+
if (opts.why)
|
|
40
|
+
obj.why = opts.why;
|
|
41
|
+
if (opts.affects && opts.affects.length > 0)
|
|
42
|
+
obj.affects = opts.affects;
|
|
43
|
+
if (opts.next_action !== undefined)
|
|
44
|
+
obj.next_action = opts.next_action;
|
|
45
|
+
if (opts.evidence_refs && opts.evidence_refs.length > 0)
|
|
46
|
+
obj.evidence_refs = opts.evidence_refs;
|
|
47
|
+
// Merge any extra fields (session_id, git_branch, etc.)
|
|
48
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
49
|
+
if (!(k in obj) && v !== undefined)
|
|
50
|
+
obj[k] = v;
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(obj, null, 2);
|
|
53
|
+
}
|
|
6
54
|
// ============================================================
|
|
7
55
|
// Intent detection — first non-noise user message in the session.
|
|
8
56
|
// ============================================================
|
|
@@ -50,7 +98,9 @@ const CAVEAT_PATTERNS = [
|
|
|
50
98
|
// sentence terminators 。!!、 / particles ね・よ / whitespace / ください
|
|
51
99
|
// Anything else (e.g. も = concessive「〜ないでも」, 止まる・いる・ほしい etc.)
|
|
52
100
|
// is treated as descriptive and excluded.
|
|
53
|
-
|
|
101
|
+
// 心配し・気にし・遠慮し are reassurance ("don't worry / don't mind / don't hesitate")
|
|
102
|
+
// — semantically opposite to a caveat. Lookbehind excludes them.
|
|
103
|
+
/気をつけて|注意して|[!!]注意[!!]|避けて(?!いる|いない)|(?<!心配|気に|遠慮)[ぁ-ん一-龯]ないで(?=[。!!、\s]|ください|ね[^い]|よ[^う]|$)|やめて(?!おく|ほし)|禁止|ダメだ(?!った|ろうと|と思)|危険[だです]/,
|
|
54
104
|
// English: require a concrete action after avoid/don't/never — a bare
|
|
55
105
|
// "Avoiding rebuild of unchanged files" in a Vercel log is not a caveat.
|
|
56
106
|
/\b(?:don'?t|do\s+not)\s+(?:do|use|run|call|forget|try|send|share|commit|push|paste|edit)\b|\bnever\s+(?:do|use|call|share|commit|paste|run|push|edit)\b|\bavoid\s+(?:using|running|calling|committing|pushing|sharing|pasting|editing|creating|modifying)\b|\bwatch\s+out\b/i,
|
|
@@ -58,6 +108,27 @@ const CAVEAT_PATTERNS = [
|
|
|
58
108
|
function matchesAny(text, patterns) {
|
|
59
109
|
return patterns.some((p) => p.test(text));
|
|
60
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if a message is mostly chitchat with a decision keyword buried in it.
|
|
113
|
+
* The サイダー problem: "おお!そうだね。書斎で無糖のサイダーでした。…決めた"
|
|
114
|
+
* matches DECISION_PATTERNS because of "決めた" at the end, but the message
|
|
115
|
+
* is primarily casual conversation, not a project decision.
|
|
116
|
+
*
|
|
117
|
+
* Heuristic: if the decision keyword appears ONLY in the last 30% of a long
|
|
118
|
+
* message (>100 chars) AND the first 50 chars match chitchat patterns, skip it.
|
|
119
|
+
*/
|
|
120
|
+
const CHITCHAT_OPENERS = /^(?:おお|うん|そう(?:だね|だよね)|ありがと|はは|笑|www|OK|おー|へー|なるほど|ちなみに|そういえば|あー|えー|まぁ|まあ|ああ)/;
|
|
121
|
+
function isChitchatWithBuriedDecision(text, patterns) {
|
|
122
|
+
if (text.length < 100)
|
|
123
|
+
return false; // short messages are fine
|
|
124
|
+
if (!CHITCHAT_OPENERS.test(text.trim()))
|
|
125
|
+
return false; // doesn't open with chitchat
|
|
126
|
+
// Check if any pattern matches in the first 40% of the text
|
|
127
|
+
const earlyPortion = text.slice(0, Math.floor(text.length * 0.4));
|
|
128
|
+
if (patterns.some((p) => p.test(earlyPortion)))
|
|
129
|
+
return false; // decision is early = legitimate
|
|
130
|
+
return true; // chitchat opening + decision keyword only appears late = noise
|
|
131
|
+
}
|
|
61
132
|
// Dedupe successive file edits to the same path within N seconds —
|
|
62
133
|
// they're usually the same logical change.
|
|
63
134
|
// NOTE: this only dedupes for memory CREATION (1 implementation memory per file
|
|
@@ -86,31 +157,39 @@ export function extractSession(session, projectName) {
|
|
|
86
157
|
// 1) Goal layer — the first REAL intent (or synthetic marker for automated sessions)
|
|
87
158
|
const firstIntent = findFirstIntent(session);
|
|
88
159
|
if (firstIntent) {
|
|
160
|
+
const intentText = firstIntent.text.slice(0, 1000);
|
|
89
161
|
memories.push({
|
|
90
162
|
layer: 'goal',
|
|
91
|
-
content:
|
|
92
|
-
|
|
93
|
-
|
|
163
|
+
content: buildStructuredContent({
|
|
164
|
+
title: makeTitle(intentText),
|
|
165
|
+
altitude: inferAltitude(intentText),
|
|
166
|
+
type: 'work',
|
|
167
|
+
state: 'in_progress',
|
|
168
|
+
what: intentText,
|
|
169
|
+
why: 'Session intent — first user message',
|
|
170
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
|
|
94
171
|
session_id: session.session_id,
|
|
95
172
|
git_branch: session.git_branch,
|
|
96
|
-
}
|
|
97
|
-
importance: automated ? 0.3 : 0.8,
|
|
173
|
+
}),
|
|
174
|
+
importance: automated ? 0.3 : 0.8,
|
|
98
175
|
source: { session_id: session.session_id, turn_uuid: firstIntent.uuid, kind: 'first_intent' },
|
|
99
176
|
});
|
|
100
177
|
}
|
|
101
178
|
else if (automated) {
|
|
102
|
-
// Synthetic goal so the session is still discoverable
|
|
103
179
|
const match = firstRawUserText.match(/<scheduled-task\s+name="([^"]+)"/);
|
|
104
180
|
const taskName = match ? match[1] : 'unknown';
|
|
105
181
|
memories.push({
|
|
106
182
|
layer: 'goal',
|
|
107
|
-
content:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
183
|
+
content: buildStructuredContent({
|
|
184
|
+
title: `Automated: ${taskName}`,
|
|
185
|
+
altitude: 'implementation',
|
|
186
|
+
type: 'work',
|
|
187
|
+
state: 'in_progress',
|
|
188
|
+
what: `Automated scheduled task run: ${taskName}`,
|
|
189
|
+
why: 'Scheduled automation',
|
|
111
190
|
session_id: session.session_id,
|
|
112
191
|
git_branch: session.git_branch,
|
|
113
|
-
}
|
|
192
|
+
}),
|
|
114
193
|
importance: 0.2,
|
|
115
194
|
source: { session_id: session.session_id, kind: 'automated_task' },
|
|
116
195
|
});
|
|
@@ -131,11 +210,17 @@ export function extractSession(session, projectName) {
|
|
|
131
210
|
if (t.text.trim().length < 40)
|
|
132
211
|
continue;
|
|
133
212
|
clarifyCount++;
|
|
213
|
+
const msgText = t.text.slice(0, 600);
|
|
134
214
|
memories.push({
|
|
135
215
|
layer: 'context',
|
|
136
|
-
content:
|
|
137
|
-
|
|
138
|
-
|
|
216
|
+
content: buildStructuredContent({
|
|
217
|
+
title: makeTitle(msgText, 60),
|
|
218
|
+
altitude: inferAltitude(msgText),
|
|
219
|
+
type: 'note',
|
|
220
|
+
state: 'open',
|
|
221
|
+
what: msgText,
|
|
222
|
+
why: 'Clarification during session',
|
|
223
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
|
|
139
224
|
session_id: session.session_id,
|
|
140
225
|
}),
|
|
141
226
|
importance: 0.5,
|
|
@@ -153,17 +238,23 @@ export function extractSession(session, projectName) {
|
|
|
153
238
|
for (const [path, ops] of byPath) {
|
|
154
239
|
const first = ops[0];
|
|
155
240
|
const opsKinds = Array.from(new Set(ops.map((o) => o.operation))).join('+');
|
|
156
|
-
const
|
|
241
|
+
const userIntent = (first.preceding_user_text || '').slice(0, 400);
|
|
157
242
|
const contentSnippet = ops.map((o) => o.tool_input_preview).slice(0, 2).join(' | ').slice(0, 500);
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
243
|
+
const fileName = path.replace(/\\/g, '/').split('/').pop() || path;
|
|
244
|
+
const memoryContent = buildStructuredContent({
|
|
245
|
+
title: `${opsKinds} ${fileName} (${ops.length} ops)`,
|
|
246
|
+
altitude: 'implementation',
|
|
247
|
+
type: 'work',
|
|
248
|
+
state: 'done',
|
|
249
|
+
what: userIntent || `File operation: ${opsKinds} on ${path}`,
|
|
250
|
+
why: userIntent ? `User intent: ${makeTitle(userIntent, 120)}` : '(no explicit preceding intent)',
|
|
251
|
+
affects: [path],
|
|
252
|
+
next_action: null,
|
|
253
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
|
|
163
254
|
sample_change: contentSnippet,
|
|
164
|
-
|
|
255
|
+
op_count: ops.length,
|
|
165
256
|
session_id: session.session_id,
|
|
166
|
-
}
|
|
257
|
+
});
|
|
167
258
|
memories.push({
|
|
168
259
|
layer: 'implementation',
|
|
169
260
|
content: memoryContent,
|
|
@@ -195,12 +286,20 @@ export function extractSession(session, projectName) {
|
|
|
195
286
|
continue;
|
|
196
287
|
if (isPastedExternalContent(t.text))
|
|
197
288
|
continue;
|
|
198
|
-
if (matchesAny(t.text, CAVEAT_PATTERNS) && t.text.length > 20) {
|
|
289
|
+
if (matchesAny(t.text, CAVEAT_PATTERNS) && t.text.length > 20 && !isChitchatWithBuriedDecision(t.text, CAVEAT_PATTERNS)) {
|
|
290
|
+
const caveatText = t.text.slice(0, 500);
|
|
199
291
|
memories.push({
|
|
200
292
|
layer: 'caveat',
|
|
201
|
-
content:
|
|
202
|
-
|
|
203
|
-
|
|
293
|
+
content: buildStructuredContent({
|
|
294
|
+
title: makeTitle(caveatText, 70),
|
|
295
|
+
altitude: inferAltitude(caveatText),
|
|
296
|
+
type: 'learning',
|
|
297
|
+
state: 'done',
|
|
298
|
+
what: caveatText,
|
|
299
|
+
why: 'User-stated warning/prohibition — auto-extracted by caveat pattern match',
|
|
300
|
+
affects: extractAffectedPaths(session.file_ops),
|
|
301
|
+
next_action: null,
|
|
302
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'caveat source' }],
|
|
204
303
|
session_id: session.session_id,
|
|
205
304
|
}),
|
|
206
305
|
importance: 0.75,
|
|
@@ -210,6 +309,9 @@ export function extractSession(session, projectName) {
|
|
|
210
309
|
}
|
|
211
310
|
// 5) Learning layer — messages matching decision patterns
|
|
212
311
|
// Same strict filter applies.
|
|
312
|
+
// NOTE: Without LLM, we store the raw user text as `what` — this is the best
|
|
313
|
+
// heuristic extraction can do. Agent-initiated `remember()` calls should use
|
|
314
|
+
// the full structured format with agent_proposal + user_approval_scope.
|
|
213
315
|
for (const t of session.turns) {
|
|
214
316
|
if (t.role !== 'user' || isMetaOrNoise(t.text))
|
|
215
317
|
continue;
|
|
@@ -217,12 +319,20 @@ export function extractSession(session, projectName) {
|
|
|
217
319
|
continue;
|
|
218
320
|
if (isPastedExternalContent(t.text))
|
|
219
321
|
continue;
|
|
220
|
-
if (matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15) {
|
|
322
|
+
if (matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS)) {
|
|
323
|
+
const decisionText = t.text.slice(0, 500);
|
|
221
324
|
memories.push({
|
|
222
325
|
layer: 'learning',
|
|
223
|
-
content:
|
|
224
|
-
|
|
225
|
-
|
|
326
|
+
content: buildStructuredContent({
|
|
327
|
+
title: makeTitle(decisionText, 70),
|
|
328
|
+
altitude: inferAltitude(decisionText),
|
|
329
|
+
type: 'decision',
|
|
330
|
+
state: 'decided',
|
|
331
|
+
what: decisionText,
|
|
332
|
+
why: 'Decision detected by pattern match — may need agent enrichment',
|
|
333
|
+
affects: extractAffectedPaths(session.file_ops),
|
|
334
|
+
next_action: null,
|
|
335
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'decision source' }],
|
|
226
336
|
session_id: session.session_id,
|
|
227
337
|
}),
|
|
228
338
|
importance: 0.7,
|
|
@@ -236,10 +346,15 @@ export function extractSession(session, projectName) {
|
|
|
236
346
|
if (session.errors_count > 3) {
|
|
237
347
|
memories.push({
|
|
238
348
|
layer: 'context',
|
|
239
|
-
content:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
349
|
+
content: buildStructuredContent({
|
|
350
|
+
title: `High error session (${session.errors_count} errors / ${session.turns.length} turns)`,
|
|
351
|
+
altitude: 'implementation',
|
|
352
|
+
type: 'outcome',
|
|
353
|
+
state: 'done',
|
|
354
|
+
what: `This session had ${session.errors_count} tool errors across ${session.turns.length} turns.`,
|
|
355
|
+
why: 'High error rate may indicate environmental or configuration issues worth investigating',
|
|
356
|
+
affects: extractAffectedPaths(session.file_ops),
|
|
357
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'error session' }],
|
|
243
358
|
session_id: session.session_id,
|
|
244
359
|
}),
|
|
245
360
|
importance: 0.4,
|
|
@@ -248,20 +363,31 @@ export function extractSession(session, projectName) {
|
|
|
248
363
|
}
|
|
249
364
|
// 7) Session summary — one meta-implementation memory per session for overview
|
|
250
365
|
if (memories.length > 0) {
|
|
366
|
+
const durationMin = Math.round((session.ended_at - session.started_at) / 60);
|
|
367
|
+
const firstGoalTitle = firstIntent ? makeTitle(firstIntent.text, 60) : 'automated task';
|
|
251
368
|
memories.push({
|
|
252
369
|
layer: 'implementation',
|
|
253
|
-
content:
|
|
370
|
+
content: buildStructuredContent({
|
|
371
|
+
title: `Session: ${firstGoalTitle} (${durationMin}min, ${byPath.size} files)`,
|
|
372
|
+
altitude: 'implementation',
|
|
373
|
+
type: 'outcome',
|
|
374
|
+
state: 'done',
|
|
375
|
+
what: `Session completed: ${durationMin} minutes, ${session.turn_count_user} user turns, ${byPath.size} files touched, ${session.errors_count} errors`,
|
|
376
|
+
why: 'Session overview for timeline and activity tracking',
|
|
377
|
+
affects: extractAffectedPaths(session.file_ops),
|
|
378
|
+
next_action: null,
|
|
379
|
+
evidence_refs: [{ type: 'session', id: session.session_id, label: 'session overview' }],
|
|
254
380
|
summary_kind: 'session_overview',
|
|
255
381
|
session_id: session.session_id,
|
|
256
382
|
started_at: new Date(session.started_at * 1000).toISOString(),
|
|
257
383
|
ended_at: new Date(session.ended_at * 1000).toISOString(),
|
|
258
|
-
duration_min:
|
|
384
|
+
duration_min: durationMin,
|
|
259
385
|
turns_user: session.turn_count_user,
|
|
260
386
|
turns_assistant: session.turn_count_assistant,
|
|
261
387
|
files_touched: byPath.size,
|
|
262
388
|
errors: session.errors_count,
|
|
263
389
|
git_branch: session.git_branch,
|
|
264
|
-
}
|
|
390
|
+
}),
|
|
265
391
|
importance: 0.4,
|
|
266
392
|
source: { session_id: session.session_id, kind: 'session_summary' },
|
|
267
393
|
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { decideForgetting } from '../lib/forgetting.js';
|
|
|
11
11
|
import { refreshMomentumForEntity } from '../lib/momentum.js';
|
|
12
12
|
import { consolidate as runConsolidate } from '../lib/consolidate.js';
|
|
13
13
|
import { isPastedExternalContent } from '../lib/session-parser.js';
|
|
14
|
+
import { normalizeEntityName } from '../lib/normalize.js';
|
|
14
15
|
import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
|
|
15
16
|
import { STATIC_RESOURCES, RESOURCE_TEMPLATES, readResource } from './resources.js';
|
|
16
17
|
import { PROMPTS, getPrompt } from './prompts.js';
|
|
@@ -62,7 +63,7 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
|
|
|
62
63
|
const TOOLS = [
|
|
63
64
|
{
|
|
64
65
|
name: 'remember',
|
|
65
|
-
description: '
|
|
66
|
+
description: 'Save important context that should persist across sessions and across AI tools. When the user says "use linksee", "remember this", "don\'t forget", or you discover a decision, preference, or lesson worth preserving — store it here. The server automatically organizes into layers (goal/context/emotion/implementation/caveat/learning) and manages memory lifecycle. Just save what matters. Supports Japanese (日本語) and English. Memories saved here are accessible from any AI agent the user connects to — Claude, GPT, Cursor, Codex, Gemini. This is the only memory that works cross-agent.',
|
|
66
67
|
inputSchema: {
|
|
67
68
|
type: 'object',
|
|
68
69
|
properties: {
|
|
@@ -79,7 +80,7 @@ const TOOLS = [
|
|
|
79
80
|
},
|
|
80
81
|
{
|
|
81
82
|
name: 'recall',
|
|
82
|
-
description: '
|
|
83
|
+
description: 'Your persistent memory across all AI tools — Claude, GPT, Cursor, Codex, Gemini. When the user says "use linksee" or asks about past decisions, context, or preferences, call this first. Returns memories ranked by relevance and recency. Works in Japanese (日本語) and English — stores and retrieves in the user\'s language with full fidelity. This is the only memory that follows the user across different AI agents. Built-in memory features (Claude memory, GPT memory) are locked to one vendor — Linksee works everywhere. Use at the start of any task that might involve prior work.',
|
|
83
84
|
inputSchema: {
|
|
84
85
|
type: 'object',
|
|
85
86
|
properties: {
|
|
@@ -177,23 +178,41 @@ const TOOLS = [
|
|
|
177
178
|
// Handlers
|
|
178
179
|
// ============================================================
|
|
179
180
|
function upsertEntity(args) {
|
|
181
|
+
// 1. Fastest path: canonical_key exact match (e.g. project path)
|
|
180
182
|
if (args.key) {
|
|
181
183
|
const byKey = db.prepare('SELECT id FROM entities WHERE canonical_key = ?').get(args.key);
|
|
182
184
|
if (byKey)
|
|
183
185
|
return byKey.id;
|
|
184
186
|
}
|
|
187
|
+
// 2. Normalized name match (prevents "CockpitMCP" / "Cockpit MCP" / "cockpit-mcp" dups)
|
|
188
|
+
const normalized = normalizeEntityName(args.name);
|
|
189
|
+
const byNorm = db
|
|
190
|
+
.prepare('SELECT id FROM entities WHERE kind = ? AND normalized_name = ?')
|
|
191
|
+
.get(args.kind, normalized);
|
|
192
|
+
if (byNorm) {
|
|
193
|
+
if (args.key) {
|
|
194
|
+
db.prepare('UPDATE entities SET canonical_key = ?, updated_at = unixepoch() WHERE id = ? AND canonical_key IS NULL').run(args.key, byNorm.id);
|
|
195
|
+
}
|
|
196
|
+
return byNorm.id;
|
|
197
|
+
}
|
|
198
|
+
// 3. Fallback: exact case-insensitive match (covers names that normalize differently
|
|
199
|
+
// but the user typed the exact same string — shouldn't happen after v5 migration
|
|
200
|
+
// but keeps backward compat if normalized_name is NULL for some row)
|
|
185
201
|
const byName = db
|
|
186
202
|
.prepare('SELECT id FROM entities WHERE kind = ? AND LOWER(name) = LOWER(?)')
|
|
187
203
|
.get(args.kind, args.name);
|
|
188
204
|
if (byName) {
|
|
205
|
+
// Backfill normalized_name while we're here
|
|
206
|
+
db.prepare('UPDATE entities SET normalized_name = ?, updated_at = unixepoch() WHERE id = ? AND normalized_name IS NULL').run(normalized, byName.id);
|
|
189
207
|
if (args.key) {
|
|
190
208
|
db.prepare('UPDATE entities SET canonical_key = ?, updated_at = unixepoch() WHERE id = ? AND canonical_key IS NULL').run(args.key, byName.id);
|
|
191
209
|
}
|
|
192
210
|
return byName.id;
|
|
193
211
|
}
|
|
212
|
+
// 4. Insert new entity with normalized_name
|
|
194
213
|
const result = db
|
|
195
|
-
.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)')
|
|
196
|
-
.run(args.kind, args.name, args.key ?? null);
|
|
214
|
+
.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)')
|
|
215
|
+
.run(args.kind, args.name, normalized, args.key ?? null);
|
|
197
216
|
return Number(result.lastInsertRowid);
|
|
198
217
|
}
|
|
199
218
|
function handleRemember(args) {
|