linksee-memory 0.7.2 → 0.10.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.
@@ -157,7 +157,9 @@ function findFirstIntent(session) {
157
157
  // Decisions & learnings — user messages containing explicit commitment words.
158
158
  // ============================================================
159
159
  const DECISION_PATTERNS = [
160
- /決めた|採用|確定|これで(いい|進め)|OK進めて|やろう|行こう/,
160
+ // ひらがな「いこう」「すすめよう」 variants were missed (real loss observed: "OK. CからAいこう").
161
+ // Widening is safe now that content-dedup + chitchat guard + needs_distill triage exist downstream.
162
+ /決めた|採用|確定|これで(いい|進め)|OK進めて|やろう|行こう|いこう|進めよう|すすめよう/,
161
163
  /learn(ed)?|decid(?:e|ed|ing)|chose|picked|going with|let'?s\s+go|pivot(?:ing|ed)?|switch(?:ing)?\s+to|settled\s+on|approved|we'?ll\s+use|commit(?:ting)?\s+to/i,
162
164
  ];
163
165
  const FAILURE_PATTERNS = [
@@ -240,6 +242,10 @@ export function extractSession(session, projectName) {
240
242
  // Priority: goal(first_intent) > caveat > decision > context. capturedTurns
241
243
  // tracks caveat-claimed turns so the decision pass skips them.
242
244
  const capturedTurns = new Set();
245
+ // Content-level dedup: the SAME utterance sent twice (interrupt + resend is common)
246
+ // arrives as two distinct turns and used to become two identical memories.
247
+ const seenWhats = new Set();
248
+ const whatKey = (layer, text) => `${layer}::${text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 200)}`;
243
249
  // Detect fully-automated sessions (e.g. scheduled cron tasks) — no user intent to extract
244
250
  const firstRawUserText = session.turns.find((t) => t.role === 'user' && !t.tool_results)?.text ?? '';
245
251
  const automated = isAutomatedSession(firstRawUserText);
@@ -262,6 +268,7 @@ export function extractSession(session, projectName) {
262
268
  }),
263
269
  importance: automated ? 0.3 : 0.8,
264
270
  thread_id: session.session_id,
271
+ occurred_at: firstIntent.timestamp,
265
272
  source: { session_id: session.session_id, turn_uuid: firstIntent.uuid, kind: 'first_intent' },
266
273
  });
267
274
  }
@@ -282,6 +289,7 @@ export function extractSession(session, projectName) {
282
289
  }),
283
290
  importance: 0.2,
284
291
  thread_id: session.session_id,
292
+ occurred_at: session.started_at,
285
293
  source: { session_id: session.session_id, kind: 'automated_task' },
286
294
  });
287
295
  }
@@ -306,8 +314,12 @@ export function extractSession(session, projectName) {
306
314
  const wouldBeDecision = matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS);
307
315
  if (wouldBeCaveat || wouldBeDecision)
308
316
  continue;
309
- clarifyCount++;
310
317
  const msgText = t.text.slice(0, 600);
318
+ const ctxKey = whatKey('context', msgText);
319
+ if (seenWhats.has(ctxKey))
320
+ continue;
321
+ seenWhats.add(ctxKey);
322
+ clarifyCount++;
311
323
  memories.push({
312
324
  layer: 'context',
313
325
  content: buildStructuredContent({
@@ -322,6 +334,7 @@ export function extractSession(session, projectName) {
322
334
  }),
323
335
  importance: 0.5,
324
336
  thread_id: session.session_id,
337
+ occurred_at: t.timestamp,
325
338
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'clarification' },
326
339
  });
327
340
  }
