linksee-memory 0.7.1 → 0.8.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.
@@ -0,0 +1,178 @@
1
+ // Memory→memory edge detection (precision-first, no LLM required).
2
+ //
3
+ // Populates the `memory_edges` table with supersedes/contradicts links between
4
+ // DECISION memories, so the dashboard can render Pivot Chains and recall can
5
+ // surface superseded decisions. Runs inside the sleep-mode consolidation sweep.
6
+ // Idempotent (UNIQUE constraint on memory_edges).
7
+ //
8
+ // Heuristic: within one entity, a later decision that shares strong topical terms
9
+ // with an earlier decision *supersedes* it (→ *contradicts* if it carries reversal
10
+ // markers like やめる / 撤回 / revert / instead of). The earlier decision's state
11
+ // is flipped to 'superseded'. We link to the MOST RECENT same-topic decision so
12
+ // chains form (A → B → C) rather than cliques.
13
+ import { isMetaOrNoise, isPastedExternalContent } from './session-parser.js';
14
+ // A later decision either REVERSES an earlier one (→ contradicts) or REPLACES it
15
+ // (→ supersedes). A same-topic decision with NEITHER marker only EXTENDS the earlier
16
+ // one and must NOT deactivate it — marking a still-valid decision 'superseded' is
17
+ // silent data loss (e.g. "v2 format" extends, it does not kill, the "2-layer arch" decision).
18
+ const CONTRADICT_MARKERS = /やめ|撤回|取り消|ではなく|じゃなく|見直|revert|rollback|instead of|no longer|abandon|\bdon'?t\b/i;
19
+ const SUPERSEDE_MARKERS = /の代わり|に変更|に切り替|乗り換|を置き換|に置換|廃止|replaces?\b|supersed|deprecat|switch(?:ing|ed)?\s+(?:to|from|away)/i;
20
+ const STOPWORDS = new Set([
21
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'your', 'our', 'their',
22
+ 'about', 'what', 'when', 'will', 'have', 'has', 'was', 'were', 'are', 'not', 'use', 'using',
23
+ 'memory', 'linksee', 'session', 'decision', 'project', 'やった', 'する', 'した', 'して',
24
+ 'こと', 'ため', 'よう', 'という', 'です', 'ます', 'など', 'その', 'この', 'これ', 'それ',
25
+ // conversational fillers — must never count as a shared "topic" term
26
+ 'そうだね', 'そうね', 'ありがとう', 'なるほど', 'やろう', 'いいね', 'おおいね', 'これでいい',
27
+ 'これでいいと思う', 'これでいいかな', 'わかった', '了解', 'おはよう', 'おつかれ',
28
+ ]);
29
+ // Both endpoints must have substantive core text — kills "そうだね"/"ありがとう"
30
+ // turns that the upstream classifier over-labels as decisions.
31
+ const MIN_CORE_LEN = 40;
32
+ // Decisions whose body opens with an acknowledgement are almost always chitchat
33
+ // the upstream classifier mis-typed — never use them as edge endpoints.
34
+ const CHITCHAT_OPENER = /^\s*(?:そう(?:だ?ね|だよね)?|うん|ありがと|なるほど|おお|へえ|了解|わかった|はい|おはよう|おつかれ|いいね|まあ|ええ|あー)/;
35
+ // Terminal output, git/npm logs, and pasted emails also get mis-typed as decisions.
36
+ // isPastedExternalContent misses these shapes, so guard them explicitly.
37
+ const LOOKS_LIKE_PASTE = /PS [A-Za-z]:\\|[A-Za-z]:\\Users\\|create mode \d{6}|\bgit (?:commit|push|add|status|log|diff|branch|checkout|merge)\b|commit -m|\bnpm (?:run|install|ci)\b|Upon further review|resubmission|Thank you for your/i;
38
+ function significantTerms(text) {
39
+ const out = new Set();
40
+ const lower = text.toLowerCase();
41
+ const asciiRe = /[a-z][a-z0-9_+.-]{2,}/g;
42
+ let m;
43
+ while ((m = asciiRe.exec(lower)) !== null) {
44
+ const w = m[0];
45
+ if (w.length >= 3 && !STOPWORDS.has(w))
46
+ out.add(w);
47
+ }
48
+ // CJK runs (hiragana / katakana / kanji / half-width kana), length 2..12.
49
+ const cjkRe = /[぀-ヿ㐀-鿿ヲ-゚]{2,12}/g;
50
+ while ((m = cjkRe.exec(text)) !== null) {
51
+ const w = m[0];
52
+ if (!STOPWORDS.has(w))
53
+ out.add(w);
54
+ }
55
+ return out;
56
+ }
57
+ function coreText(content) {
58
+ try {
59
+ const o = JSON.parse(content);
60
+ return {
61
+ title: String(o.title ?? ''),
62
+ body: String(o.what ?? o.decision ?? o.intent ?? o.learned ?? ''),
63
+ };
64
+ }
65
+ catch {
66
+ return { title: '', body: content };
67
+ }
68
+ }
69
+ export function detectMemoryEdges(db, opts = {}) {
70
+ const lookback = opts.lookback ?? 25;
71
+ const res = {
72
+ decisionsScanned: 0, edgesCreated: 0, supersedes: 0, contradicts: 0, extends: 0, supersededMarked: 0, samples: [],
73
+ };
74
+ const rows = db.prepare(`
75
+ SELECT id, entity_id, content, created_at
76
+ FROM memories
77
+ WHERE mem_type = 'decision' AND json_valid(content)
78
+ ORDER BY entity_id ASC, created_at ASC, id ASC
79
+ `).all();
80
+ res.decisionsScanned = rows.length;
81
+ if (rows.length < 2)
82
+ return res;
83
+ const byEntity = new Map();
84
+ for (const r of rows) {
85
+ const arr = byEntity.get(r.entity_id);
86
+ if (arr)
87
+ arr.push(r);
88
+ else
89
+ byEntity.set(r.entity_id, [r]);
90
+ }
91
+ // Prepare write statements only when actually writing — keeps dryRun safe on a
92
+ // readonly connection (preview / verification path).
93
+ const insEdge = opts.dryRun ? null : db.prepare(`INSERT OR IGNORE INTO memory_edges (from_memory_id, to_memory_id, relation) VALUES (?, ?, ?)`);
94
+ const markSuperseded = opts.dryRun ? null : db.prepare(`
95
+ UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
+ WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
97
+ `);
98
+ const apply = () => {
99
+ for (const decisions of byEntity.values()) {
100
+ if (decisions.length < 2)
101
+ continue;
102
+ const meta = decisions.map((d) => {
103
+ const { title, body } = coreText(d.content);
104
+ const text = `${title} ${body}`;
105
+ // Defend against the polluted 'decision' input set: drop pasted external
106
+ // content (emails / terminal logs), meta-noise, chitchat, and too-short bodies.
107
+ const usable = text.trim().length >= MIN_CORE_LEN
108
+ && !CHITCHAT_OPENER.test(body)
109
+ && !isMetaOrNoise(body)
110
+ && !isPastedExternalContent(body)
111
+ && !LOOKS_LIKE_PASTE.test(text);
112
+ return { id: d.id, title: (title || body).slice(0, 60), text, len: text.trim().length, terms: significantTerms(text), usable };
113
+ }).filter((m) => m.usable && m.terms.size >= 3);
114
+ for (let i = 1; i < meta.length; i++) {
115
+ if (meta[i].terms.size < 3 || meta[i].len < MIN_CORE_LEN)
116
+ continue;
117
+ let linked = -1;
118
+ let linkedShared = [];
119
+ for (let j = i - 1; j >= 0 && i - j <= lookback; j--) {
120
+ if (meta[j].terms.size < 3 || meta[j].len < MIN_CORE_LEN)
121
+ continue;
122
+ const shared = [];
123
+ for (const t of meta[i].terms)
124
+ if (meta[j].terms.has(t))
125
+ shared.push(t);
126
+ if (shared.length < 3)
127
+ continue; // require >= 3 shared topic terms (precision-first)
128
+ const overlap = shared.length / Math.min(meta[i].terms.size, meta[j].terms.size);
129
+ if (overlap > 0.85)
130
+ continue; // near-identical memories = a duplicate, not a supersession
131
+ if (overlap >= 0.25) {
132
+ linked = j;
133
+ linkedShared = shared;
134
+ break;
135
+ } // most-recent same-topic → chain
136
+ }
137
+ if (linked < 0)
138
+ continue;
139
+ const reversing = CONTRADICT_MARKERS.test(meta[i].text);
140
+ const replacing = SUPERSEDE_MARKERS.test(meta[i].text);
141
+ const relation = reversing ? 'contradicts' : replacing ? 'supersedes' : 'extends';
142
+ const deactivatesOlder = reversing || replacing; // 'extends' leaves the older decision valid
143
+ let counted = true;
144
+ if (!opts.dryRun) {
145
+ const r = insEdge.run(meta[i].id, meta[linked].id, relation);
146
+ if (r.changes > 0) {
147
+ if (deactivatesOlder && markSuperseded.run(meta[linked].id).changes > 0)
148
+ res.supersededMarked++;
149
+ }
150
+ else {
151
+ counted = false; // edge already existed
152
+ }
153
+ }
154
+ if (counted) {
155
+ res.edgesCreated++;
156
+ if (relation === 'contradicts')
157
+ res.contradicts++;
158
+ else if (relation === 'supersedes')
159
+ res.supersedes++;
160
+ else
161
+ res.extends++;
162
+ if (res.samples.length < 25) {
163
+ res.samples.push({
164
+ from_id: meta[i].id, to_id: meta[linked].id, relation,
165
+ from_title: meta[i].title, to_title: meta[linked].title, shared: linkedShared.slice(0, 6),
166
+ });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ };
172
+ if (opts.dryRun)
173
+ apply();
174
+ else
175
+ db.transaction(apply)();
176
+ return res;
177
+ }
178
+ //# sourceMappingURL=edge-detection.js.map
@@ -25,7 +25,35 @@ const TYPE_PATTERNS = [
25
25
  [/学び|教訓|分かった|判明|発見|learn|realize|discover|find\s+out|turns?\s+out|takeaway|insight/i, 'learning'],
26
26
  [/結果|完了|成功|失敗|outcome|result|shipped|deployed|launched|finished|accomplished/i, 'outcome'],
27
27
  ];
28
+ // Pasted terminal/git/npm output and forwarded emails get mis-typed as decisions.
29
+ // isPastedExternalContent misses these shapes, so guard them explicitly.
30
+ const LOOKS_LIKE_PASTE = /PS [A-Za-z]:\\|[A-Za-z]:\\Users\\|create mode \d{6}|\bgit (?:commit|push|add|status|log|diff|branch|checkout|merge)\b|commit -m|\bnpm (?:run|install|ci)\b|Upon further review|resubmission|Thank you for your/i;
31
+ // A turn that is ENTIRELY an acknowledgement carries no cognitive content. This only
32
+ // fires on short, wholly-filler messages — anything with substance after the opener
33
+ // (e.g. "そうだね、Reactを採用しよう") still flows to the patterns below.
34
+ const PURE_ACK = /^(?:そう(?:だ?ね|だよね)?|うん+|ありがと[うー]?|なるほど|了解(?:です)?|わかった|おはよう|おつかれ(?:さま)?|はい+|おお+|へえ+|いいね|まあ|ええ|あー|ok|okay|thanks?|got it|sounds good)[。、,.!!??\s~〜ねよ]*$/i;
35
+ // Whether a memory's text carries enough signal to assign a meaningful cognitive type.
36
+ // Junk (pasted logs/emails, meta-noise, pure acknowledgements) must NOT become a
37
+ // 'decision'/'comparison' — that pollution caps memory_edges + dashboard quality.
38
+ function isClassifiableContent(text) {
39
+ const t = text.trim();
40
+ if (t.length === 0)
41
+ return false;
42
+ if (PURE_ACK.test(t))
43
+ return false;
44
+ if (LOOKS_LIKE_PASTE.test(text))
45
+ return false;
46
+ if (isPastedExternalContent(text))
47
+ return false;
48
+ if (isMetaOrNoise(text))
49
+ return false;
50
+ return true;
51
+ }
28
52
  export function inferType(text, layer) {
53
+ // Precision guard: junk content has no meaningful type — fall back to 'note'
54
+ // BEFORE pattern matching / layer defaults so it can never become a 'decision'.
55
+ if (!isClassifiableContent(text))
56
+ return 'note';
29
57
  for (const [pattern, type] of TYPE_PATTERNS) {
30
58
  if (pattern.test(text))
31
59
  return type;
@@ -54,6 +82,9 @@ const STATE_PATTERNS = [
54
82
  [/取り替え|代わりに|置き換え|replaced|superseded|deprecated|obsolete|旧版|old\s+approach/i, 'superseded'],
55
83
  ];
56
84
  export function inferState(text, layer) {
85
+ // Junk content gets a neutral 'open' state rather than a confident 'decided'/'done'.
86
+ if (!isClassifiableContent(text))
87
+ return 'open';
57
88
  for (const [pattern, state] of STATE_PATTERNS) {
58
89
  if (pattern.test(text))
59
90
  return state;
@@ -205,6 +236,10 @@ function dedupeEdits(edits) {
205
236
  export function extractSession(session, projectName) {
206
237
  const memories = [];
207
238
  const file_edits = [];
239
+ // Turn-level dedup: a single user turn should yield at most ONE memory.
240
+ // Priority: goal(first_intent) > caveat > decision > context. capturedTurns
241
+ // tracks caveat-claimed turns so the decision pass skips them.
242
+ const capturedTurns = new Set();
208
243
  // Detect fully-automated sessions (e.g. scheduled cron tasks) — no user intent to extract
209
244
  const firstRawUserText = session.turns.find((t) => t.role === 'user' && !t.tool_results)?.text ?? '';
210
245
  const automated = isAutomatedSession(firstRawUserText);
@@ -265,6 +300,12 @@ export function extractSession(session, projectName) {
265
300
  continue;
266
301
  if (t.text.trim().length < 40)
267
302
  continue;
303
+ // Defer to the caveat/decision passes when they WOULD capture this turn,
304
+ // so a clarification that is really a warning/decision isn't double-saved as context.
305
+ const wouldBeCaveat = matchesAny(t.text, CAVEAT_PATTERNS) && t.text.length > 20 && !isChitchatWithBuriedDecision(t.text, CAVEAT_PATTERNS);
306
+ const wouldBeDecision = matchesAny(t.text, DECISION_PATTERNS) && t.text.length > 15 && !isChitchatWithBuriedDecision(t.text, DECISION_PATTERNS);
307
+ if (wouldBeCaveat || wouldBeDecision)
308
+ continue;
268
309
  clarifyCount++;
269
310
  const msgText = t.text.slice(0, 600);
270
311
  memories.push({
@@ -340,6 +381,8 @@ export function extractSession(session, projectName) {
340
381
  for (const t of session.turns) {
341
382
  if (t.role !== 'user' || isMetaOrNoise(t.text))
342
383
  continue;
384
+ if (t === firstIntent)
385
+ continue; // already captured as the goal/first-intent memory
343
386
  if (t.tool_results && t.tool_results.length > 0)
344
387
  continue;
345
388
  if (isPastedExternalContent(t.text))
@@ -364,6 +407,8 @@ export function extractSession(session, projectName) {
364
407
  thread_id: session.session_id,
365
408
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'caveat' },
366
409
  });
410
+ if (t.uuid)
411
+ capturedTurns.add(t.uuid); // claim this turn so the decision pass skips it
367
412
  }
368
413
  }
369
414
  // 5) Learning layer — messages matching decision patterns
@@ -374,6 +419,10 @@ export function extractSession(session, projectName) {
374
419
  for (const t of session.turns) {
375
420
  if (t.role !== 'user' || isMetaOrNoise(t.text))
376
421
  continue;
422
+ if (t === firstIntent)
423
+ continue; // already captured as the goal/first-intent memory
424
+ if (t.uuid && capturedTurns.has(t.uuid))
425
+ continue; // already captured as a caveat
377
426
  if (t.tool_results && t.tool_results.length > 0)
378
427
  continue;
379
428
  if (isPastedExternalContent(t.text))
@@ -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
+ };