@lotargo/memory_plugin 1.4.621 → 1.5.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 +352 -366
- package/mcp-server/admin/auth.js +31 -4
- package/mcp-server/admin/snapshot.js +19 -7
- package/mcp-server/cli/direct_commands.js +313 -0
- package/mcp-server/cli/handlers/cloud_actions.js +138 -0
- package/mcp-server/cli/handlers/diagnostics_actions.js +107 -0
- package/mcp-server/cli/handlers/engine_actions.js +214 -0
- package/mcp-server/cli/handlers/prompt_actions.js +24 -0
- package/mcp-server/cli/handlers/storage_actions.js +749 -0
- package/mcp-server/cli/quick_stats.js +39 -0
- package/mcp-server/cli/ui.js +565 -0
- package/mcp-server/cli.js +324 -2085
- package/mcp-server/config/auth_store.js +56 -9
- package/mcp-server/config/config_manager.js +1 -0
- package/mcp-server/db/database.js +14 -1
- package/mcp-server/db/migrations.js +28 -0
- package/mcp-server/fact_format.js +244 -177
- package/mcp-server/graph/graph_extractor.js +20 -5
- package/mcp-server/identity.js +152 -0
- package/mcp-server/index.js +42 -679
- package/mcp-server/ingest/normalizer.js +40 -4
- package/mcp-server/ingest/pipeline.js +6 -13
- package/mcp-server/memory.js +50 -63
- package/mcp-server/prompt_manager.js +1 -1
- package/mcp-server/retrieval/retriever.js +59 -42
- package/mcp-server/tools/helpers.js +39 -0
- package/mcp-server/tools/identity_tools.js +277 -0
- package/mcp-server/tools/index.js +9 -0
- package/mcp-server/tools/memory_tools.js +506 -0
- package/mcp-server/tools/rag_tools.js +235 -0
- package/opencode-plugin/index.js +460 -48
- package/package.json +7 -3
- package/skills/using-memory/SKILL.md +31 -14
- package/mcp-server/benchmarks/fetch_real_corpus.js +0 -351
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +0 -170
- package/mcp-server/benchmarks/quality_evaluator.js +0 -600
- package/mcp-server/benchmarks/run_benchmarks.js +0 -347
- package/mcp-server/benchmarks/stress_ingestion.js +0 -195
- package/mcp-server/benchmarks/test_dual_layer.js +0 -140
|
@@ -1,177 +1,244 @@
|
|
|
1
|
-
// Shared fact-line format + metadata parsing for the Notebook Layer 1 store.
|
|
2
|
-
//
|
|
3
|
-
// A fact line looks like:
|
|
4
|
-
// - [2026-08-02 06:08] text here <!-- id:8f3a2c, ttl:90d, keep:1, supersedes:a1b2c3, tags:pref,arch -->
|
|
5
|
-
// The trailing HTML comment carries optional metadata. It is invisible in
|
|
6
|
-
// Markdown, so the store stays a single plain Markdown file. Older lines
|
|
7
|
-
// without a comment parse to empty metadata and stay fully compatible.
|
|
8
|
-
//
|
|
9
|
-
// Metadata keys:
|
|
10
|
-
// id short random id (e.g. "8f3a2c")
|
|
11
|
-
// ttl time-to-live, e.g. "90d", "2w", "24h", "12m" (m=month ~30d)
|
|
12
|
-
// keep "1" = protected fact: forget refuses to delete without force
|
|
13
|
-
// supersedes id (or number/text) this fact replaces
|
|
14
|
-
// supersededBy id of the fact that replaced this one
|
|
15
|
-
// tags comma-separated free-form tags for recall filtering
|
|
16
|
-
|
|
17
|
-
const META_KEYS = ["id", "ttl", "keep", "
|
|
18
|
-
|
|
19
|
-
const ENTRY_RE = /^- \[(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]\s+(.*)$/;
|
|
20
|
-
|
|
21
|
-
// Parse a raw entry line into { line, date, time, text, meta }.
|
|
22
|
-
// Returns null if the line is not a fact entry.
|
|
23
|
-
export function parseFactEntry(line) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
if (
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
}
|
|
1
|
+
// Shared fact-line format + metadata parsing for the Notebook Layer 1 store.
|
|
2
|
+
//
|
|
3
|
+
// A fact line looks like:
|
|
4
|
+
// - [2026-08-02 06:08] text here <!-- id:8f3a2c, ttl:90d, keep:1, supersedes:a1b2c3, tags:pref,arch -->
|
|
5
|
+
// The trailing HTML comment carries optional metadata. It is invisible in
|
|
6
|
+
// Markdown, so the store stays a single plain Markdown file. Older lines
|
|
7
|
+
// without a comment parse to empty metadata and stay fully compatible.
|
|
8
|
+
//
|
|
9
|
+
// Metadata keys:
|
|
10
|
+
// id short random id (e.g. "8f3a2c")
|
|
11
|
+
// ttl time-to-live, e.g. "90d", "2w", "24h", "12m" (m=month ~30d)
|
|
12
|
+
// keep "1" = protected fact: forget refuses to delete without force
|
|
13
|
+
// supersedes id (or number/text) this fact replaces
|
|
14
|
+
// supersededBy id of the fact that replaced this one
|
|
15
|
+
// tags comma-separated free-form tags for recall filtering
|
|
16
|
+
|
|
17
|
+
const META_KEYS = ["id", "ttl", "keep", "supersededBy", "supersedes", "tags", "inject"];
|
|
18
|
+
|
|
19
|
+
const ENTRY_RE = /^- \[(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\]\s+(.*)$/;
|
|
20
|
+
|
|
21
|
+
// Parse a raw entry line into { line, date, time, text, meta }.
|
|
22
|
+
// Returns null if the line is not a fact entry.
|
|
23
|
+
export function parseFactEntry(line) {
|
|
24
|
+
line = String(line).replace(/\r$/, "");
|
|
25
|
+
const m = ENTRY_RE.exec(line);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const [, date, time, rest] = m;
|
|
28
|
+
let text = rest;
|
|
29
|
+
const meta = {};
|
|
30
|
+
const cm = /^(.*?)\s*<!--\s*(.*?)\s*-->$/.exec(rest);
|
|
31
|
+
if (cm) {
|
|
32
|
+
// Values may themselves contain commas (e.g. "tags:pref,arch"), so scan
|
|
33
|
+
// for known "key:" tokens instead of splitting the comment on commas.
|
|
34
|
+
const raw = cm[2];
|
|
35
|
+
const keyRe = new RegExp(`(${META_KEYS.join("|")})\\s*:`, "g");
|
|
36
|
+
const found = [];
|
|
37
|
+
let fm;
|
|
38
|
+
while ((fm = keyRe.exec(raw))) {
|
|
39
|
+
found.push({ key: fm[1], valStart: fm.index + fm[0].length, tokenStart: fm.index });
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < found.length; i++) {
|
|
42
|
+
const valEnd = i + 1 < found.length ? found[i + 1].tokenStart : raw.length;
|
|
43
|
+
const v = raw.slice(found[i].valStart, valEnd).replace(/[,\s]+$/, "").trim();
|
|
44
|
+
if (v !== "") {
|
|
45
|
+
meta[found[i].key] = v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(meta).length) text = cm[1];
|
|
49
|
+
}
|
|
50
|
+
return { line, date, time, text, meta };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Display text of a fact line (metadata comment stripped).
|
|
54
|
+
export function factText(line) {
|
|
55
|
+
const p = parseFactEntry(line);
|
|
56
|
+
return p ? p.text : line;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Metadata object of a fact line (empty if none / unparsable).
|
|
60
|
+
export function factMeta(line) {
|
|
61
|
+
const p = parseFactEntry(line);
|
|
62
|
+
return p ? p.meta : {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Format a fact line from parts. `meta` entries with falsy values are omitted.
|
|
66
|
+
export function formatFactEntry({ date, time, text, meta = {} }) {
|
|
67
|
+
let out = `- [${date} ${time}] ${text}`;
|
|
68
|
+
const pairs = [];
|
|
69
|
+
for (const k of META_KEYS) {
|
|
70
|
+
if (meta[k]) pairs.push(`${k}:${meta[k]}`);
|
|
71
|
+
}
|
|
72
|
+
if (pairs.length) out += ` <!-- ${pairs.join(", ")} -->`;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Return a new line with `patch` applied to the metadata. Patch values that are
|
|
77
|
+
// null/undefined/"" remove the corresponding key. Non-fact lines are returned
|
|
78
|
+
// unchanged.
|
|
79
|
+
export function withMeta(line, patch) {
|
|
80
|
+
const p = parseFactEntry(line);
|
|
81
|
+
if (!p) return line;
|
|
82
|
+
const meta = { ...p.meta };
|
|
83
|
+
for (const k of Object.keys(patch)) {
|
|
84
|
+
const v = patch[k];
|
|
85
|
+
if (v === null || v === undefined || v === "") delete meta[k];
|
|
86
|
+
else meta[k] = String(v);
|
|
87
|
+
}
|
|
88
|
+
return formatFactEntry({ date: p.date, time: p.time, text: p.text, meta });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Generate a short random id that is unique within the given entries.
|
|
92
|
+
export function nextFactId(entries) {
|
|
93
|
+
let id;
|
|
94
|
+
do {
|
|
95
|
+
id = Math.random().toString(36).slice(2, 8);
|
|
96
|
+
} while (entries.some((e) => factMeta(e).id === id));
|
|
97
|
+
return id;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const TTL_UNITS = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
|
|
101
|
+
|
|
102
|
+
// Parse "90d" | "2w" | "24h" | "12m" (m = ~30 days) into milliseconds. Null if invalid.
|
|
103
|
+
export function ttlMs(ttl) {
|
|
104
|
+
const m = /^(\d+)\s*([hdwm])?$/.exec(String(ttl || "").trim());
|
|
105
|
+
if (!m) return null;
|
|
106
|
+
const n = parseInt(m[1], 10);
|
|
107
|
+
const u = m[2] || "d";
|
|
108
|
+
return n * (TTL_UNITS[u] || TTL_UNITS.d);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// True if the fact line has a ttl and it has elapsed relative to `now` (ms).
|
|
112
|
+
export function isExpiredLine(line, now = Date.now()) {
|
|
113
|
+
const p = parseFactEntry(line);
|
|
114
|
+
if (!p || !p.meta.ttl) return false;
|
|
115
|
+
const ms = ttlMs(p.meta.ttl);
|
|
116
|
+
if (!ms) return false;
|
|
117
|
+
const ts = new Date(`${p.date}T${p.time}:00`).getTime();
|
|
118
|
+
if (Number.isNaN(ts)) return false;
|
|
119
|
+
return now > ts + ms;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// True if the fact is protected from deletion (keep:1).
|
|
123
|
+
export function isKeepFact(line) {
|
|
124
|
+
return factMeta(line).keep === "1";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// True if the fact has been superseded by another fact.
|
|
128
|
+
export function isSuperseded(line) {
|
|
129
|
+
return Boolean(factMeta(line).supersededBy);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Keyword match: space-separated terms, all must be present (case-insensitive).
|
|
133
|
+
// Also matches against the fact's id and tags.
|
|
134
|
+
export function matchesQuery(factLine, query) {
|
|
135
|
+
const q = String(query || "").trim();
|
|
136
|
+
if (!q) return true;
|
|
137
|
+
const p = parseFactEntry(factLine);
|
|
138
|
+
if (!p) return false;
|
|
139
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
140
|
+
const haystack = `${p.text} ${p.meta.id || ""} ${p.meta.tags || ""} ${p.date}`.toLowerCase();
|
|
141
|
+
return terms.every((t) => haystack.includes(t));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tag filter: comma-separated requested tags; match if ANY fact tag equals or
|
|
145
|
+
// contains a requested tag (case-insensitive).
|
|
146
|
+
export function matchesTags(factLine, tagsStr) {
|
|
147
|
+
const want = String(tagsStr || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
148
|
+
if (!want.length) return true;
|
|
149
|
+
const have = (factMeta(factLine).tags || "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
150
|
+
if (!have.length) return false;
|
|
151
|
+
return want.some((w) => have.some((h) => h === w || h.includes(w) || w.includes(h)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Date-range filter. `since`/`until` are "YYYY-MM-DD" (inclusive).
|
|
155
|
+
export function inDateRange(factLine, since, until) {
|
|
156
|
+
const p = parseFactEntry(factLine);
|
|
157
|
+
if (!p) return false;
|
|
158
|
+
if (since && p.date < since) return false;
|
|
159
|
+
if (until && p.date > until) return false;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Human-readable badges for a fact line, e.g. ["EXPIRED", "KEEP", "SUPERSEDED"].
|
|
164
|
+
export function metaBadges(factLine, now = Date.now()) {
|
|
165
|
+
const badges = [];
|
|
166
|
+
if (isExpiredLine(factLine, now)) badges.push("EXPIRED");
|
|
167
|
+
if (isKeepFact(factLine)) badges.push("KEEP");
|
|
168
|
+
if (isSuperseded(factLine)) badges.push("SUPERSEDED");
|
|
169
|
+
return badges;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Display text of a fact line with badges appended, e.g.
|
|
173
|
+
// "user prefers TS [EXPIRED] [KEEP]"
|
|
174
|
+
export function displayFact(factLine, now = Date.now()) {
|
|
175
|
+
const text = factText(factLine);
|
|
176
|
+
const badges = metaBadges(factLine, now);
|
|
177
|
+
return badges.length ? `${text} [${badges.join("] [")}]` : text;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Part A1: Title + Body support
|
|
181
|
+
|
|
182
|
+
export function factTitle(line) {
|
|
183
|
+
const p = parseFactEntry(line);
|
|
184
|
+
if (!p) return "";
|
|
185
|
+
const text = p.text;
|
|
186
|
+
const m = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/.exec(text);
|
|
187
|
+
if (m) {
|
|
188
|
+
return m[1].trim();
|
|
189
|
+
}
|
|
190
|
+
const parts = text.split(/(?:\s*(?:—|--)\s*|\s+-\s+|\.)/);
|
|
191
|
+
if (parts.length > 0 && parts[0].trim()) {
|
|
192
|
+
return parts[0].trim();
|
|
193
|
+
}
|
|
194
|
+
return text.trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function factBody(line) {
|
|
198
|
+
const p = parseFactEntry(line);
|
|
199
|
+
if (!p) return "";
|
|
200
|
+
const text = p.text;
|
|
201
|
+
const m = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/.exec(text);
|
|
202
|
+
if (m) {
|
|
203
|
+
return m[2].trim();
|
|
204
|
+
}
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function autoGenerateTitle(fact) {
|
|
209
|
+
const f = String(fact || "").trim();
|
|
210
|
+
const m = /^\*\*([^*]+)\*\*\s*(?:—|--|-|:)?\s*(.*)$/.exec(f);
|
|
211
|
+
if (m) {
|
|
212
|
+
return m[1].trim();
|
|
213
|
+
}
|
|
214
|
+
const parts = f.split(/(?:\s*(?:—|--)\s*|\s+-\s+|\.)/);
|
|
215
|
+
if (parts.length > 0 && parts[0].trim()) {
|
|
216
|
+
return parts[0].trim();
|
|
217
|
+
}
|
|
218
|
+
return f.slice(0, 50).trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function withTitleAndBody(line, { title, body }) {
|
|
222
|
+
const p = parseFactEntry(line);
|
|
223
|
+
if (!p) return line;
|
|
224
|
+
const currentTitle = factTitle(line);
|
|
225
|
+
const currentBody = factBody(line);
|
|
226
|
+
|
|
227
|
+
const newTitle = title !== undefined && title !== null ? String(title).trim() : currentTitle;
|
|
228
|
+
const newBody = body !== undefined && body !== null ? String(body).trim() : currentBody;
|
|
229
|
+
|
|
230
|
+
const newText = `**${newTitle}** — ${newBody}`;
|
|
231
|
+
return formatFactEntry({ date: p.date, time: p.time, text: newText, meta: p.meta });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Return a new line where a legacy fact WITHOUT a `**Title**` prefix gets one
|
|
235
|
+
// auto-generated (first phrase of the body). Lines that already have a title or
|
|
236
|
+
// are not fact entries are returned unchanged.
|
|
237
|
+
export function withTitle(line) {
|
|
238
|
+
const p = parseFactEntry(line);
|
|
239
|
+
if (!p) return line;
|
|
240
|
+
if (/^\*\*[^*]+\*\*/.test(p.text)) return line;
|
|
241
|
+
const title = autoGenerateTitle(p.text);
|
|
242
|
+
if (!title) return line;
|
|
243
|
+
return formatFactEntry({ date: p.date, time: p.time, text: `**${title}** — ${p.text}`, meta: p.meta });
|
|
244
|
+
}
|
|
@@ -85,11 +85,26 @@ export async function saveGraphEdges(db, edges) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export async function
|
|
88
|
+
export async function getRelatedSymbolsBatch(db, sectionIds) {
|
|
89
|
+
if (!sectionIds || sectionIds.length === 0) return new Map();
|
|
90
|
+
const placeholders = sectionIds.map(() => "?").join(",");
|
|
89
91
|
const stmt = db.prepare(`
|
|
90
|
-
SELECT
|
|
91
|
-
WHERE source_id
|
|
92
|
+
SELECT source_id, target_id FROM graph_edges
|
|
93
|
+
WHERE source_id IN (${placeholders}) AND relation_type = 'DEFINES_SYMBOL';
|
|
92
94
|
`);
|
|
93
|
-
const rows = await stmt.all(
|
|
94
|
-
|
|
95
|
+
const rows = await stmt.all(...sectionIds);
|
|
96
|
+
const result = new Map();
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
const list = result.get(r.source_id) || [];
|
|
99
|
+
list.push(r.target_id.replace("symbol:", ""));
|
|
100
|
+
result.set(r.source_id, list);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function getRelatedSymbols(db, sectionId) {
|
|
106
|
+
if (!sectionId) return [];
|
|
107
|
+
const map = await getRelatedSymbolsBatch(db, [sectionId]);
|
|
108
|
+
return map.get(sectionId) || [];
|
|
95
109
|
}
|
|
110
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
export function normalizeRemoteUrl(url) {
|
|
9
|
+
if (!url || typeof url !== "string") return "";
|
|
10
|
+
let s = url.trim();
|
|
11
|
+
s = s.replace(/^(https?|git|ssh|file):\/\//i, "");
|
|
12
|
+
s = s.replace(/^[^@/]+@/, "");
|
|
13
|
+
s = s.replace(/:([a-zA-Z~])/, "/$1");
|
|
14
|
+
s = s.replace(/^([^/]+):[0-9]+/, "$1");
|
|
15
|
+
s = s.replace(/\.git\/?$/, "");
|
|
16
|
+
s = s.replace(/\/+$/, "");
|
|
17
|
+
return s.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function detectGitToplevel(dir) {
|
|
21
|
+
const absoluteDir = resolve(dir || process.cwd());
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: absoluteDir });
|
|
24
|
+
return resolve(stdout.trim());
|
|
25
|
+
} catch (err) {
|
|
26
|
+
let current = absoluteDir;
|
|
27
|
+
while (true) {
|
|
28
|
+
const gitPath = join(current, ".git");
|
|
29
|
+
if (existsSync(gitPath)) {
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(current);
|
|
33
|
+
if (parent === current) break;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getRemoteUrls(toplevel) {
|
|
41
|
+
const urls = [];
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await execFileAsync("git", ["config", "--get-regexp", "remote\\..*\\.url"], { cwd: toplevel });
|
|
44
|
+
const lines = stdout.split("\n").filter(Boolean);
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const parts = line.split(/\s+/);
|
|
47
|
+
if (parts.length >= 2) {
|
|
48
|
+
urls.push(parts[1]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const configPath = join(toplevel, ".git", "config");
|
|
53
|
+
if (existsSync(configPath)) {
|
|
54
|
+
try {
|
|
55
|
+
const configText = readFileSync(configPath, "utf-8");
|
|
56
|
+
const lines = configText.split("\n");
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const match = /^\s*url\s*=\s*(.*)$/.exec(line);
|
|
59
|
+
if (match) {
|
|
60
|
+
urls.push(match[1].trim());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return urls;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let identityCache = new Map();
|
|
70
|
+
|
|
71
|
+
export function bustIdentityCache() {
|
|
72
|
+
identityCache.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function resolveProjectIdentity(dir) {
|
|
76
|
+
const absoluteDir = resolve(dir || process.cwd());
|
|
77
|
+
if (identityCache.has(absoluteDir)) {
|
|
78
|
+
return identityCache.get(absoluteDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const toplevel = await detectGitToplevel(absoluteDir);
|
|
82
|
+
if (!toplevel) {
|
|
83
|
+
identityCache.set(absoluteDir, null);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const remotes = await getRemoteUrls(toplevel);
|
|
88
|
+
let key;
|
|
89
|
+
let name = basename(toplevel) || "default";
|
|
90
|
+
let primaryRemote = null;
|
|
91
|
+
|
|
92
|
+
if (remotes.length > 0) {
|
|
93
|
+
primaryRemote = normalizeRemoteUrl(remotes[0]);
|
|
94
|
+
key = `git:${primaryRemote}`;
|
|
95
|
+
} else {
|
|
96
|
+
key = `git:local:${name}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = { key, name, primaryRemote, toplevel };
|
|
100
|
+
identityCache.set(absoluteDir, result);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Registry SQLite API
|
|
105
|
+
|
|
106
|
+
export async function upsertIdentity(db, { key, name, primaryRemote }) {
|
|
107
|
+
await db.prepare(`
|
|
108
|
+
INSERT INTO project_identities (key, name, primary_remote, created_at, updated_at)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(key) DO UPDATE SET name = excluded.name, primary_remote = excluded.primary_remote, updated_at = excluded.updated_at;
|
|
111
|
+
`).run(key, name, primaryRemote || null, Date.now(), Date.now());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function registerAlias(db, { alias, identityKey, kind }) {
|
|
115
|
+
await db.prepare(`
|
|
116
|
+
INSERT INTO project_aliases (alias, identity_key, kind, created_at)
|
|
117
|
+
VALUES (?, ?, ?, ?)
|
|
118
|
+
ON CONFLICT(alias) DO UPDATE SET identity_key = excluded.identity_key, kind = excluded.kind;
|
|
119
|
+
`).run(alias, identityKey, kind, Date.now());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function unregisterAlias(db, alias) {
|
|
123
|
+
await db.prepare("DELETE FROM project_aliases WHERE alias = ?;").run(alias);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function lookupByCandidates(db, candidates) {
|
|
127
|
+
if (!candidates || !candidates.length) return null;
|
|
128
|
+
const placeholders = candidates.map(() => "?").join(",");
|
|
129
|
+
const row = await db.prepare(`
|
|
130
|
+
SELECT identity_key FROM project_aliases
|
|
131
|
+
WHERE alias IN (${placeholders})
|
|
132
|
+
LIMIT 1;
|
|
133
|
+
`).get(...candidates);
|
|
134
|
+
return row ? row.identity_key : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function listIdentities(db) {
|
|
138
|
+
const rows = await db.prepare("SELECT * FROM project_identities ORDER BY updated_at DESC;").all();
|
|
139
|
+
const res = [];
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const aliases = await db.prepare("SELECT alias, kind FROM project_aliases WHERE identity_key = ?;").all(row.key);
|
|
142
|
+
res.push({
|
|
143
|
+
...row,
|
|
144
|
+
aliases: aliases.map((a) => ({ alias: a.alias, kind: a.kind }))
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return res;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function removeIdentity(db, key) {
|
|
151
|
+
await db.prepare("DELETE FROM project_identities WHERE key = ?;").run(key);
|
|
152
|
+
}
|