@@ -339,13 +352,16 @@ export function extractSession(session, projectName) {
339
352
  const userIntent = (first.preceding_user_text || '').slice(0, 400);
340
353
  const contentSnippet = ops.map((o) => o.tool_input_preview).slice(0, 2).join(' | ').slice(0, 500);
341
354
  const fileName = path.replace(/\\/g, '/').split('/').pop() || path;
355
+ // `what` must be op-specific, NOT the shared user utterance: one turn editing 5 files
356
+ // produces 5 of these memories, and a shared raw-utterance `what` rendered as 5 visual
357
+ // duplicates in every digest/recall view. The intent lives ONCE, titled, in `why`.
342
358
  const memoryContent = buildStructuredContent({
343
359
  title: `${opsKinds} ${fileName} (${ops.length} ops)`,
344
360
  altitude: 'implementation',
345
361
  type: 'work',
346
362
  state: 'done',
347
- what: userIntent || `File operation: ${opsKinds} on ${path}`,
348
- why: userIntent ? `User intent: ${makeTitle(userIntent, 120)}` : '(no explicit preceding intent)',
363
+ what: `${opsKinds} ${path} (${ops.length} ops)`,
364
+ why: userIntent ? makeTitle(userIntent, 160) : '(no explicit preceding intent)',
349
365
  affects: [path],
350
366
  next_action: null,
351
367
  evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
@@ -358,6 +374,7 @@ export function extractSession(session, projectName) {
358
374
  content: memoryContent,
359
375
  importance: 0.6,
360
376
  thread_id: session.session_id,
377
+ occurred_at: first.timestamp,
361
378
  source: { session_id: session.session_id, turn_uuid: first.turn_uuid, kind: 'file_edit' },
362
379
  });
363
380
  // Create a file_edit link record for EACH physical op (NOT deduped),
@@ -378,7 +395,16 @@ export function extractSession(session, projectName) {
378
395
  }
379
396
  // 4) Caveat layer — user messages matching caveat/failure patterns
380
397
  // Stricter filter: must NOT be pasted external content.
398
+ // needs_distill: the stored `what` is a RAW utterance (best heuristic extraction can do
399
+ // without an LLM in the hook path) — `dream` queues these for agent rewriting.
400
+ // context_hint: tail of the assistant message the user was replying to, so the distiller
401
+ // can resolve references like「a)やろう」without re-reading the transcript.
402
+ let prevAssistantForCaveat = '';
381
403
  for (const t of session.turns) {
404
+ if (t.role === 'assistant') {
405
+ prevAssistantForCaveat = t.text;
406
+ continue;
407
+ }
382
408
  if (t.role !== 'user' || isMetaOrNoise(t.text))
383
409
  continue;
384
410
  if (t === firstIntent)
@@ -389,6 +415,13 @@ export function extractSession(session, projectName) {
389
415
  continue;
390
416
  if (matchesAny(t.text, CAVEAT_PATTERNS) && t.text.length > 20 && !isChitchatWithBuriedDecision(t.text, CAVEAT_PATTERNS)) {
391
417
  const caveatText = t.text.slice(0, 500);
418
+ const key = whatKey('caveat', caveatText);
419
+ if (seenWhats.has(key)) {
420
+ if (t.uuid)
421
+ capturedTurns.add(t.uuid);
422
+ continue;
423
+ }
424
+ seenWhats.add(key);
392
425
  memories.push({
393
426
  layer: 'caveat',
394
427
  content: buildStructuredContent({
@@ -397,14 +430,17 @@ export function extractSession(session, projectName) {
397
430
  type: inferType(caveatText, 'caveat'),
398
431
  state: inferState(caveatText, 'caveat'),
399
432
  what: caveatText,
400
- why: 'User-stated warning/prohibition auto-extracted by caveat pattern match',
433
+ why: 'user-stated warning (auto-extracted) pending distillation',
401
434
  affects: extractAffectedPaths(session.file_ops),
402
435
  next_action: null,
403
436
  evidence_refs: [{ type: 'session', id: session.session_id, label: 'caveat source' }],
404
437
  session_id: session.session_id,
438
+ needs_distill: true,
439
+ context_hint: prevAssistantForCaveat.slice(-280),
405
440
  }),
406
441
  importance: 0.75,
407
442
  thread_id: session.session_id,
443
+ occurred_at: t.timestamp,
408
444
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'caveat' },
409
445
  });
410
446
  if (t.uuid)
@@ -412,11 +448,14 @@ export function extractSession(session, projectName) {
412
448
  }
413
449
  }
414
450
  // 5) Learning layer — messages matching decision patterns
415
- // Same strict filter applies.
416
- // NOTE: Without LLM, we store the raw user text as `what` this is the best
417
- // heuristic extraction can do. Agent-initiated `remember()` calls should use
418
- // the full structured format with agent_proposal + user_approval_scope.
451
+ // Same strict filter applies. Raw `what` + needs_distill + context_hint, same contract
452
+ // as the caveat pass above `dream` surfaces these for agent distillation.
453
+ let prevAssistantForDecision = '';
419
454
  for (const t of session.turns) {
455
+ if (t.role === 'assistant') {
456
+ prevAssistantForDecision = t.text;
457
+ continue;
458
+ }
420
459
  if (t.role !== 'user' || isMetaOrNoise(t.text))
421
460
  continue;
422
461
  if (t === firstIntent)
@@ -427,8 +466,15 @@ export function extractSession(session, projectName) {
427
466
  continue;
428
467
  if (isPastedExternalContent(t.text))
429
468
  continue;
430
- if (matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS)) {
469
+ // Length floor 8 (was 15): short JP approvals ARE the decision record ("OK. CからAいこう",
470
+ // "Bだね。以下OK." — both real, both were lost at 15). They only carry meaning WITH the
471
+ // context_hint captured below, which is exactly what the distill pass resolves.
472
+ if (matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 8 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS)) {
431
473
  const decisionText = t.text.slice(0, 500);
474
+ const key = whatKey('learning', decisionText);
475
+ if (seenWhats.has(key))
476
+ continue;
477
+ seenWhats.add(key);
432
478
  memories.push({
433
479
  layer: 'learning',
434
480
  content: buildStructuredContent({
@@ -437,14 +483,17 @@ export function extractSession(session, projectName) {
437
483
  type: inferType(decisionText, 'learning'),
438
484
  state: inferState(decisionText, 'learning'),
439
485
  what: decisionText,
440
- why: 'Decision detected by pattern match may need agent enrichment',
486
+ why: 'decision keyword matched (auto-extracted)pending distillation',
441
487
  affects: extractAffectedPaths(session.file_ops),
442
488
  next_action: null,
443
489
  evidence_refs: [{ type: 'session', id: session.session_id, label: 'decision source' }],
444
490
  session_id: session.session_id,
491
+ needs_distill: true,
492
+ context_hint: prevAssistantForDecision.slice(-280),
445
493
  }),
446
494
  importance: 0.7,
447
495
  thread_id: session.session_id,
496
+ occurred_at: t.timestamp,
448
497
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'decision' },
449
498
  });
450
499
  }
@@ -468,6 +517,7 @@ export function extractSession(session, projectName) {
468
517
  }),
469
518
  importance: 0.4,
470
519
  thread_id: session.session_id,
520
+ occurred_at: session.ended_at,
471
521
  source: { session_id: session.session_id, kind: 'error_recovery' },
472
522
  });
473
523
  }
@@ -500,6 +550,7 @@ export function extractSession(session, projectName) {
500
550
  }),
