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.
@@ -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
- /気をつけて|注意して|[!!]注意[!!]|避けて(?!いる|いない)|[ぁ-ん一-龯]ないで(?=[。!!、\s]|ください|ね[^い]|よ[^う]|$)|やめて(?!おく|ほし)|禁止|ダメだ(?!った|ろうと|と思)|危険[だです]/,
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: JSON.stringify({
92
- intent: firstIntent.text.slice(0, 1000),
93
- when: new Date(firstIntent.timestamp * 1000).toISOString(),
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
- }, null, 2),
97
- importance: automated ? 0.3 : 0.8, // lower for automated runs
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: JSON.stringify({
108
- intent: `Automated scheduled task run: ${taskName}`,
109
- automated: true,
110
- when: new Date(session.started_at * 1000).toISOString(),
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
- }, null, 2),
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: JSON.stringify({
137
- message: t.text.slice(0, 600),
138
- when: new Date(t.timestamp * 1000).toISOString(),
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 why = (first.preceding_user_text || '(no explicit preceding intent)').slice(0, 400);
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 memoryContent = JSON.stringify({
159
- file: path,
160
- ops: opsKinds,
161
- op_count: ops.length,
162
- why_extracted: why,
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
- when: new Date(first.timestamp * 1000).toISOString(),
255
+ op_count: ops.length,
165
256
  session_id: session.session_id,
166
- }, null, 2);
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: JSON.stringify({
202
- rule_or_warning: t.text.slice(0, 500),
203
- when: new Date(t.timestamp * 1000).toISOString(),
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: JSON.stringify({
224
- decision: t.text.slice(0, 500),
225
- when: new Date(t.timestamp * 1000).toISOString(),
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: JSON.stringify({
240
- why_now: `This session had ${session.errors_count} tool errors across ${session.turns.length} turns.`,
241
- triggering_event: 'high_error_rate',
242
- when: new Date(session.started_at * 1000).toISOString(),
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: JSON.stringify({
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: Math.round((session.ended_at - session.started_at) / 60),
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
- }, null, 2),
390
+ }),
265
391
  importance: 0.4,
266
392
  source: { session_id: session.session_id, kind: 'session_summary' },
267
393
  });
@@ -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: 'Store a memory about an entity (person/company/project/concept/file) in one of 6 layers: goal (WHY), context (WHY-THIS-NOW), emotion (USER tone), implementation (HOW success/failure), caveat (PAIN lesson, never forgotten), learning (GROWTH log). Use this when you discover non-obvious goals, unexpected failures, user preferences, or decisions worth preserving. Pasted assistant output or CI logs are rejected (use force=true only if you are sure).',
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: 'Retrieve memories relevant to the current context using full-text search (BM25) + entity-name match, re-ranked by a composite score (relevance × heat × momentum × importance). Returns only what fits in the token budget, with match_reasons explaining WHY each memory was returned. Opportunistically refreshes stale momentum scores for entities in the result set. Supports pagination via offset/has_more. Layer aliases accepted. Use at the start of any task that might involve prior work.',
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) {