wayfind 0.0.1 → 2.0.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/BOOTSTRAP_PROMPT.md +120 -0
- package/bin/connectors/github.js +617 -0
- package/bin/connectors/index.js +13 -0
- package/bin/connectors/intercom.js +595 -0
- package/bin/connectors/llm.js +469 -0
- package/bin/connectors/notion.js +747 -0
- package/bin/connectors/transport.js +325 -0
- package/bin/content-store.js +2006 -0
- package/bin/digest.js +813 -0
- package/bin/rebuild-status.js +297 -0
- package/bin/slack-bot.js +1535 -0
- package/bin/slack.js +342 -0
- package/bin/storage/index.js +171 -0
- package/bin/storage/json-backend.js +348 -0
- package/bin/storage/sqlite-backend.js +415 -0
- package/bin/team-context.js +4209 -0
- package/bin/telemetry.js +159 -0
- package/doctor.sh +291 -0
- package/install.sh +144 -0
- package/journal-summary.sh +577 -0
- package/package.json +48 -6
- package/setup.sh +641 -0
- package/specializations/claude-code/CLAUDE.md-global-fragment.md +53 -0
- package/specializations/claude-code/CLAUDE.md-repo-fragment.md +16 -0
- package/specializations/claude-code/README.md +99 -0
- package/specializations/claude-code/commands/doctor.md +31 -0
- package/specializations/claude-code/commands/init-memory.md +154 -0
- package/specializations/claude-code/commands/init-team.md +415 -0
- package/specializations/claude-code/commands/journal.md +66 -0
- package/specializations/claude-code/commands/review-prs.md +119 -0
- package/specializations/claude-code/hooks/check-global-state.sh +20 -0
- package/specializations/claude-code/hooks/session-end.sh +36 -0
- package/specializations/claude-code/settings.json +15 -0
- package/specializations/cursor/README.md +120 -0
- package/specializations/cursor/global-rule.mdc +53 -0
- package/specializations/cursor/repo-rule.mdc +25 -0
- package/specializations/generic/README.md +47 -0
- package/templates/autopilot/design.md +22 -0
- package/templates/autopilot/engineering.md +22 -0
- package/templates/autopilot/product.md +22 -0
- package/templates/autopilot/strategy.md +22 -0
- package/templates/autopilot/unified.md +24 -0
- package/templates/deploy/.env.example +110 -0
- package/templates/deploy/docker-compose.yml +63 -0
- package/templates/deploy/slack-app-manifest.json +45 -0
- package/templates/github-actions/meridian-digest.yml +85 -0
- package/templates/global.md +79 -0
- package/templates/memory-file.md +18 -0
- package/templates/personal-state.md +14 -0
- package/templates/personas.json +28 -0
- package/templates/product-state.md +41 -0
- package/templates/prompts-readme.md +19 -0
- package/templates/repo-state.md +18 -0
- package/templates/session-protocol-fragment.md +46 -0
- package/templates/slack-app-manifest.json +27 -0
- package/templates/statusline.sh +22 -0
- package/templates/strategy-state.md +39 -0
- package/templates/team-state.md +55 -0
- package/uninstall.sh +105 -0
- package/README.md +0 -4
package/bin/slack.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
8
|
+
|
|
9
|
+
// Persona emoji map
|
|
10
|
+
const PERSONA_EMOJI = {
|
|
11
|
+
unified: ':compass:',
|
|
12
|
+
engineering: ':wrench:',
|
|
13
|
+
product: ':dart:',
|
|
14
|
+
design: ':art:',
|
|
15
|
+
strategy: ':telescope:',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── Markdown to Slack mrkdwn conversion ─────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert standard markdown to Slack mrkdwn format.
|
|
22
|
+
* Preserves code blocks unchanged; converts bold, headings, links, lists, and
|
|
23
|
+
* tables to Slack-compatible formatting.
|
|
24
|
+
* @param {string} markdown - Markdown content
|
|
25
|
+
* @returns {string} Slack mrkdwn content
|
|
26
|
+
*/
|
|
27
|
+
function markdownToMrkdwn(markdown) {
|
|
28
|
+
if (!markdown) return '';
|
|
29
|
+
|
|
30
|
+
// Extract code blocks to preserve them unchanged
|
|
31
|
+
const codeBlocks = [];
|
|
32
|
+
let text = markdown.replace(/```[\s\S]*?```/g, (match) => {
|
|
33
|
+
codeBlocks.push(match);
|
|
34
|
+
return `__CODE_BLOCK_${codeBlocks.length - 1}__`;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Convert tables: strip | chars and format as indented lines
|
|
38
|
+
// Match table rows (lines starting with optional whitespace then |)
|
|
39
|
+
const lines = text.split('\n');
|
|
40
|
+
const result = [];
|
|
41
|
+
let inTable = false;
|
|
42
|
+
let tableHeaders = [];
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
|
|
48
|
+
// Detect table separator row (e.g., |---|---|)
|
|
49
|
+
if (/^\|[\s:-]+\|/.test(trimmed) && /^[\s|:-]+$/.test(trimmed)) {
|
|
50
|
+
inTable = true;
|
|
51
|
+
continue; // skip separator row
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Detect table row
|
|
55
|
+
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
|
56
|
+
const cells = trimmed
|
|
57
|
+
.slice(1, -1)
|
|
58
|
+
.split('|')
|
|
59
|
+
.map((c) => c.trim());
|
|
60
|
+
|
|
61
|
+
if (!inTable) {
|
|
62
|
+
// This is a header row — save headers
|
|
63
|
+
tableHeaders = cells;
|
|
64
|
+
inTable = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Data row — format as key: value pairs
|
|
69
|
+
if (tableHeaders.length > 0 && tableHeaders.length === cells.length) {
|
|
70
|
+
for (let j = 0; j < cells.length; j++) {
|
|
71
|
+
if (cells[j] && cells[j] !== '-') {
|
|
72
|
+
result.push(` ${tableHeaders[j]}: ${cells[j]}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
result.push('');
|
|
76
|
+
} else {
|
|
77
|
+
// No headers or mismatched — just output cells as indented text
|
|
78
|
+
const content = cells.filter((c) => c && c !== '-').join(' ');
|
|
79
|
+
if (content) {
|
|
80
|
+
result.push(` ${content}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// End of table
|
|
87
|
+
if (inTable && !trimmed.startsWith('|')) {
|
|
88
|
+
inTable = false;
|
|
89
|
+
tableHeaders = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.push(line);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
text = result.join('\n');
|
|
96
|
+
|
|
97
|
+
// Strip horizontal rules
|
|
98
|
+
text = text.replace(/^[ \t]*(---+|___+|\*\*\*+)[ \t]*$/gm, '');
|
|
99
|
+
|
|
100
|
+
// Convert bold first (before headings, to avoid collision)
|
|
101
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '*$1*');
|
|
102
|
+
|
|
103
|
+
// Convert headings: # Heading -> *Heading*
|
|
104
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
|
105
|
+
|
|
106
|
+
// Convert links: [text](url) -> <url|text>
|
|
107
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
|
108
|
+
|
|
109
|
+
// Convert list items: - item -> bullet item
|
|
110
|
+
text = text.replace(/^(\s*)- (.+)$/gm, '$1\u2022 $2');
|
|
111
|
+
|
|
112
|
+
// Restore code blocks
|
|
113
|
+
text = text.replace(/__CODE_BLOCK_(\d+)__/g, (_, idx) => {
|
|
114
|
+
return codeBlocks[parseInt(idx, 10)];
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Date formatting ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const MONTH_ABBR = [
|
|
123
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
124
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Format a date range as "Feb 24 \u2013 Feb 28" (abbreviated month, en-dash).
|
|
129
|
+
* @param {{ from: string, to: string }} dateRange
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function formatDateRange(dateRange) {
|
|
133
|
+
const from = new Date(dateRange.from + 'T00:00:00');
|
|
134
|
+
const to = new Date(dateRange.to + 'T00:00:00');
|
|
135
|
+
const fromStr = `${MONTH_ABBR[from.getUTCMonth()]} ${from.getUTCDate()}`;
|
|
136
|
+
const toStr = `${MONTH_ABBR[to.getUTCMonth()]} ${to.getUTCDate()}`;
|
|
137
|
+
return `${fromStr} \u2013 ${toStr}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Capitalize the first letter of a string.
|
|
142
|
+
* @param {string} str
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function capitalize(str) {
|
|
146
|
+
if (!str) return '';
|
|
147
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── HTTP POST ───────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* POST a JSON payload to a Slack incoming webhook URL.
|
|
154
|
+
* @param {string} webhookUrl - Full HTTPS webhook URL
|
|
155
|
+
* @param {Object} payload - JSON payload to send
|
|
156
|
+
* @returns {Promise<{ ok: true }>}
|
|
157
|
+
*/
|
|
158
|
+
function postToWebhook(webhookUrl, payload) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const url = new URL(webhookUrl);
|
|
161
|
+
const data = JSON.stringify(payload);
|
|
162
|
+
const opts = {
|
|
163
|
+
hostname: url.hostname,
|
|
164
|
+
path: url.pathname,
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: {
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
'Content-Length': Buffer.byteLength(data),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const req = https.request(opts, (res) => {
|
|
172
|
+
const chunks = [];
|
|
173
|
+
res.on('data', (c) => chunks.push(c));
|
|
174
|
+
res.on('end', () => {
|
|
175
|
+
const body = Buffer.concat(chunks).toString();
|
|
176
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
177
|
+
resolve({ ok: true });
|
|
178
|
+
} else {
|
|
179
|
+
reject(new Error(`Slack webhook returned ${res.statusCode}: ${body}`));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
req.setTimeout(10000, () => {
|
|
185
|
+
req.destroy();
|
|
186
|
+
reject(new Error('Slack webhook timeout'));
|
|
187
|
+
});
|
|
188
|
+
req.write(data);
|
|
189
|
+
req.end();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Bot token delivery ──────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Deliver a digest to Slack via chat.postMessage (bot token).
|
|
197
|
+
* Returns the message ts for reaction tracking.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} botToken - Slack bot OAuth token (xoxb-...)
|
|
200
|
+
* @param {string} channel - Slack channel ID or name
|
|
201
|
+
* @param {string} content - Formatted mrkdwn content (already converted)
|
|
202
|
+
* @param {string} personaName - Persona ID
|
|
203
|
+
* @returns {Promise<{ ok: true, persona: string, ts: string, channel: string }>}
|
|
204
|
+
*/
|
|
205
|
+
async function deliverViaBot(botToken, channel, content, personaName) {
|
|
206
|
+
const { WebClient } = require('@slack/web-api');
|
|
207
|
+
const client = new WebClient(botToken);
|
|
208
|
+
|
|
209
|
+
const truncated = content.length > 3900 ? content.slice(0, 3900) + '\n\n_...truncated_' : content;
|
|
210
|
+
|
|
211
|
+
const result = await client.chat.postMessage({
|
|
212
|
+
channel,
|
|
213
|
+
text: truncated,
|
|
214
|
+
unfurl_links: false,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Post a threaded follow-up asking for feedback
|
|
218
|
+
try {
|
|
219
|
+
await client.chat.postMessage({
|
|
220
|
+
channel,
|
|
221
|
+
thread_ts: result.ts,
|
|
222
|
+
text: '_React to the digest or reply here with feedback — what was useful? What was missing? Your input shapes future digests._',
|
|
223
|
+
unfurl_links: false,
|
|
224
|
+
});
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// Non-fatal — digest was delivered, feedback prompt is optional
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { ok: true, persona: personaName, ts: result.ts, channel: result.channel };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Deliver ─────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Deliver a digest to Slack via incoming webhook or bot token.
|
|
236
|
+
* In simulation mode, writes the payload JSON to disk instead of POSTing.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} webhookUrl - Slack incoming webhook URL
|
|
239
|
+
* @param {string} digestContent - Markdown content of the digest
|
|
240
|
+
* @param {string} personaName - Persona ID (engineering, product, design, strategy)
|
|
241
|
+
* @param {{ from: string, to: string }} dateRange - Date range for the digest
|
|
242
|
+
* @param {Object} [options] - Optional delivery options
|
|
243
|
+
* @param {string} [options.botToken] - Slack bot token for chat.postMessage delivery
|
|
244
|
+
* @param {string} [options.channel] - Slack channel for bot delivery
|
|
245
|
+
* @returns {Promise<{ ok: true, persona: string, ts?: string, channel?: string }>}
|
|
246
|
+
*/
|
|
247
|
+
async function deliver(webhookUrl, digestContent, personaName, dateRange, options) {
|
|
248
|
+
const emoji = PERSONA_EMOJI[personaName] || ':memo:';
|
|
249
|
+
const label = personaName === 'unified' ? 'Wayfind' : capitalize(personaName);
|
|
250
|
+
const range = formatDateRange(dateRange);
|
|
251
|
+
const mrkdwn = markdownToMrkdwn(digestContent);
|
|
252
|
+
const formattedText = `${emoji} *${label} Digest* (${range})\n\n${mrkdwn}`;
|
|
253
|
+
|
|
254
|
+
const payload = {
|
|
255
|
+
text: formattedText,
|
|
256
|
+
unfurl_links: false,
|
|
257
|
+
unfurl_media: false,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Simulation mode: write payload to disk
|
|
261
|
+
if (process.env.TEAM_CONTEXT_SIMULATE === '1') {
|
|
262
|
+
const digestsDir = path.join(HOME, '.claude', 'team-context', 'digests');
|
|
263
|
+
fs.mkdirSync(digestsDir, { recursive: true });
|
|
264
|
+
const outFile = path.join(digestsDir, `${dateRange.to}-slack-${personaName}.json`);
|
|
265
|
+
fs.writeFileSync(outFile, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
266
|
+
return { ok: true, persona: personaName };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Try bot token delivery first (returns message ts for reaction tracking)
|
|
270
|
+
const opts = options || {};
|
|
271
|
+
if (opts.botToken && opts.channel) {
|
|
272
|
+
try {
|
|
273
|
+
return await deliverViaBot(opts.botToken, opts.channel, formattedText, personaName);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error(`Bot delivery failed for ${personaName}, falling back to webhook: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Fallback: POST to webhook
|
|
280
|
+
await postToWebhook(webhookUrl, payload);
|
|
281
|
+
return { ok: true, persona: personaName };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Deliver All ─────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Deliver digests for multiple personas to Slack.
|
|
288
|
+
* Reads each persona's digest file and calls deliver() with a 1-second delay
|
|
289
|
+
* between posts to respect Slack rate limits.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} webhookUrl - Slack incoming webhook URL
|
|
292
|
+
* @param {Object} digestResult - Return value from generateDigest():
|
|
293
|
+
* { files: string[], personas: string[], dateRange: { from, to } }
|
|
294
|
+
* @param {string[]} personaIds - Array of persona IDs to deliver
|
|
295
|
+
* @param {Object} [options] - Optional delivery options
|
|
296
|
+
* @param {string} [options.botToken] - Slack bot token for chat.postMessage delivery
|
|
297
|
+
* @param {string} [options.channel] - Slack channel for bot delivery
|
|
298
|
+
* @returns {Promise<Array<{ ok: true, persona: string, ts?: string, channel?: string }>>}
|
|
299
|
+
*/
|
|
300
|
+
async function deliverAll(webhookUrl, digestResult, personaIds, options) {
|
|
301
|
+
const results = [];
|
|
302
|
+
const toDate = digestResult.dateRange.to;
|
|
303
|
+
|
|
304
|
+
for (let i = 0; i < personaIds.length; i++) {
|
|
305
|
+
const persona = personaIds[i];
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// Read the digest file for this persona
|
|
309
|
+
const digestFile = path.join(
|
|
310
|
+
HOME, '.claude', 'team-context', 'digests', persona, `${toDate}.md`
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
let content;
|
|
314
|
+
try {
|
|
315
|
+
content = fs.readFileSync(digestFile, 'utf8');
|
|
316
|
+
} catch {
|
|
317
|
+
results.push({ ok: false, persona, error: `Digest file not found: ${digestFile}` });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Rate limit: 1 second delay between posts (skip before first)
|
|
322
|
+
if (i > 0) {
|
|
323
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const result = await deliver(webhookUrl, content, persona, digestResult.dateRange, options);
|
|
327
|
+
results.push(result);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
results.push({ ok: false, persona, error: err.message });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return results;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = {
|
|
337
|
+
deliver,
|
|
338
|
+
deliverAll,
|
|
339
|
+
deliverViaBot,
|
|
340
|
+
markdownToMrkdwn,
|
|
341
|
+
postToWebhook,
|
|
342
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const JsonBackend = require('./json-backend');
|
|
6
|
+
|
|
7
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const JSON_FILES = [
|
|
10
|
+
'index.json',
|
|
11
|
+
'embeddings.json',
|
|
12
|
+
'conversation-index.json',
|
|
13
|
+
'digest-feedback.json',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// ── Cache ────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const cache = {};
|
|
19
|
+
|
|
20
|
+
// ── Migration ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Migrate existing JSON data into the SQLite backend.
|
|
24
|
+
* Idempotent — skips if already migrated or no JSON files exist.
|
|
25
|
+
* Does NOT delete JSON files after migration.
|
|
26
|
+
*/
|
|
27
|
+
function migrateFromJson(sqliteBackend, storePath) {
|
|
28
|
+
// Already migrated?
|
|
29
|
+
const row = sqliteBackend.db
|
|
30
|
+
.prepare('SELECT value FROM metadata WHERE key = ?')
|
|
31
|
+
.get('migrated_from_json');
|
|
32
|
+
if (row) return;
|
|
33
|
+
|
|
34
|
+
// Any JSON files to migrate?
|
|
35
|
+
const found = JSON_FILES.filter(f =>
|
|
36
|
+
fs.existsSync(path.join(storePath, f))
|
|
37
|
+
);
|
|
38
|
+
if (found.length === 0) return;
|
|
39
|
+
|
|
40
|
+
const json = new JsonBackend(storePath);
|
|
41
|
+
json.open();
|
|
42
|
+
|
|
43
|
+
let totalEntries = 0;
|
|
44
|
+
|
|
45
|
+
// index.json → decisions table
|
|
46
|
+
const index = json.loadIndex();
|
|
47
|
+
if (index && index.entries) {
|
|
48
|
+
const entries = index.entries;
|
|
49
|
+
const count = Object.keys(entries).length;
|
|
50
|
+
if (count > 0) {
|
|
51
|
+
sqliteBackend.bulkUpsertEntries(entries);
|
|
52
|
+
totalEntries += count;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// embeddings.json → embeddings table
|
|
57
|
+
const embeddings = json.loadEmbeddings();
|
|
58
|
+
if (embeddings && Object.keys(embeddings).length > 0) {
|
|
59
|
+
sqliteBackend.saveEmbeddings(embeddings);
|
|
60
|
+
totalEntries += Object.keys(embeddings).length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// conversation-index.json → conversation_index table
|
|
64
|
+
const convIndex = json.loadConversationIndex();
|
|
65
|
+
if (convIndex && Object.keys(convIndex).length > 0) {
|
|
66
|
+
sqliteBackend.saveConversationIndex(convIndex);
|
|
67
|
+
totalEntries += Object.keys(convIndex).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// digest-feedback.json → digest_feedback table
|
|
71
|
+
const feedback = json.loadFeedback();
|
|
72
|
+
if (feedback && feedback.digests && Object.keys(feedback.digests).length > 0) {
|
|
73
|
+
sqliteBackend.saveFeedback(feedback);
|
|
74
|
+
totalEntries += Object.keys(feedback.digests).length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
json.close();
|
|
78
|
+
|
|
79
|
+
// Mark migration complete
|
|
80
|
+
sqliteBackend.db
|
|
81
|
+
.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)')
|
|
82
|
+
.run('migrated_from_json', new Date().toISOString());
|
|
83
|
+
|
|
84
|
+
if (totalEntries > 0) {
|
|
85
|
+
console.error(`[wayfind] Migrated ${totalEntries} entries from JSON to SQLite`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Backend selection ────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get (or create and cache) the storage backend for the given store path.
|
|
93
|
+
* Honors TEAM_CONTEXT_STORAGE_BACKEND env var ('json' or 'sqlite').
|
|
94
|
+
* When unset, auto-detects: tries SQLite first, falls back to JSON.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} storePath - Directory for storage files
|
|
97
|
+
* @returns {JsonBackend|SqliteBackend}
|
|
98
|
+
*/
|
|
99
|
+
function getBackend(storePath) {
|
|
100
|
+
if (cache[storePath]) return cache[storePath];
|
|
101
|
+
|
|
102
|
+
const forced = process.env.TEAM_CONTEXT_STORAGE_BACKEND;
|
|
103
|
+
|
|
104
|
+
if (forced === 'json') {
|
|
105
|
+
const backend = new JsonBackend(storePath);
|
|
106
|
+
backend.open();
|
|
107
|
+
cache[storePath] = backend;
|
|
108
|
+
return backend;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (forced === 'sqlite') {
|
|
112
|
+
const SqliteBackend = require('./sqlite-backend');
|
|
113
|
+
const backend = new SqliteBackend(storePath);
|
|
114
|
+
backend.open();
|
|
115
|
+
migrateFromJson(backend, storePath);
|
|
116
|
+
cache[storePath] = backend;
|
|
117
|
+
return backend;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Auto-detect: try SQLite, fall back to JSON
|
|
121
|
+
try {
|
|
122
|
+
require.resolve('better-sqlite3');
|
|
123
|
+
const SqliteBackend = require('./sqlite-backend');
|
|
124
|
+
const backend = new SqliteBackend(storePath);
|
|
125
|
+
backend.open();
|
|
126
|
+
migrateFromJson(backend, storePath);
|
|
127
|
+
cache[storePath] = backend;
|
|
128
|
+
return backend;
|
|
129
|
+
} catch {
|
|
130
|
+
const backend = new JsonBackend(storePath);
|
|
131
|
+
backend.open();
|
|
132
|
+
cache[storePath] = backend;
|
|
133
|
+
return backend;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Close all cached backends and clear the cache. Useful for tests.
|
|
139
|
+
*/
|
|
140
|
+
function clearCache() {
|
|
141
|
+
for (const storePath of Object.keys(cache)) {
|
|
142
|
+
try {
|
|
143
|
+
cache[storePath].close();
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore close errors during cleanup
|
|
146
|
+
}
|
|
147
|
+
delete cache[storePath];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns the backend type string ('sqlite' or 'json') for a cached backend.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} storePath
|
|
155
|
+
* @returns {'sqlite'|'json'|null} null if no backend is cached for this path
|
|
156
|
+
*/
|
|
157
|
+
function getBackendType(storePath) {
|
|
158
|
+
const backend = cache[storePath];
|
|
159
|
+
if (!backend) return null;
|
|
160
|
+
if (backend instanceof JsonBackend) return 'json';
|
|
161
|
+
// SqliteBackend — check constructor name to avoid requiring the module
|
|
162
|
+
// just for a type check (it may not be available)
|
|
163
|
+
if (backend.constructor && backend.constructor.name === 'SqliteBackend') return 'sqlite';
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
getBackend,
|
|
169
|
+
clearCache,
|
|
170
|
+
getBackendType,
|
|
171
|
+
};
|