linksee-memory 0.10.0 → 0.11.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/LICENSE +21 -21
- package/README.md +152 -68
- package/dist/bin/import-sessions.js +6 -6
- package/dist/bin/install-skill.js +14 -14
- package/dist/bin/map-import.d.ts +2 -0
- package/dist/bin/map-import.js +493 -0
- package/dist/bin/setup.js +14 -14
- package/dist/bin/stats.js +20 -20
- package/dist/db/migrate.js +36 -0
- package/dist/db/schema.sql +73 -2
- package/dist/lib/consolidate.js +19 -19
- package/dist/lib/drift-detection.js +38 -2
- package/dist/lib/edge-detection.js +8 -8
- package/dist/lib/guard.js +28 -28
- package/dist/lib/map-import.d.ts +55 -0
- package/dist/lib/map-import.js +150 -0
- package/dist/lib/map-reconcile.d.ts +29 -0
- package/dist/lib/map-reconcile.js +245 -0
- package/dist/lib/map-view.d.ts +103 -0
- package/dist/lib/map-view.js +201 -0
- package/dist/lib/momentum.js +7 -7
- package/dist/lib/truth-engine.js +11 -11
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +45 -0
- package/dist/skill/SKILL.md +734 -734
- package/package.json +9 -5
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, extname } from 'node:path';
|
|
3
|
+
const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.sql', '.py', '.go', '.rs', '.java', '.rb', '.php', '.sh']);
|
|
4
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.next', 'coverage', 'build']);
|
|
5
|
+
const COMMENT_PREFIXES = ['//', '*', '/*', '#', '<!--', '--'];
|
|
6
|
+
const MAX_FILES = 4000;
|
|
7
|
+
function signalHit(line, signals, skipComments) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return null;
|
|
11
|
+
// For CODE scans, a comment MENTIONS a term but doesn't realize it — skip. For DOC
|
|
12
|
+
// scans (README etc.) the content IS in those lines, so don't skip.
|
|
13
|
+
if (skipComments && COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
|
|
14
|
+
return null;
|
|
15
|
+
const lower = trimmed.toLowerCase();
|
|
16
|
+
for (const s of signals) {
|
|
17
|
+
const sl = s.toLowerCase();
|
|
18
|
+
if (/^[\x20-\x7e]+$/.test(sl)) {
|
|
19
|
+
const esc = sl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
if (new RegExp(`(?<![a-z0-9_])${esc}(?![a-z0-9_])`).test(lower))
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
else if (lower.includes(sl))
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Scan a single file (any type; e.g. README.md) for the first signal match.
|
|
29
|
+
function scanFileForSignal(file, signals, skipComments) {
|
|
30
|
+
return scanFile(file, (line) => signalHit(line, signals, skipComments));
|
|
31
|
+
}
|
|
32
|
+
// Scan a single file for the first regex match (meaning-aware: "where_am_i\s+.*(tool|MCP)").
|
|
33
|
+
function scanFileForRegex(file, re, skipComments) {
|
|
34
|
+
return scanFile(file, (line) => {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed)
|
|
37
|
+
return null;
|
|
38
|
+
if (skipComments && COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
|
|
39
|
+
return null;
|
|
40
|
+
const m = re.exec(trimmed);
|
|
41
|
+
return m ? m[0].slice(0, 60) : null;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function scanFile(file, match) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const lines = text.split('\n');
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const hit = match(lines[i]);
|
|
55
|
+
if (hit)
|
|
56
|
+
return { file, line_no: i + 1, line_text: lines[i].trim().slice(0, 200), hit };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Walk a code subtree, applying a per-file matcher (comments skipped). Returns first hit or null.
|
|
61
|
+
function scanDir(root, perFile) {
|
|
62
|
+
let scanned = 0;
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const name of entries) {
|
|
74
|
+
const full = join(dir, name);
|
|
75
|
+
let st;
|
|
76
|
+
try {
|
|
77
|
+
st = statSync(full);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (st.isDirectory()) {
|
|
83
|
+
if (!SKIP_DIRS.has(name))
|
|
84
|
+
stack.push(full);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!CODE_EXT.has(extname(name)))
|
|
88
|
+
continue;
|
|
89
|
+
if (++scanned > MAX_FILES)
|
|
90
|
+
return null;
|
|
91
|
+
const hit = perFile(full);
|
|
92
|
+
if (hit)
|
|
93
|
+
return hit;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const scanForSignal = (root, signals) => scanDir(root, (f) => scanFileForSignal(f, signals, true));
|
|
99
|
+
const scanForRegex = (root, re) => scanDir(root, (f) => scanFileForRegex(f, re, true));
|
|
100
|
+
// Scan ONLY within a named markdown section (header containing `section`, until the next
|
|
101
|
+
// same-or-higher header). Beats a bare substring: "where_am_i" must be IN the Tools section,
|
|
102
|
+
// not merely somewhere in the file (e.g. next to "未対応です").
|
|
103
|
+
function scanSection(file, section, signals) {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = readFileSync(file, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const lines = text.split('\n');
|
|
112
|
+
const want = section.toLowerCase();
|
|
113
|
+
let start = -1, level = 0;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const m = /^(#{1,6})\s+(.*)$/.exec(lines[i].trim());
|
|
116
|
+
if (m && m[2].toLowerCase().includes(want)) {
|
|
117
|
+
start = i;
|
|
118
|
+
level = m[1].length;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (start < 0)
|
|
123
|
+
return null; // section not found at all
|
|
124
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
125
|
+
const h = /^(#{1,6})\s+/.exec(lines[i].trim());
|
|
126
|
+
if (h && h[1].length <= level)
|
|
127
|
+
break; // reached the end of this section
|
|
128
|
+
const hit = signalHit(lines[i], signals, false);
|
|
129
|
+
if (hit)
|
|
130
|
+
return { file, line_no: i + 1, line_text: lines[i].trim().slice(0, 200), hit };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Run ONE check → ✓/✗ with evidence. path-targeted scans don't skip comments (docs).
|
|
135
|
+
function runCheck(check, repoRoot) {
|
|
136
|
+
if (check.kind === 'file_present' || check.kind === 'file_absent') {
|
|
137
|
+
const present = check.path ? existsSync(join(repoRoot, check.path)) : false;
|
|
138
|
+
const ok = check.kind === 'file_present' ? present : !present;
|
|
139
|
+
return { claim: check.claim, ok, file: check.path ?? null, line: null, detail: present ? 'file present' : 'file absent' };
|
|
140
|
+
}
|
|
141
|
+
// regex check — meaning-aware ("where_am_i\s+.*(tool|MCP)"), beats a bare substring
|
|
142
|
+
if (check.kind === 'regex_present' || check.kind === 'regex_absent') {
|
|
143
|
+
let re;
|
|
144
|
+
try {
|
|
145
|
+
re = new RegExp(check.pattern ?? '', 'i');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return { claim: check.claim, ok: false, file: null, line: null, detail: `bad regex: ${check.pattern}` };
|
|
149
|
+
}
|
|
150
|
+
const hit = check.path ? scanFileForRegex(join(repoRoot, check.path), re, false) : scanForRegex(join(repoRoot, check.dir ?? '.'), re);
|
|
151
|
+
const found = hit != null;
|
|
152
|
+
const ok = check.kind === 'regex_present' ? found : !found;
|
|
153
|
+
return {
|
|
154
|
+
claim: check.claim, ok, file: hit ? hit.file : (check.path ?? check.dir ?? null), line: hit ? hit.line_no : null,
|
|
155
|
+
detail: found ? `matched /${check.pattern}/` : `/${check.pattern}/ not matched`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const signals = (check.signal ?? []).filter(Boolean);
|
|
159
|
+
// section_contains — the signal must appear inside a named markdown section
|
|
160
|
+
if (check.kind === 'section_contains') {
|
|
161
|
+
const hit = check.path ? scanSection(join(repoRoot, check.path), check.section ?? '', signals) : null;
|
|
162
|
+
return {
|
|
163
|
+
claim: check.claim, ok: hit != null, file: hit ? hit.file : (check.path ?? null), line: hit ? hit.line_no : null,
|
|
164
|
+
detail: hit ? `found "${hit.hit}" in section "${check.section}"` : `"${signals.join(', ')}" not in section "${check.section}"`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
// signal scan: a single path (doc, no comment-skip) or a code subtree (comment-skip)
|
|
168
|
+
const hit = check.path
|
|
169
|
+
? scanFileForSignal(join(repoRoot, check.path), signals, false)
|
|
170
|
+
: scanForSignal(join(repoRoot, check.dir ?? '.'), signals);
|
|
171
|
+
const found = hit != null;
|
|
172
|
+
const ok = check.kind === 'signal_present' ? found : !found;
|
|
173
|
+
return {
|
|
174
|
+
claim: check.claim, ok,
|
|
175
|
+
file: hit ? hit.file : (check.path ?? check.dir ?? null), line: hit ? hit.line_no : null,
|
|
176
|
+
detail: found ? `found "${hit.hit}"` : `"${signals.join(', ')}" not found`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function evaluate(reality, repoRoot) {
|
|
180
|
+
if (!reality.kind && !reality.checks) {
|
|
181
|
+
return { verdict: null, reason: 'no reality declaration — stays human-declared', why: null, fix: [], checks: [], expected: null, check: null };
|
|
182
|
+
}
|
|
183
|
+
if (reality.kind === 'external') {
|
|
184
|
+
return {
|
|
185
|
+
verdict: null, reason: 'external state — not locally verifiable; human-confirmed',
|
|
186
|
+
why: reality.why ?? null, fix: reality.fix ?? [], checks: [],
|
|
187
|
+
expected: reality.expected ?? null, check: reality.check ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Multiple named checks, or one check from the single-kind shorthand (back-compat).
|
|
191
|
+
const checks = reality.checks ?? [{
|
|
192
|
+
claim: reality.kind === 'signal_present' ? `code implements: ${(reality.signal ?? []).join(', ')}`
|
|
193
|
+
: reality.kind === 'signal_absent' ? `forbidden absent: ${(reality.signal ?? []).join(', ')}`
|
|
194
|
+
: reality.kind === 'file_present' ? `file present: ${reality.path}`
|
|
195
|
+
: `file absent: ${reality.path}`,
|
|
196
|
+
kind: reality.kind, dir: reality.dir, path: reality.path, signal: reality.signal,
|
|
197
|
+
}];
|
|
198
|
+
const results = checks.map((c) => runCheck(c, repoRoot));
|
|
199
|
+
const failed = results.filter((r) => !r.ok);
|
|
200
|
+
const verdict = failed.length === 0 ? 'convergence' : 'divergence';
|
|
201
|
+
const why = reality.why ?? (failed.length === 0
|
|
202
|
+
? 'all checks pass — reality matches the declaration'
|
|
203
|
+
: `${failed.length} check(s) fail: ${failed.map((f) => f.claim).join('; ')}`);
|
|
204
|
+
const reason = failed.length === 0 ? `verified: ${results.length} check(s) pass` : `${failed.length}/${results.length} checks failed`;
|
|
205
|
+
return { verdict, reason, why, fix: reality.fix ?? [], checks: results, expected: null, check: null };
|
|
206
|
+
}
|
|
207
|
+
export function reconcile(db, project, repoRoot) {
|
|
208
|
+
const rows = db.prepare('SELECT id, status, reality FROM map_nodes WHERE project = ?').all(project);
|
|
209
|
+
const upd = db.prepare('UPDATE map_nodes SET live_verdict = ?, verdict_evidence = ?, reconciled_at = unixepoch() WHERE project = ? AND id = ?');
|
|
210
|
+
const verdicts = [];
|
|
211
|
+
let checked = 0, external = 0;
|
|
212
|
+
const tx = db.transaction(() => {
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
let reality = {};
|
|
215
|
+
try {
|
|
216
|
+
reality = JSON.parse(r.reality || '{}');
|
|
217
|
+
}
|
|
218
|
+
catch { /* malformed → treat as none */ }
|
|
219
|
+
const { verdict, reason, why, fix, checks, expected, check } = evaluate(reality, repoRoot);
|
|
220
|
+
const firstShown = checks.find((c) => !c.ok) ?? checks[0];
|
|
221
|
+
const evidence = {
|
|
222
|
+
reason, why, fix, checks, expected, check,
|
|
223
|
+
file: firstShown?.file ?? null, line_no: firstShown?.line ?? null, // compact convenience for the dashboard
|
|
224
|
+
};
|
|
225
|
+
if (verdict === null) {
|
|
226
|
+
external++;
|
|
227
|
+
upd.run(null, JSON.stringify(evidence), project, r.id);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
checked++;
|
|
231
|
+
upd.run(verdict, JSON.stringify(evidence), project, r.id);
|
|
232
|
+
}
|
|
233
|
+
// "flipped" = hand-declared suspect that reality contradicts (suspect→convergence),
|
|
234
|
+
// or a non-suspect that reality flags (→divergence).
|
|
235
|
+
const flipped = (r.status === 'suspect' && verdict === 'convergence')
|
|
236
|
+
|| (r.status !== 'suspect' && verdict === 'divergence');
|
|
237
|
+
verdicts.push({ id: r.id, status: r.status, verdict, reason, why, fix, checks, evidence, flipped });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
tx();
|
|
241
|
+
const refuted = verdicts.filter((v) => v.status === 'suspect' && v.verdict === 'convergence');
|
|
242
|
+
const confirmed = verdicts.filter((v) => v.status === 'suspect' && (v.verdict === 'divergence' || v.verdict === 'absence'));
|
|
243
|
+
return { project, checked, external, verdicts, refuted, confirmed };
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=map-reconcile.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export interface NodeRow {
|
|
3
|
+
id: string;
|
|
4
|
+
project: string;
|
|
5
|
+
layer: string;
|
|
6
|
+
stage: string | null;
|
|
7
|
+
statement: string;
|
|
8
|
+
status: string;
|
|
9
|
+
facets: string;
|
|
10
|
+
role: string | null;
|
|
11
|
+
note: string | null;
|
|
12
|
+
due: string | null;
|
|
13
|
+
paused_reason: string | null;
|
|
14
|
+
related_project: string | null;
|
|
15
|
+
spinout_candidate: number;
|
|
16
|
+
anchor_id: number | null;
|
|
17
|
+
live_verdict?: string | null;
|
|
18
|
+
verdict_evidence?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface EdgeRow {
|
|
21
|
+
from_id: string;
|
|
22
|
+
to_id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
strength: string | null;
|
|
25
|
+
note: string | null;
|
|
26
|
+
}
|
|
27
|
+
export type EdgeStrength = 'hard' | 'soft' | 'watch';
|
|
28
|
+
export declare const edgeStrength: (e: {
|
|
29
|
+
type: string;
|
|
30
|
+
strength?: string | null;
|
|
31
|
+
}) => EdgeStrength;
|
|
32
|
+
export type StatusColor = 'green' | 'red' | 'gray' | 'amber' | 'blue';
|
|
33
|
+
export declare function statusColor(status: string): StatusColor;
|
|
34
|
+
export declare function verdictColor(verdict: string): StatusColor;
|
|
35
|
+
export declare function nodeColor(n: {
|
|
36
|
+
status: string;
|
|
37
|
+
live_verdict?: string | null;
|
|
38
|
+
}): StatusColor;
|
|
39
|
+
export interface ProjectMeta {
|
|
40
|
+
project: string;
|
|
41
|
+
job: string | null;
|
|
42
|
+
audience: Record<string, unknown>;
|
|
43
|
+
product_status: string | null;
|
|
44
|
+
template: string | null;
|
|
45
|
+
stages: Array<{
|
|
46
|
+
id: string;
|
|
47
|
+
label?: string;
|
|
48
|
+
}>;
|
|
49
|
+
related_projects: unknown[];
|
|
50
|
+
}
|
|
51
|
+
export declare function getProjectMeta(db: Database.Database, project: string): ProjectMeta | undefined;
|
|
52
|
+
export declare function listMapProjects(db: Database.Database): string[];
|
|
53
|
+
export declare function getNode(db: Database.Database, project: string, id: string): NodeRow | undefined;
|
|
54
|
+
export declare function getSuspects(db: Database.Database, project: string): NodeRow[];
|
|
55
|
+
export interface BlastHit {
|
|
56
|
+
id: string;
|
|
57
|
+
statement: string;
|
|
58
|
+
status: string;
|
|
59
|
+
via: string;
|
|
60
|
+
relation: string;
|
|
61
|
+
strength: EdgeStrength;
|
|
62
|
+
}
|
|
63
|
+
export declare function blastRadius(db: Database.Database, project: string, id: string): BlastHit[];
|
|
64
|
+
export interface WhereAmIMatch {
|
|
65
|
+
node: NodeRow;
|
|
66
|
+
stage_label: string | null;
|
|
67
|
+
match_reason: string;
|
|
68
|
+
blast: BlastHit[];
|
|
69
|
+
anchor: {
|
|
70
|
+
id: number;
|
|
71
|
+
statement: string;
|
|
72
|
+
} | null;
|
|
73
|
+
}
|
|
74
|
+
export interface WhereAmIResult {
|
|
75
|
+
project: string;
|
|
76
|
+
job: string | null;
|
|
77
|
+
matched: WhereAmIMatch[];
|
|
78
|
+
}
|
|
79
|
+
export declare function whereAmI(db: Database.Database, opts: {
|
|
80
|
+
project?: string;
|
|
81
|
+
query?: string;
|
|
82
|
+
node_id?: string;
|
|
83
|
+
limit?: number;
|
|
84
|
+
}): WhereAmIResult;
|
|
85
|
+
export interface BlueprintCell {
|
|
86
|
+
stage: string;
|
|
87
|
+
label: string;
|
|
88
|
+
nodes: Array<NodeRow & {
|
|
89
|
+
color: StatusColor;
|
|
90
|
+
}>;
|
|
91
|
+
}
|
|
92
|
+
export interface Blueprint {
|
|
93
|
+
project: string;
|
|
94
|
+
stages: BlueprintCell[];
|
|
95
|
+
implementation: Array<NodeRow & {
|
|
96
|
+
color: StatusColor;
|
|
97
|
+
}>;
|
|
98
|
+
counts: Record<string, number>;
|
|
99
|
+
}
|
|
100
|
+
export declare function getBlueprint(db: Database.Database, project: string, stageOrder?: Array<{
|
|
101
|
+
id: string;
|
|
102
|
+
label?: string;
|
|
103
|
+
}>): Blueprint;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const DEFAULT_STRENGTH = {
|
|
2
|
+
'must-stay-consistent-with': 'hard',
|
|
3
|
+
realizes: 'hard',
|
|
4
|
+
'should-align-with': 'soft',
|
|
5
|
+
supports: 'soft',
|
|
6
|
+
mentions: 'watch',
|
|
7
|
+
reflux: 'watch',
|
|
8
|
+
};
|
|
9
|
+
export const edgeStrength = (e) => e.strength || DEFAULT_STRENGTH[e.type] || 'soft';
|
|
10
|
+
export function statusColor(status) {
|
|
11
|
+
switch (status) {
|
|
12
|
+
case 'active':
|
|
13
|
+
case 'commitment': return 'green'; // convergence (realized / on the hook)
|
|
14
|
+
case 'suspect': return 'red'; // divergence (reality may contradict)
|
|
15
|
+
case 'paused':
|
|
16
|
+
case 'planned':
|
|
17
|
+
case 'future_thesis': return 'gray'; // absence (declared, not realized)
|
|
18
|
+
case 'experiment': return 'amber'; // in flight, allowed to wobble
|
|
19
|
+
default: return 'blue';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// A live reconciler verdict (when present) OVERRIDES the declared-status color —
|
|
23
|
+
// reality wins over what we hand-declared.
|
|
24
|
+
export function verdictColor(verdict) {
|
|
25
|
+
switch (verdict) {
|
|
26
|
+
case 'convergence': return 'green';
|
|
27
|
+
case 'divergence': return 'red';
|
|
28
|
+
case 'absence': return 'gray';
|
|
29
|
+
default: return 'blue';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function nodeColor(n) {
|
|
33
|
+
return n.live_verdict ? verdictColor(n.live_verdict) : statusColor(n.status);
|
|
34
|
+
}
|
|
35
|
+
export function getProjectMeta(db, project) {
|
|
36
|
+
const r = db.prepare('SELECT * FROM map_projects WHERE project = ?').get(project);
|
|
37
|
+
if (!r)
|
|
38
|
+
return undefined;
|
|
39
|
+
const j = (s, dflt) => { try {
|
|
40
|
+
return JSON.parse(s);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return dflt;
|
|
44
|
+
} };
|
|
45
|
+
return {
|
|
46
|
+
project: r.project, job: r.job, audience: j(r.audience, {}), product_status: r.product_status,
|
|
47
|
+
template: r.template, stages: j(r.stages, []), related_projects: j(r.related_projects, []),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function listMapProjects(db) {
|
|
51
|
+
return db.prepare('SELECT project FROM map_projects ORDER BY project').all().map((r) => r.project);
|
|
52
|
+
}
|
|
53
|
+
export function getNode(db, project, id) {
|
|
54
|
+
return db.prepare('SELECT * FROM map_nodes WHERE project = ? AND id = ?').get(project, id);
|
|
55
|
+
}
|
|
56
|
+
export function getSuspects(db, project) {
|
|
57
|
+
return db.prepare("SELECT * FROM map_nodes WHERE project = ? AND status = 'suspect' ORDER BY stage, id").all(project);
|
|
58
|
+
}
|
|
59
|
+
// 1-hop blast radius. Propagation rules, by edge type:
|
|
60
|
+
// must-stay-consistent-with — SYMMETRIC: touch either end, the other is suspect.
|
|
61
|
+
// realizes / supports — DIRECTIONAL: an out-of-band change to the SOURCE
|
|
62
|
+
// threatens the TARGET (the thing it realizes/supports). We also surface the
|
|
63
|
+
// reverse for `realizes` (changing a surface's intent implicates its impl).
|
|
64
|
+
// reflux — informational only (expand→discover feedback), not
|
|
65
|
+
// a breakage path; excluded from the suspect set.
|
|
66
|
+
export function blastRadius(db, project, id) {
|
|
67
|
+
const edges = db.prepare('SELECT from_id, to_id, type, strength, note FROM map_edges WHERE project = ? AND (from_id = ? OR to_id = ?)')
|
|
68
|
+
.all(project, id, id);
|
|
69
|
+
const hits = new Map();
|
|
70
|
+
const rank = { hard: 0, soft: 1, watch: 2 };
|
|
71
|
+
const add = (other, relation, strength) => {
|
|
72
|
+
if (other === id)
|
|
73
|
+
return;
|
|
74
|
+
const existing = hits.get(other);
|
|
75
|
+
if (existing && rank[existing.strength] <= rank[strength])
|
|
76
|
+
return; // keep the stronger edge
|
|
77
|
+
const n = getNode(db, project, other);
|
|
78
|
+
if (!n)
|
|
79
|
+
return;
|
|
80
|
+
hits.set(other, { id: n.id, statement: n.statement, status: n.status, via: id, relation, strength });
|
|
81
|
+
};
|
|
82
|
+
for (const e of edges) {
|
|
83
|
+
if (e.type === 'reflux')
|
|
84
|
+
continue; // feedback loop, not a dependency path
|
|
85
|
+
const s = edgeStrength(e);
|
|
86
|
+
if (e.type === 'must-stay-consistent-with')
|
|
87
|
+
add(e.from_id === id ? e.to_id : e.from_id, e.type, s);
|
|
88
|
+
else if (e.type === 'should-align-with')
|
|
89
|
+
add(e.from_id === id ? e.to_id : e.from_id, e.type, s);
|
|
90
|
+
else if (e.type === 'mentions')
|
|
91
|
+
add(e.from_id === id ? e.to_id : e.from_id, e.type, s);
|
|
92
|
+
else if (e.type === 'realizes') {
|
|
93
|
+
if (e.from_id === id)
|
|
94
|
+
add(e.to_id, 'realizes→ (impl change threatens surface)', s);
|
|
95
|
+
else
|
|
96
|
+
add(e.from_id, '←realizes (surface intent change implicates impl)', s);
|
|
97
|
+
}
|
|
98
|
+
else if (e.type === 'supports') {
|
|
99
|
+
if (e.from_id === id)
|
|
100
|
+
add(e.to_id, 'supports→ (this underpins it)', s);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// hard first, then soft, then watch
|
|
104
|
+
return [...hits.values()].sort((a, b) => rank[a.strength] - rank[b.strength]);
|
|
105
|
+
}
|
|
106
|
+
function queryTerms(q) {
|
|
107
|
+
return q.toLowerCase().split(/[\s、。,.\/_()「」"'`::]+/).map((t) => t.trim()).filter((t) => t.length >= 2);
|
|
108
|
+
}
|
|
109
|
+
export function whereAmI(db, opts) {
|
|
110
|
+
const project = opts.project
|
|
111
|
+
?? db.prepare('SELECT project FROM map_projects ORDER BY updated_at DESC LIMIT 1').get()?.project
|
|
112
|
+
?? '';
|
|
113
|
+
const meta = project ? getProjectMeta(db, project) : undefined;
|
|
114
|
+
const stageLabel = (stageId) => stageId ? meta?.stages.find((s) => s.id === stageId)?.label ?? stageId : null;
|
|
115
|
+
const anchorOf = (n) => n.anchor_id != null
|
|
116
|
+
? db.prepare('SELECT id, statement FROM drift_anchors WHERE id = ?').get(n.anchor_id) ?? null
|
|
117
|
+
: null;
|
|
118
|
+
const wrap = (n, reason) => ({
|
|
119
|
+
node: n, stage_label: stageLabel(n.stage), match_reason: reason,
|
|
120
|
+
blast: blastRadius(db, project, n.id), anchor: anchorOf(n),
|
|
121
|
+
});
|
|
122
|
+
const limit = opts.limit ?? 3;
|
|
123
|
+
const norm = (p) => p.replace(/\\/g, '/');
|
|
124
|
+
const nodePaths = (n) => {
|
|
125
|
+
let r = {};
|
|
126
|
+
try {
|
|
127
|
+
r = JSON.parse(n.reality || '{}');
|
|
128
|
+
}
|
|
129
|
+
catch { /* none */ }
|
|
130
|
+
const ps = [];
|
|
131
|
+
if (r.path)
|
|
132
|
+
ps.push(norm(r.path));
|
|
133
|
+
for (const c of (r.checks ?? []))
|
|
134
|
+
if (c.path)
|
|
135
|
+
ps.push(norm(c.path));
|
|
136
|
+
return ps;
|
|
137
|
+
};
|
|
138
|
+
// 1. exact node id
|
|
139
|
+
if (opts.node_id) {
|
|
140
|
+
const n = getNode(db, project, opts.node_id);
|
|
141
|
+
return { project, job: meta?.job ?? null, matched: n ? [wrap(n, 'exact node id')] : [] };
|
|
142
|
+
}
|
|
143
|
+
// 2. AUTO-INFERENCE: no query/node_id → locate from what you just edited (the per-turn
|
|
144
|
+
// re-anchor with zero args). Recent edit/write file paths → the node that owns the file.
|
|
145
|
+
if (!opts.query) {
|
|
146
|
+
const recents = db.prepare("SELECT file_path, MAX(occurred_at) AS t FROM session_file_edits WHERE operation IN ('edit','write') GROUP BY file_path ORDER BY t DESC LIMIT 25").all();
|
|
147
|
+
const all = db.prepare('SELECT * FROM map_nodes WHERE project = ?').all(project);
|
|
148
|
+
const matched = [];
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const r of recents) {
|
|
151
|
+
const fp = norm(r.file_path);
|
|
152
|
+
for (const n of all) {
|
|
153
|
+
if (seen.has(n.id))
|
|
154
|
+
continue;
|
|
155
|
+
if (nodePaths(n).some((p) => fp === p || fp.endsWith('/' + p))) {
|
|
156
|
+
seen.add(n.id);
|
|
157
|
+
matched.push(wrap(n, `recently edited ${r.file_path.split(/[\\/]/).pop()}`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (matched.length >= limit)
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
return { project, job: meta?.job ?? null, matched: matched.slice(0, limit) };
|
|
164
|
+
}
|
|
165
|
+
// 3. lexical scoring over id + statement + note + facets
|
|
166
|
+
const terms = queryTerms(opts.query ?? '');
|
|
167
|
+
const all = db.prepare('SELECT * FROM map_nodes WHERE project = ?').all(project);
|
|
168
|
+
const scored = all.map((n) => {
|
|
169
|
+
const hay = `${n.id} ${n.statement} ${n.note ?? ''} ${n.facets}`.toLowerCase();
|
|
170
|
+
let score = 0;
|
|
171
|
+
if (terms.some((t) => n.id.toLowerCase() === t))
|
|
172
|
+
score += 10; // id hit dominates
|
|
173
|
+
for (const t of terms)
|
|
174
|
+
if (hay.includes(t))
|
|
175
|
+
score += n.id.toLowerCase().includes(t) ? 3 : 1;
|
|
176
|
+
return { n, score };
|
|
177
|
+
}).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
178
|
+
return {
|
|
179
|
+
project, job: meta?.job ?? null,
|
|
180
|
+
matched: scored.map((x) => wrap(x.n, `lexical match (score ${x.score})`)),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// The dashboard home: surface nodes laid out by journey stage (the columns),
|
|
184
|
+
// implementation nodes in their own tray, every node carrying a status color.
|
|
185
|
+
export function getBlueprint(db, project, stageOrder) {
|
|
186
|
+
// Fall back to the persisted canonical spine when the caller didn't pass one (the dashboard path).
|
|
187
|
+
const order = stageOrder ?? getProjectMeta(db, project)?.stages ?? [];
|
|
188
|
+
const all = db.prepare('SELECT * FROM map_nodes WHERE project = ? ORDER BY stage, id').all(project);
|
|
189
|
+
const withColor = (n) => ({ ...n, color: nodeColor(n) });
|
|
190
|
+
const stages = order.map((s) => ({
|
|
191
|
+
stage: s.id,
|
|
192
|
+
label: s.label ?? s.id,
|
|
193
|
+
nodes: all.filter((n) => n.layer === 'surface' && n.stage === s.id).map(withColor),
|
|
194
|
+
}));
|
|
195
|
+
const implementation = all.filter((n) => n.layer === 'implementation').map(withColor);
|
|
196
|
+
const counts = {};
|
|
197
|
+
for (const n of all)
|
|
198
|
+
counts[n.status] = (counts[n.status] ?? 0) + 1;
|
|
199
|
+
return { project, stages, implementation, counts };
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=map-view.js.map
|
package/dist/lib/momentum.js
CHANGED
|
@@ -52,13 +52,13 @@ export function refreshMomentumForEntity(db, entityId) {
|
|
|
52
52
|
const dayAgo = now - 86400;
|
|
53
53
|
const weekAgo = now - 7 * 86400;
|
|
54
54
|
const stats = db
|
|
55
|
-
.prepare(`
|
|
56
|
-
SELECT
|
|
57
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e24,
|
|
58
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e7d,
|
|
59
|
-
(SELECT COUNT(*) FROM events WHERE entity_id = ?) as eAll,
|
|
60
|
-
(SELECT created_at FROM entities WHERE id = ?) as createdAt,
|
|
61
|
-
(SELECT COALESCE(AVG(importance), 0.5) FROM memories WHERE entity_id = ? AND created_at >= ?) as avgImpRecent
|
|
55
|
+
.prepare(`
|
|
56
|
+
SELECT
|
|
57
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e24,
|
|
58
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ? AND occurred_at >= ?) as e7d,
|
|
59
|
+
(SELECT COUNT(*) FROM events WHERE entity_id = ?) as eAll,
|
|
60
|
+
(SELECT created_at FROM entities WHERE id = ?) as createdAt,
|
|
61
|
+
(SELECT COALESCE(AVG(importance), 0.5) FROM memories WHERE entity_id = ? AND created_at >= ?) as avgImpRecent
|
|
62
62
|
`)
|
|
63
63
|
.get(entityId, dayAgo, entityId, weekAgo, entityId, entityId, entityId, weekAgo);
|
|
64
64
|
const daysObserved = stats.createdAt ? Math.max(1, (now - stats.createdAt) / 86400) : 1;
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -96,7 +96,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
96
96
|
const resolutionFor = buildResolutionLookup(db);
|
|
97
97
|
// ── Candidates (indexed by target node) ──
|
|
98
98
|
const candRows = db
|
|
99
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
99
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status, proposed_node
|
|
100
100
|
FROM memory_write_candidates ORDER BY id DESC`)
|
|
101
101
|
.all();
|
|
102
102
|
const pendingByNode = new Map();
|
|
@@ -113,8 +113,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
// ── Active nodes + state derivation ──
|
|
116
|
-
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
117
|
-
card_policy, review_after
|
|
116
|
+
let sql = `SELECT id, node_type, domain, decision_mode, statement, rationale, confidence, lifecycle,
|
|
117
|
+
card_policy, review_after
|
|
118
118
|
FROM drift_anchors WHERE status = 'active'`;
|
|
119
119
|
const params = [];
|
|
120
120
|
if (opts.domain) {
|
|
@@ -264,8 +264,8 @@ export function getTruthView(db, opts = {}) {
|
|
|
264
264
|
// ── check_decision: single-node deep view ────────────────────────────────────
|
|
265
265
|
export function getDecisionDetail(db, anchorId) {
|
|
266
266
|
const row = db
|
|
267
|
-
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
268
|
-
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
267
|
+
.prepare(`SELECT id, kind, node_type, domain, decision_mode, statement, rationale, confidence,
|
|
268
|
+
lifecycle, card_policy, review_after, affects, detect_terms, violation_signal, tier
|
|
269
269
|
FROM drift_anchors WHERE id = ? AND status = 'active'`)
|
|
270
270
|
.get(anchorId);
|
|
271
271
|
if (!row)
|
|
@@ -283,14 +283,14 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
283
283
|
// State derivation (same logic)
|
|
284
284
|
let state, accounted, accountedBy;
|
|
285
285
|
const pendingCand = db
|
|
286
|
-
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
287
|
-
FROM memory_write_candidates
|
|
288
|
-
WHERE target_node_id = ? AND status = 'pending_review'
|
|
286
|
+
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
287
|
+
FROM memory_write_candidates
|
|
288
|
+
WHERE target_node_id = ? AND status = 'pending_review'
|
|
289
289
|
ORDER BY id DESC`)
|
|
290
290
|
.all(anchorId);
|
|
291
291
|
const cardCand = db
|
|
292
|
-
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
293
|
-
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
292
|
+
.prepare(`SELECT rationale FROM memory_write_candidates
|
|
293
|
+
WHERE target_node_id = ? AND proposed_node LIKE '%"src":"t%'
|
|
294
294
|
ORDER BY id DESC LIMIT 1`)
|
|
295
295
|
.get(anchorId);
|
|
296
296
|
const hasPending = pendingCand.length > 0;
|
|
@@ -328,7 +328,7 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
328
328
|
?? (state === 'aligned' ? 'Committed reality matches intent (convergent)' : null);
|
|
329
329
|
// Drift edges for this anchor
|
|
330
330
|
const edges = db
|
|
331
|
-
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
331
|
+
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
|
332
332
|
FROM drift_edges WHERE anchor_id = ? ORDER BY detected_at DESC`)
|
|
333
333
|
.all(anchorId);
|
|
334
334
|
return {
|
package/dist/mcp/read-smart.js
CHANGED
|
@@ -30,14 +30,14 @@ export function handleReadSmart(db, args) {
|
|
|
30
30
|
const fileHash = hashFile(content);
|
|
31
31
|
const chunks = chunkFile(path, content);
|
|
32
32
|
const chunkMeta = chunks.map(toMeta);
|
|
33
|
-
db.prepare(`INSERT INTO file_snapshots (path, content_hash, mtime, size_bytes, chunks, last_read_at, read_count)
|
|
34
|
-
VALUES (?, ?, ?, ?, ?, unixepoch(), 1)
|
|
35
|
-
ON CONFLICT(path) DO UPDATE SET
|
|
36
|
-
content_hash = excluded.content_hash,
|
|
37
|
-
mtime = excluded.mtime,
|
|
38
|
-
size_bytes = excluded.size_bytes,
|
|
39
|
-
chunks = excluded.chunks,
|
|
40
|
-
last_read_at = unixepoch(),
|
|
33
|
+
db.prepare(`INSERT INTO file_snapshots (path, content_hash, mtime, size_bytes, chunks, last_read_at, read_count)
|
|
34
|
+
VALUES (?, ?, ?, ?, ?, unixepoch(), 1)
|
|
35
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
36
|
+
content_hash = excluded.content_hash,
|
|
37
|
+
mtime = excluded.mtime,
|
|
38
|
+
size_bytes = excluded.size_bytes,
|
|
39
|
+
chunks = excluded.chunks,
|
|
40
|
+
last_read_at = unixepoch(),
|
|
41
41
|
read_count = read_count + 1`).run(path, fileHash, mtime, size, JSON.stringify(chunkMeta));
|
|
42
42
|
return JSON.stringify({
|
|
43
43
|
ok: true,
|