501
551
  importance: 0.4,
502
552
  thread_id: session.session_id,
553
+ occurred_at: session.ended_at,
503
554
  source: { session_id: session.session_id, kind: 'session_summary' },
504
555
  });
505
556
  }
@@ -0,0 +1,84 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type DriftState = 'drift' | 'review' | 'held' | 'aligned';
3
+ export type Species = 'hypothesis' | 'constraint' | 'commitment' | 'source_of_truth';
4
+ export interface TruthNode {
5
+ id: number;
6
+ node_type: string | null;
7
+ domain: string | null;
8
+ decision_mode: string | null;
9
+ species: Species;
10
+ statement: string;
11
+ rationale: string | null;
12
+ confidence: number;
13
+ lifecycle: string;
14
+ cadence_days: number | null;
15
+ review_after: number | null;
16
+ state: DriftState;
17
+ accounted: boolean;
18
+ accountedBy: string | null;
19
+ reality: string | null;
20
+ reviewDate: string | null;
21
+ overdue: boolean;
22
+ }
23
+ export interface TruthCandidate {
24
+ id: number;
25
+ candidate_type: string;
26
+ target_node_id: number | null;
27
+ target_statement: string | null;
28
+ rationale: string;
29
+ confidence: number;
30
+ status: string;
31
+ }
32
+ export interface TruthCounts {
33
+ nodes: number;
34
+ by_mode: Record<string, number>;
35
+ by_species: Record<Species, number>;
36
+ by_state: Record<DriftState, number>;
37
+ auto: number;
38
+ suppressed: number;
39
+ }
40
+ export interface TruthView {
41
+ attention: TruthNode[];
42
+ alignedByDomain: Array<{
43
+ domain: string;
44
+ nodes: TruthNode[];
45
+ }>;
46
+ candidates: {
47
+ auto: TruthCandidate[];
48
+ suppressed: TruthCandidate[];
49
+ };
50
+ counts: TruthCounts;
51
+ nextReopen: string | null;
52
+ }
53
+ export interface DecisionDetail extends TruthNode {
54
+ kind: string | null;
55
+ affects: string[];
56
+ detect_terms: string[];
57
+ violation_signal: string[];
58
+ tier: string;
59
+ pendingCandidates: TruthCandidate[];
60
+ driftEdges: Array<{
61
+ edge_id: number;
62
+ verdict: string;
63
+ confidence: number;
64
+ status: string;
65
+ detected_at: number;
66
+ }>;
67
+ }
68
+ export declare function getTruthView(db: Database.Database, opts?: {
69
+ domain?: string;
70
+ decision_mode?: string;
71
+ }): TruthView;
72
+ export declare function getDecisionDetail(db: Database.Database, anchorId: number): DecisionDetail | null;
73
+ export type ResolutionAction = 'fix' | 'supersede' | 'acknowledge' | 'dismiss';
74
+ export interface ResolveInput {
75
+ anchor_id: number;
76
+ action: ResolutionAction;
77
+ rationale?: string;
78
+ review_after?: string;
79
+ superseded_by?: number;
80
+ }
81
+ export declare function resolveDrift(db: Database.Database, input: ResolveInput): {
82
+ ok: boolean;
83
+ resolution: any;
84
+ };