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
|
@@ -0,0 +1,2006 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const llm = require('./connectors/llm');
|
|
7
|
+
const telemetry = require('./telemetry');
|
|
8
|
+
const { getBackend, getBackendType } = require('./storage');
|
|
9
|
+
|
|
10
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
13
|
+
const DEFAULT_STORE_PATH = HOME ? path.join(HOME, '.claude', 'team-context', 'content-store') : null;
|
|
14
|
+
const DEFAULT_JOURNAL_DIR = HOME ? path.join(HOME, '.claude', 'memory', 'journal') : null;
|
|
15
|
+
const DEFAULT_PROJECTS_DIR = HOME ? path.join(HOME, '.claude', 'projects') : null;
|
|
16
|
+
const DEFAULT_SIGNALS_DIR = HOME ? path.join(HOME, '.claude', 'team-context', 'signals') : null;
|
|
17
|
+
const INDEX_VERSION = '2.0.0';
|
|
18
|
+
const FILE_PERMS = 0o600;
|
|
19
|
+
|
|
20
|
+
// Field mapping for journal entries
|
|
21
|
+
const FIELD_MAP = {
|
|
22
|
+
'Why': 'why',
|
|
23
|
+
'What': 'what',
|
|
24
|
+
'Outcome': 'outcome',
|
|
25
|
+
'On track?': 'onTrack',
|
|
26
|
+
'Lessons': 'lessons',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const FIELD_LABELS = {
|
|
30
|
+
why: 'Why',
|
|
31
|
+
what: 'What',
|
|
32
|
+
outcome: 'Outcome',
|
|
33
|
+
onTrack: 'On track?',
|
|
34
|
+
lessons: 'Lessons',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Regex patterns
|
|
38
|
+
const ENTRY_HEADER_RE = /^##\s+(.+?)\s+[—–]\s+(.+)$/;
|
|
39
|
+
const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/;
|
|
40
|
+
const DATE_FILE_RE = /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9._-]+))?\.md$/;
|
|
41
|
+
|
|
42
|
+
// Repo exclusion list (comma-separated, case-insensitive, supports org/repo or just repo)
|
|
43
|
+
const EXCLUDE_REPOS = (process.env.TEAM_CONTEXT_EXCLUDE_REPOS || '')
|
|
44
|
+
.split(',').map(r => r.trim().toLowerCase()).filter(Boolean);
|
|
45
|
+
|
|
46
|
+
// Drift detection keywords
|
|
47
|
+
const DRIFT_POSITIVE = ['drift', 'drifted', 'tangent', 'pivoted', 'sidetracked', 'off track', 'off-track'];
|
|
48
|
+
const DRIFT_NEGATIVE = ['no drift', 'no tangent', 'on track', 'focused', 'laser focused', 'stayed focused'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if a repo name matches the exclusion list.
|
|
52
|
+
* Matches against repo name alone or org/repo format.
|
|
53
|
+
*/
|
|
54
|
+
function isRepoExcluded(repo) {
|
|
55
|
+
if (!EXCLUDE_REPOS.length || !repo) return false;
|
|
56
|
+
const lower = repo.toLowerCase();
|
|
57
|
+
return EXCLUDE_REPOS.some(ex => lower === ex || lower.endsWith('/' + ex) || lower.startsWith(ex + '/'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Journal parsing ─────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a single journal file into an array of entry objects.
|
|
64
|
+
* @param {string} filePath - Path to the journal markdown file
|
|
65
|
+
* @returns {{ date: string, entries: Array<{ repo: string, title: string, fields: Object }> }}
|
|
66
|
+
*/
|
|
67
|
+
function parseJournalFile(filePath) {
|
|
68
|
+
const basename = path.basename(filePath);
|
|
69
|
+
const dateMatch = basename.match(DATE_FILE_RE);
|
|
70
|
+
const date = dateMatch ? dateMatch[1] : null;
|
|
71
|
+
const filenameAuthor = dateMatch ? (dateMatch[2] || null) : null;
|
|
72
|
+
|
|
73
|
+
if (!date) return { date: null, entries: [] };
|
|
74
|
+
|
|
75
|
+
let content;
|
|
76
|
+
try {
|
|
77
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
78
|
+
} catch {
|
|
79
|
+
return { date, entries: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Normalize line endings — git checkouts on Windows/WSL may produce CRLF
|
|
83
|
+
content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
84
|
+
|
|
85
|
+
if (!content.trim()) return { date, entries: [] };
|
|
86
|
+
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
const entries = [];
|
|
89
|
+
let current = null;
|
|
90
|
+
let currentField = null;
|
|
91
|
+
|
|
92
|
+
// Author tracking: **Author:** lines apply to the NEXT ## entry header.
|
|
93
|
+
// A pending author is consumed when the next header appears.
|
|
94
|
+
const AUTHOR_RE = /^\*\*Author:\*\*\s*(.+)$/;
|
|
95
|
+
let fileLevelAuthor = null;
|
|
96
|
+
let pendingAuthor = null;
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
// Detect **Author:** lines — these always apply to the NEXT entry
|
|
100
|
+
const authorMatch = line.match(AUTHOR_RE);
|
|
101
|
+
if (authorMatch) {
|
|
102
|
+
pendingAuthor = authorMatch[1].trim();
|
|
103
|
+
if (!current) {
|
|
104
|
+
// Before any entry — also set as file-level default
|
|
105
|
+
fileLevelAuthor = pendingAuthor;
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const headerMatch = line.match(ENTRY_HEADER_RE);
|
|
111
|
+
if (headerMatch) {
|
|
112
|
+
if (current) entries.push(current);
|
|
113
|
+
current = {
|
|
114
|
+
repo: headerMatch[1].trim().replace(/^\[|\]$/g, ''),
|
|
115
|
+
title: headerMatch[2].trim(),
|
|
116
|
+
fields: {},
|
|
117
|
+
author: pendingAuthor || null,
|
|
118
|
+
};
|
|
119
|
+
pendingAuthor = null;
|
|
120
|
+
currentField = null;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!current) continue;
|
|
125
|
+
|
|
126
|
+
const fieldMatch = line.match(FIELD_RE);
|
|
127
|
+
if (fieldMatch) {
|
|
128
|
+
const label = fieldMatch[1].trim();
|
|
129
|
+
const value = fieldMatch[2].trim();
|
|
130
|
+
const key = FIELD_MAP[label];
|
|
131
|
+
if (key) {
|
|
132
|
+
current.fields[key] = value;
|
|
133
|
+
currentField = key;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Continuation line for multi-line fields
|
|
139
|
+
if (currentField && line.trim() && !line.startsWith('#')) {
|
|
140
|
+
current.fields[currentField] += '\n' + line;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (current) entries.push(current);
|
|
145
|
+
|
|
146
|
+
// Resolve author for each entry: entry-level > file-level > filename author
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (!entry.author) {
|
|
149
|
+
entry.author = fileLevelAuthor || filenameAuthor || null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { date, entries };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Detect whether an entry indicates drift.
|
|
158
|
+
* @param {Object} fields - Entry fields object
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
function isDrifted(fields) {
|
|
162
|
+
const text = [fields.onTrack || '', fields.outcome || '', fields.lessons || ''].join(' ').toLowerCase();
|
|
163
|
+
|
|
164
|
+
// Check negative patterns first (they override)
|
|
165
|
+
for (const neg of DRIFT_NEGATIVE) {
|
|
166
|
+
if (text.includes(neg)) return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const pos of DRIFT_POSITIVE) {
|
|
170
|
+
if (text.includes(pos)) return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate a deterministic ID for a journal entry.
|
|
178
|
+
* @param {string} date - YYYY-MM-DD
|
|
179
|
+
* @param {string} repo - Repository/project name
|
|
180
|
+
* @param {string} title - Entry title
|
|
181
|
+
* @returns {string} - 12-char hex ID
|
|
182
|
+
*/
|
|
183
|
+
function generateEntryId(date, repo, title) {
|
|
184
|
+
const input = `${date}:${repo}:${title}`;
|
|
185
|
+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build the text content for embedding from an entry's fields.
|
|
190
|
+
* @param {Object} entry - Entry with date, repo, title, fields
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
function buildContent(entry) {
|
|
194
|
+
const parts = [`${entry.repo} — ${entry.title}`];
|
|
195
|
+
if (entry.date) parts.push(`Date: ${entry.date}`);
|
|
196
|
+
if (entry.author) parts.push(`Author: ${entry.author}`);
|
|
197
|
+
for (const [key, label] of Object.entries(FIELD_LABELS)) {
|
|
198
|
+
if (entry.fields[key]) {
|
|
199
|
+
parts.push(`${label}: ${entry.fields[key]}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return parts.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Extract tags from entry content using word boundaries.
|
|
207
|
+
* Includes repo name and meaningful words from the title.
|
|
208
|
+
* @param {Object} entry - Entry with repo, title, fields
|
|
209
|
+
* @returns {string[]}
|
|
210
|
+
*/
|
|
211
|
+
function extractTags(entry) {
|
|
212
|
+
const tags = new Set();
|
|
213
|
+
|
|
214
|
+
// Repo name as tag (lowercase, cleaned)
|
|
215
|
+
const repoTag = entry.repo.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
216
|
+
if (repoTag) tags.add(repoTag);
|
|
217
|
+
|
|
218
|
+
// Words from title (3+ chars, lowercase, word-boundary aware)
|
|
219
|
+
const titleWords = entry.title.match(/\b[a-zA-Z][a-zA-Z0-9]{2,}\b/g) || [];
|
|
220
|
+
const stopWords = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'was', 'are', 'but', 'not', 'has', 'had', 'have', 'been', 'more', 'also', 'into', 'than']);
|
|
221
|
+
for (const word of titleWords) {
|
|
222
|
+
const lower = word.toLowerCase();
|
|
223
|
+
if (!stopWords.has(lower)) tags.add(lower);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return [...tags];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compute a content hash for change detection.
|
|
231
|
+
* @param {string} content - Text content
|
|
232
|
+
* @returns {string} - SHA-256 hex hash (first 16 chars)
|
|
233
|
+
*/
|
|
234
|
+
function contentHash(content) {
|
|
235
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Cosine similarity ───────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compute cosine similarity between two vectors.
|
|
242
|
+
* @param {number[]} a
|
|
243
|
+
* @param {number[]} b
|
|
244
|
+
* @returns {number}
|
|
245
|
+
*/
|
|
246
|
+
function cosineSimilarity(a, b) {
|
|
247
|
+
if (a.length !== b.length) return 0;
|
|
248
|
+
let dot = 0, magA = 0, magB = 0;
|
|
249
|
+
for (let i = 0; i < a.length; i++) {
|
|
250
|
+
dot += a[i] * b[i];
|
|
251
|
+
magA += a[i] * a[i];
|
|
252
|
+
magB += b[i] * b[i];
|
|
253
|
+
}
|
|
254
|
+
const denom = Math.sqrt(magA) * Math.sqrt(magB);
|
|
255
|
+
return denom === 0 ? 0 : dot / denom;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Core functions ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Index journal files. Incremental — skips unchanged entries.
|
|
262
|
+
* @param {Object} options
|
|
263
|
+
* @param {string} [options.journalDir] - Journal directory
|
|
264
|
+
* @param {string} [options.storePath] - Content store directory
|
|
265
|
+
* @param {boolean} [options.embeddings] - Generate embeddings (default: true if OPENAI_API_KEY or TEAM_CONTEXT_SIMULATE)
|
|
266
|
+
* @returns {Promise<Object>} - Stats: { entryCount, newEntries, updatedEntries, skippedEntries, removedEntries }
|
|
267
|
+
*/
|
|
268
|
+
async function indexJournals(options = {}) {
|
|
269
|
+
const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
|
|
270
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
271
|
+
const doEmbeddings = options.embeddings !== undefined
|
|
272
|
+
? options.embeddings
|
|
273
|
+
: !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
|
|
274
|
+
|
|
275
|
+
if (!journalDir || !storePath) {
|
|
276
|
+
throw new Error('Journal directory and store path are required.');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!fs.existsSync(journalDir)) {
|
|
280
|
+
throw new Error(`Journal directory not found: ${journalDir}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Load existing index
|
|
284
|
+
const backend = getBackend(storePath);
|
|
285
|
+
const existingIndex = backend.loadIndex();
|
|
286
|
+
const existingEntries = existingIndex ? existingIndex.entries : {};
|
|
287
|
+
const existingEmbeddings = doEmbeddings ? backend.loadEmbeddings() : {};
|
|
288
|
+
|
|
289
|
+
// Parse all journal files
|
|
290
|
+
const files = fs.readdirSync(journalDir).filter(f => DATE_FILE_RE.test(f)).sort();
|
|
291
|
+
const newEntries = {};
|
|
292
|
+
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
const filePath = path.join(journalDir, file);
|
|
295
|
+
const { date, entries } = parseJournalFile(filePath);
|
|
296
|
+
if (!date) continue;
|
|
297
|
+
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (isRepoExcluded(entry.repo)) continue;
|
|
300
|
+
const id = generateEntryId(date, entry.repo, entry.title);
|
|
301
|
+
const author = entry.author || options.defaultAuthor || '';
|
|
302
|
+
const content = buildContent({ ...entry, date, author });
|
|
303
|
+
const hash = contentHash(content);
|
|
304
|
+
|
|
305
|
+
newEntries[id] = {
|
|
306
|
+
date,
|
|
307
|
+
repo: entry.repo,
|
|
308
|
+
title: entry.title,
|
|
309
|
+
user: author,
|
|
310
|
+
drifted: isDrifted(entry.fields),
|
|
311
|
+
contentHash: hash,
|
|
312
|
+
contentLength: content.length,
|
|
313
|
+
tags: extractTags(entry),
|
|
314
|
+
hasEmbedding: false,
|
|
315
|
+
_content: content, // temporary, not saved to index
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Compute diffs
|
|
321
|
+
const stats = { entryCount: 0, newEntries: 0, updatedEntries: 0, skippedEntries: 0, removedEntries: 0 };
|
|
322
|
+
const finalEntries = {};
|
|
323
|
+
const finalEmbeddings = { ...existingEmbeddings };
|
|
324
|
+
|
|
325
|
+
for (const [id, entry] of Object.entries(newEntries)) {
|
|
326
|
+
const existing = existingEntries[id];
|
|
327
|
+
const content = entry._content;
|
|
328
|
+
delete entry._content;
|
|
329
|
+
|
|
330
|
+
if (existing && existing.contentHash === entry.contentHash) {
|
|
331
|
+
// Unchanged — but generate embedding if missing and embeddings are enabled
|
|
332
|
+
entry.hasEmbedding = existing.hasEmbedding;
|
|
333
|
+
if (doEmbeddings && !existing.hasEmbedding && content) {
|
|
334
|
+
try {
|
|
335
|
+
const vec = await llm.generateEmbedding(content);
|
|
336
|
+
finalEmbeddings[id] = vec;
|
|
337
|
+
entry.hasEmbedding = true;
|
|
338
|
+
stats.updatedEntries++;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
stats.skippedEntries++;
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
stats.skippedEntries++;
|
|
344
|
+
}
|
|
345
|
+
finalEntries[id] = entry;
|
|
346
|
+
} else if (existing) {
|
|
347
|
+
// Changed — re-embed
|
|
348
|
+
if (doEmbeddings) {
|
|
349
|
+
try {
|
|
350
|
+
const vec = await llm.generateEmbedding(content);
|
|
351
|
+
finalEmbeddings[id] = vec;
|
|
352
|
+
entry.hasEmbedding = true;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// Keep going without embedding for this entry
|
|
355
|
+
entry.hasEmbedding = false;
|
|
356
|
+
delete finalEmbeddings[id];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
finalEntries[id] = entry;
|
|
360
|
+
stats.updatedEntries++;
|
|
361
|
+
} else {
|
|
362
|
+
// New entry
|
|
363
|
+
if (doEmbeddings) {
|
|
364
|
+
try {
|
|
365
|
+
const vec = await llm.generateEmbedding(content);
|
|
366
|
+
finalEmbeddings[id] = vec;
|
|
367
|
+
entry.hasEmbedding = true;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
entry.hasEmbedding = false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
finalEntries[id] = entry;
|
|
373
|
+
stats.newEntries++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Remove stale entries (in old index but not in current journals)
|
|
378
|
+
for (const id of Object.keys(existingEntries)) {
|
|
379
|
+
if (!newEntries[id]) {
|
|
380
|
+
delete finalEmbeddings[id];
|
|
381
|
+
stats.removedEntries++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
stats.entryCount = Object.keys(finalEntries).length;
|
|
386
|
+
|
|
387
|
+
// Save
|
|
388
|
+
const index = {
|
|
389
|
+
version: INDEX_VERSION,
|
|
390
|
+
lastUpdated: Date.now(),
|
|
391
|
+
entryCount: stats.entryCount,
|
|
392
|
+
entries: finalEntries,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
backend.saveIndex(index);
|
|
396
|
+
if (doEmbeddings) {
|
|
397
|
+
backend.saveEmbeddings(finalEmbeddings);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
telemetry.capture('reindex', {
|
|
401
|
+
source: 'journals',
|
|
402
|
+
entry_count: stats.entryCount,
|
|
403
|
+
new_entries: stats.newEntries,
|
|
404
|
+
has_embeddings: doEmbeddings,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return stats;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Search journals using semantic similarity.
|
|
412
|
+
* Falls back to searchText() if no embeddings available.
|
|
413
|
+
* @param {string} query - Search query
|
|
414
|
+
* @param {Object} [options]
|
|
415
|
+
* @param {string} [options.storePath] - Content store directory
|
|
416
|
+
* @param {number} [options.limit] - Max results (default: 10)
|
|
417
|
+
* @param {string} [options.repo] - Filter by repo
|
|
418
|
+
* @param {string} [options.since] - Filter by date (YYYY-MM-DD)
|
|
419
|
+
* @param {string} [options.until] - Filter by date (YYYY-MM-DD)
|
|
420
|
+
* @param {boolean} [options.drifted] - Filter by drift status
|
|
421
|
+
* @returns {Promise<Array<{ id: string, score: number, entry: Object }>>}
|
|
422
|
+
*/
|
|
423
|
+
async function searchJournals(query, options = {}) {
|
|
424
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
425
|
+
const limit = options.limit || 10;
|
|
426
|
+
|
|
427
|
+
const backend = getBackend(storePath);
|
|
428
|
+
const index = backend.loadIndex();
|
|
429
|
+
if (!index) return [];
|
|
430
|
+
|
|
431
|
+
const embeddings = backend.loadEmbeddings();
|
|
432
|
+
const hasEmbeddings = Object.keys(embeddings).length > 0;
|
|
433
|
+
|
|
434
|
+
if (!hasEmbeddings) {
|
|
435
|
+
return searchText(query, options);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Generate query embedding
|
|
439
|
+
let queryVec;
|
|
440
|
+
try {
|
|
441
|
+
queryVec = await llm.generateEmbedding(query);
|
|
442
|
+
} catch {
|
|
443
|
+
// Fall back to text search if embedding fails
|
|
444
|
+
return searchText(query, options);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Score all entries
|
|
448
|
+
const results = [];
|
|
449
|
+
for (const [id, entry] of Object.entries(index.entries)) {
|
|
450
|
+
if (!applyFilters(entry, options)) continue;
|
|
451
|
+
const vec = embeddings[id];
|
|
452
|
+
if (!vec) continue;
|
|
453
|
+
|
|
454
|
+
const score = cosineSimilarity(queryVec, vec);
|
|
455
|
+
results.push({ id, score: Math.round(score * 1000) / 1000, entry });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Sort by score descending
|
|
459
|
+
results.sort((a, b) => b.score - a.score);
|
|
460
|
+
|
|
461
|
+
return results.slice(0, limit);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Full-text search across journal entries.
|
|
466
|
+
* Works without any API key. Matches query words against title, repo, tags.
|
|
467
|
+
* @param {string} query - Search query
|
|
468
|
+
* @param {Object} [options]
|
|
469
|
+
* @param {string} [options.storePath] - Content store directory
|
|
470
|
+
* @param {number} [options.limit] - Max results (default: 10)
|
|
471
|
+
* @param {string} [options.repo] - Filter by repo
|
|
472
|
+
* @param {string} [options.since] - Filter by date (YYYY-MM-DD)
|
|
473
|
+
* @param {string} [options.until] - Filter by date (YYYY-MM-DD)
|
|
474
|
+
* @param {boolean} [options.drifted] - Filter by drift status
|
|
475
|
+
* @returns {Array<{ id: string, score: number, entry: Object }>}
|
|
476
|
+
*/
|
|
477
|
+
function searchText(query, options = {}) {
|
|
478
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
479
|
+
const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
|
|
480
|
+
const limit = options.limit || 10;
|
|
481
|
+
|
|
482
|
+
const index = getBackend(storePath).loadIndex();
|
|
483
|
+
if (!index) return [];
|
|
484
|
+
|
|
485
|
+
// Normalize: split on whitespace, hyphens, underscores
|
|
486
|
+
const queryWords = query.toLowerCase().split(/[\s\-_]+/).filter(w => w.length > 1);
|
|
487
|
+
if (queryWords.length === 0) return [];
|
|
488
|
+
|
|
489
|
+
// Pre-load journal content for full-text search (cache by date+user key)
|
|
490
|
+
const journalCache = {};
|
|
491
|
+
function getJournalContent(date, user) {
|
|
492
|
+
const cacheKey = user ? `${date}-${user}` : date;
|
|
493
|
+
if (journalCache[cacheKey] !== undefined) return journalCache[cacheKey];
|
|
494
|
+
if (!journalDir) { journalCache[cacheKey] = null; return null; }
|
|
495
|
+
// Try authored filename first, then plain date filename
|
|
496
|
+
const candidates = user
|
|
497
|
+
? [path.join(journalDir, `${date}-${user}.md`), path.join(journalDir, `${date}.md`)]
|
|
498
|
+
: [path.join(journalDir, `${date}.md`)];
|
|
499
|
+
let content = null;
|
|
500
|
+
for (const filePath of candidates) {
|
|
501
|
+
try {
|
|
502
|
+
content = fs.readFileSync(filePath, 'utf8').toLowerCase();
|
|
503
|
+
break;
|
|
504
|
+
} catch {
|
|
505
|
+
// Try next candidate
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
journalCache[cacheKey] = content;
|
|
509
|
+
return content;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const results = [];
|
|
513
|
+
for (const [id, entry] of Object.entries(index.entries)) {
|
|
514
|
+
if (!applyFilters(entry, options)) continue;
|
|
515
|
+
|
|
516
|
+
// Build searchable text from entry metadata (normalize hyphens/underscores)
|
|
517
|
+
let searchable = [
|
|
518
|
+
entry.title,
|
|
519
|
+
entry.repo,
|
|
520
|
+
entry.date,
|
|
521
|
+
entry.user,
|
|
522
|
+
...(entry.tags || []),
|
|
523
|
+
].filter(Boolean).join(' ').toLowerCase().replace(/[-_]/g, ' ');
|
|
524
|
+
|
|
525
|
+
// Also include the full journal entry content if available
|
|
526
|
+
const journalContent = getJournalContent(entry.date, entry.user);
|
|
527
|
+
if (journalContent) {
|
|
528
|
+
// Find this entry's section in the journal file.
|
|
529
|
+
// Try exact match first, then normalize hyphens/spaces for fuzzy match.
|
|
530
|
+
const repoTitle = `${entry.repo} — ${entry.title}`.toLowerCase();
|
|
531
|
+
let idx = journalContent.indexOf(repoTitle);
|
|
532
|
+
if (idx === -1) {
|
|
533
|
+
// Normalize both sides: collapse hyphens, underscores, em-dashes, and extra spaces
|
|
534
|
+
const norm = (s) => s.replace(/[-_\u2014\u2013]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
535
|
+
const normalized = norm(repoTitle);
|
|
536
|
+
// Search through journal headers for a normalized match
|
|
537
|
+
const headerRegex = /\n## (.+)/g;
|
|
538
|
+
let match;
|
|
539
|
+
while ((match = headerRegex.exec(journalContent)) !== null) {
|
|
540
|
+
const headerNorm = norm(match[1]);
|
|
541
|
+
if (headerNorm.includes(normalized) || normalized.includes(headerNorm)) {
|
|
542
|
+
idx = match.index + 1; // skip the \n
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (idx !== -1) {
|
|
548
|
+
// Extract from header to next header (or end of file)
|
|
549
|
+
const nextHeader = journalContent.indexOf('\n## ', idx + 1);
|
|
550
|
+
const section = nextHeader !== -1 ? journalContent.slice(idx, nextHeader) : journalContent.slice(idx);
|
|
551
|
+
searchable += ' ' + section.replace(/[-_]/g, ' ');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Score: count of matching query words
|
|
556
|
+
let matches = 0;
|
|
557
|
+
for (const word of queryWords) {
|
|
558
|
+
if (searchable.includes(word)) matches++;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (matches > 0) {
|
|
562
|
+
const score = Math.round((matches / queryWords.length) * 1000) / 1000;
|
|
563
|
+
results.push({ id, score, entry });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
results.sort((a, b) => b.score - a.score);
|
|
568
|
+
return results.slice(0, limit);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Apply metadata filters to an entry.
|
|
573
|
+
* @param {Object} entry
|
|
574
|
+
* @param {Object} filters
|
|
575
|
+
* @returns {boolean}
|
|
576
|
+
*/
|
|
577
|
+
function applyFilters(entry, filters) {
|
|
578
|
+
if (isRepoExcluded(entry.repo)) return false;
|
|
579
|
+
if (filters.repo && entry.repo.toLowerCase() !== filters.repo.toLowerCase()) return false;
|
|
580
|
+
if (filters.since && entry.date < filters.since) return false;
|
|
581
|
+
if (filters.until && entry.date > filters.until) return false;
|
|
582
|
+
if (filters.drifted !== undefined && entry.drifted !== filters.drifted) return false;
|
|
583
|
+
if (filters.user && entry.user && entry.user.toLowerCase() !== filters.user.toLowerCase()) return false;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Query metadata from the index.
|
|
589
|
+
* @param {Object} [options]
|
|
590
|
+
* @param {string} [options.storePath] - Content store directory
|
|
591
|
+
* @param {string} [options.repo] - Filter by repo
|
|
592
|
+
* @param {boolean} [options.drifted] - Filter by drift status
|
|
593
|
+
* @param {string} [options.since] - Filter by date (YYYY-MM-DD)
|
|
594
|
+
* @param {string} [options.until] - Filter by date (YYYY-MM-DD)
|
|
595
|
+
* @returns {Array<{ id: string, entry: Object }>}
|
|
596
|
+
*/
|
|
597
|
+
function queryMetadata(options = {}) {
|
|
598
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
599
|
+
const index = getBackend(storePath).loadIndex();
|
|
600
|
+
if (!index) return [];
|
|
601
|
+
|
|
602
|
+
const results = [];
|
|
603
|
+
for (const [id, entry] of Object.entries(index.entries)) {
|
|
604
|
+
if (!applyFilters(entry, options)) continue;
|
|
605
|
+
results.push({ id, entry });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Sort by date descending
|
|
609
|
+
results.sort((a, b) => b.entry.date.localeCompare(a.entry.date));
|
|
610
|
+
return results;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Extract insights from the indexed journal data.
|
|
615
|
+
* @param {Object} [options]
|
|
616
|
+
* @param {string} [options.storePath] - Content store directory
|
|
617
|
+
* @returns {Object} - Insights object
|
|
618
|
+
*/
|
|
619
|
+
function extractInsights(options = {}) {
|
|
620
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
621
|
+
const index = getBackend(storePath).loadIndex();
|
|
622
|
+
if (!index || index.entryCount === 0) {
|
|
623
|
+
return {
|
|
624
|
+
totalSessions: 0,
|
|
625
|
+
driftRate: 0,
|
|
626
|
+
repoActivity: {},
|
|
627
|
+
tagFrequency: {},
|
|
628
|
+
timeline: [],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const entries = Object.values(index.entries);
|
|
633
|
+
const totalSessions = entries.length;
|
|
634
|
+
const driftedCount = entries.filter(e => e.drifted).length;
|
|
635
|
+
const driftRate = Math.round((driftedCount / totalSessions) * 100);
|
|
636
|
+
|
|
637
|
+
// Repo activity
|
|
638
|
+
const repoActivity = {};
|
|
639
|
+
for (const entry of entries) {
|
|
640
|
+
const repo = entry.repo;
|
|
641
|
+
repoActivity[repo] = (repoActivity[repo] || 0) + 1;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Tag frequency
|
|
645
|
+
const tagFrequency = {};
|
|
646
|
+
for (const entry of entries) {
|
|
647
|
+
for (const tag of (entry.tags || [])) {
|
|
648
|
+
tagFrequency[tag] = (tagFrequency[tag] || 0) + 1;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Timeline: sessions per date
|
|
653
|
+
const dateCounts = {};
|
|
654
|
+
for (const entry of entries) {
|
|
655
|
+
dateCounts[entry.date] = (dateCounts[entry.date] || 0) + 1;
|
|
656
|
+
}
|
|
657
|
+
const timeline = Object.entries(dateCounts)
|
|
658
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
659
|
+
.map(([date, count]) => ({ date, sessions: count }));
|
|
660
|
+
|
|
661
|
+
// Decision quality breakdown (conversation entries only)
|
|
662
|
+
const conversationEntries = entries.filter(e => e.source === 'conversation');
|
|
663
|
+
const totalDecisions = conversationEntries.length;
|
|
664
|
+
const richDecisions = conversationEntries.filter(e => e.hasReasoning || e.hasAlternatives).length;
|
|
665
|
+
const thinDecisions = totalDecisions - richDecisions;
|
|
666
|
+
const richRate = totalDecisions > 0 ? Math.round((richDecisions / totalDecisions) * 100) : 0;
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
totalSessions,
|
|
670
|
+
driftRate,
|
|
671
|
+
repoActivity,
|
|
672
|
+
tagFrequency,
|
|
673
|
+
timeline,
|
|
674
|
+
quality: {
|
|
675
|
+
totalDecisions,
|
|
676
|
+
rich: richDecisions,
|
|
677
|
+
thin: thinDecisions,
|
|
678
|
+
richRate,
|
|
679
|
+
},
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Get the full content of a journal entry by re-reading the original file.
|
|
685
|
+
* The index stores metadata but NOT full content. This function re-reads
|
|
686
|
+
* the original journal file on demand to return the complete entry text.
|
|
687
|
+
*
|
|
688
|
+
* Handles three entry types:
|
|
689
|
+
* - Journal entries: re-reads from journalDir
|
|
690
|
+
* - Signal entries (source === 'signal'): reads from signalsDir/channel/
|
|
691
|
+
* - Conversation entries (source === 'conversation'): tries journal path first,
|
|
692
|
+
* falls back to buildContent() metadata
|
|
693
|
+
*
|
|
694
|
+
* @param {string} entryId - Entry ID from the index
|
|
695
|
+
* @param {Object} [options]
|
|
696
|
+
* @param {string} [options.storePath] - Content store directory
|
|
697
|
+
* @param {string} [options.journalDir] - Journal directory
|
|
698
|
+
* @param {string} [options.signalsDir] - Signals directory
|
|
699
|
+
* @returns {string|null} - Full entry text, or null if not found
|
|
700
|
+
*/
|
|
701
|
+
function getEntryContent(entryId, options = {}) {
|
|
702
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
703
|
+
const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
|
|
704
|
+
const signalsDir = options.signalsDir || DEFAULT_SIGNALS_DIR;
|
|
705
|
+
|
|
706
|
+
const index = getBackend(storePath).loadIndex();
|
|
707
|
+
if (!index || !index.entries[entryId]) return null;
|
|
708
|
+
|
|
709
|
+
const entry = index.entries[entryId];
|
|
710
|
+
if (!entry.date) return null;
|
|
711
|
+
|
|
712
|
+
// ── Signal entries ──────────────────────────────────────────────────────
|
|
713
|
+
if (entry.source === 'signal') {
|
|
714
|
+
if (!signalsDir) return null;
|
|
715
|
+
// entry.repo is like 'signals/github' — extract the channel
|
|
716
|
+
const channel = (entry.repo || '').replace(/^signals\//, '');
|
|
717
|
+
if (!channel) return null;
|
|
718
|
+
|
|
719
|
+
const channelDir = path.join(signalsDir, channel);
|
|
720
|
+
if (!fs.existsSync(channelDir)) return null;
|
|
721
|
+
|
|
722
|
+
// Find a matching file in the channel directory
|
|
723
|
+
// Try date-based filename first, then scan for any file containing the title
|
|
724
|
+
const dateCandidates = [
|
|
725
|
+
path.join(channelDir, `${entry.date}.md`),
|
|
726
|
+
path.join(channelDir, `${entry.date}-summary.md`),
|
|
727
|
+
];
|
|
728
|
+
for (const filePath of dateCandidates) {
|
|
729
|
+
try {
|
|
730
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
731
|
+
} catch {
|
|
732
|
+
// Try next candidate
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Scan channel dir for files matching the date
|
|
737
|
+
try {
|
|
738
|
+
const files = fs.readdirSync(channelDir).filter(f => f.endsWith('.md') && f.includes(entry.date));
|
|
739
|
+
for (const file of files) {
|
|
740
|
+
try {
|
|
741
|
+
return fs.readFileSync(path.join(channelDir, file), 'utf8');
|
|
742
|
+
} catch {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// Channel dir not readable
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Conversation entries ────────────────────────────────────────────────
|
|
754
|
+
if (entry.source === 'conversation') {
|
|
755
|
+
// Try journal path first (for --export'd conversations)
|
|
756
|
+
if (journalDir) {
|
|
757
|
+
const candidates = entry.user
|
|
758
|
+
? [path.join(journalDir, `${entry.date}-${entry.user}.md`), path.join(journalDir, `${entry.date}.md`)]
|
|
759
|
+
: [path.join(journalDir, `${entry.date}.md`)];
|
|
760
|
+
|
|
761
|
+
for (const filePath of candidates) {
|
|
762
|
+
const result = parseJournalFile(filePath);
|
|
763
|
+
if (result.date && result.entries.length > 0) {
|
|
764
|
+
const match = result.entries.find(
|
|
765
|
+
(e) => e.repo === entry.repo && e.title === entry.title
|
|
766
|
+
);
|
|
767
|
+
if (match) {
|
|
768
|
+
return buildContent({ ...match, date: entry.date, author: entry.user || match.author });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Fallback: build content from index metadata
|
|
775
|
+
return buildContent({
|
|
776
|
+
repo: entry.repo,
|
|
777
|
+
title: entry.title,
|
|
778
|
+
date: entry.date,
|
|
779
|
+
author: entry.user || '',
|
|
780
|
+
fields: { why: entry.title },
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── Journal entries (default) ───────────────────────────────────────────
|
|
785
|
+
if (!journalDir) return null;
|
|
786
|
+
|
|
787
|
+
// Re-read the original journal file — try authored filename first, then plain date
|
|
788
|
+
const candidates = entry.user
|
|
789
|
+
? [path.join(journalDir, `${entry.date}-${entry.user}.md`), path.join(journalDir, `${entry.date}.md`)]
|
|
790
|
+
: [path.join(journalDir, `${entry.date}.md`)];
|
|
791
|
+
|
|
792
|
+
let parsed = null;
|
|
793
|
+
for (const filePath of candidates) {
|
|
794
|
+
const result = parseJournalFile(filePath);
|
|
795
|
+
if (result.date && result.entries.length > 0) {
|
|
796
|
+
// Check if this file contains the entry we're looking for
|
|
797
|
+
const hasMatch = result.entries.some(
|
|
798
|
+
(e) => e.repo === entry.repo && e.title === entry.title
|
|
799
|
+
);
|
|
800
|
+
if (hasMatch) {
|
|
801
|
+
parsed = result;
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!parsed) return null;
|
|
808
|
+
|
|
809
|
+
// Find the matching entry by repo + title
|
|
810
|
+
const match = parsed.entries.find(
|
|
811
|
+
(e) => e.repo === entry.repo && e.title === entry.title
|
|
812
|
+
);
|
|
813
|
+
if (!match) return null;
|
|
814
|
+
|
|
815
|
+
// Build the full text content from the matched entry
|
|
816
|
+
return buildContent({ ...match, date: entry.date, author: entry.user || match.author });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ── Conversation parsing and extraction ─────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Decode a Claude Code project directory name to a repo path.
|
|
823
|
+
* Directory names encode the full filesystem path with dashes as separators.
|
|
824
|
+
* e.g. "-home-user-repos-acme-corp-web-api" → "acme-corp/web-api"
|
|
825
|
+
*
|
|
826
|
+
* Strategy: reconstruct the original path by scanning the actual filesystem
|
|
827
|
+
* to find where segment boundaries are, since repo names can contain hyphens.
|
|
828
|
+
* Falls back to taking everything after "repos" in the encoded path.
|
|
829
|
+
* @param {string} dirName - Project directory name
|
|
830
|
+
* @returns {string} - Human-readable repo name
|
|
831
|
+
*/
|
|
832
|
+
function projectDirToRepo(dirName) {
|
|
833
|
+
// The dir name is the full path with / replaced by -
|
|
834
|
+
// Reconstruct: try to resolve the actual filesystem path
|
|
835
|
+
const asPath = dirName.replace(/^-/, '/').replace(/-/g, '/');
|
|
836
|
+
|
|
837
|
+
// Try progressively joining segments to find real directories
|
|
838
|
+
const parts = asPath.split('/').filter(Boolean);
|
|
839
|
+
const resolved = [];
|
|
840
|
+
let i = 0;
|
|
841
|
+
while (i < parts.length) {
|
|
842
|
+
// Try longest match first (handles hyphenated names)
|
|
843
|
+
let found = false;
|
|
844
|
+
for (let len = parts.length - i; len >= 1; len--) {
|
|
845
|
+
const candidate = '/' + [...resolved, parts.slice(i, i + len).join('-')].join('/');
|
|
846
|
+
try {
|
|
847
|
+
fs.statSync(candidate);
|
|
848
|
+
resolved.push(parts.slice(i, i + len).join('-'));
|
|
849
|
+
i += len;
|
|
850
|
+
found = true;
|
|
851
|
+
break;
|
|
852
|
+
} catch {
|
|
853
|
+
// Not a valid path, try shorter
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (!found) {
|
|
857
|
+
resolved.push(parts[i]);
|
|
858
|
+
i++;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Find 'repos' marker and return everything after it
|
|
863
|
+
const reposIdx = resolved.indexOf('repos');
|
|
864
|
+
if (reposIdx !== -1 && resolved.length > reposIdx + 1) {
|
|
865
|
+
return resolved.slice(reposIdx + 1).join('/');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Fallback: last two segments
|
|
869
|
+
return resolved.length >= 2 ? resolved.slice(-2).join('/') : resolved.join('/');
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Parse a Claude Code .jsonl transcript into a filtered human-readable exchange.
|
|
874
|
+
* Strips tool_use, thinking, progress, and file-history-snapshot messages.
|
|
875
|
+
* @param {string} filePath - Path to .jsonl transcript
|
|
876
|
+
* @returns {{ messages: Array<{ role: string, text: string }>, sessionId: string, timestamp: string, repo: string }}
|
|
877
|
+
*/
|
|
878
|
+
function parseTranscript(filePath) {
|
|
879
|
+
let content;
|
|
880
|
+
try {
|
|
881
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
882
|
+
} catch {
|
|
883
|
+
return { messages: [], sessionId: '', timestamp: '', repo: '' };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const lines = content.split('\n').filter(Boolean);
|
|
887
|
+
const messages = [];
|
|
888
|
+
let sessionId = '';
|
|
889
|
+
let timestamp = '';
|
|
890
|
+
let repo = '';
|
|
891
|
+
|
|
892
|
+
// Derive repo from parent directory name
|
|
893
|
+
const dirName = path.basename(path.dirname(filePath));
|
|
894
|
+
repo = projectDirToRepo(dirName);
|
|
895
|
+
|
|
896
|
+
for (const line of lines) {
|
|
897
|
+
let obj;
|
|
898
|
+
try {
|
|
899
|
+
obj = JSON.parse(line);
|
|
900
|
+
} catch {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!sessionId && obj.sessionId) sessionId = obj.sessionId;
|
|
905
|
+
if (!timestamp && obj.timestamp) timestamp = obj.timestamp;
|
|
906
|
+
|
|
907
|
+
if (obj.type === 'user' && obj.message) {
|
|
908
|
+
const content = obj.message.content;
|
|
909
|
+
if (typeof content === 'string' && content.trim()) {
|
|
910
|
+
messages.push({ role: 'user', text: content.trim() });
|
|
911
|
+
}
|
|
912
|
+
} else if (obj.type === 'assistant' && obj.message) {
|
|
913
|
+
const blocks = obj.message.content;
|
|
914
|
+
if (Array.isArray(blocks)) {
|
|
915
|
+
const textParts = blocks
|
|
916
|
+
.filter((b) => b.type === 'text' && b.text)
|
|
917
|
+
.map((b) => b.text.trim())
|
|
918
|
+
.filter(Boolean);
|
|
919
|
+
if (textParts.length > 0) {
|
|
920
|
+
messages.push({ role: 'assistant', text: textParts.join('\n') });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
return { messages, sessionId, timestamp, repo };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Build a condensed transcript for LLM extraction.
|
|
931
|
+
* Caps at ~4000 words to stay within Haiku's sweet spot.
|
|
932
|
+
* @param {Array<{ role: string, text: string }>} messages
|
|
933
|
+
* @returns {string}
|
|
934
|
+
*/
|
|
935
|
+
function buildTranscriptText(messages) {
|
|
936
|
+
const parts = [];
|
|
937
|
+
let wordCount = 0;
|
|
938
|
+
const MAX_WORDS = 4000;
|
|
939
|
+
|
|
940
|
+
for (const msg of messages) {
|
|
941
|
+
const prefix = msg.role === 'user' ? 'USER' : 'ASSISTANT';
|
|
942
|
+
const text = `${prefix}: ${msg.text}`;
|
|
943
|
+
const words = text.split(/\s+/).length;
|
|
944
|
+
if (wordCount + words > MAX_WORDS) break;
|
|
945
|
+
parts.push(text);
|
|
946
|
+
wordCount += words;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return parts.join('\n\n');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** System prompt for decision extraction from conversation transcripts. */
|
|
953
|
+
const EXTRACTION_PROMPT = `You are a decision extraction engine. Given a conversation transcript between a developer and an AI coding assistant, extract the key decision points.
|
|
954
|
+
|
|
955
|
+
Focus on:
|
|
956
|
+
- Rejected approaches ("don't do X because Y")
|
|
957
|
+
- Explicit trade-offs ("we chose A over B because...")
|
|
958
|
+
- Architecture or design choices
|
|
959
|
+
- Requirement clarifications
|
|
960
|
+
- Convention or pattern decisions
|
|
961
|
+
- Tech debt identified
|
|
962
|
+
|
|
963
|
+
Output a JSON array of decision objects. Each object has:
|
|
964
|
+
- "title": short summary (under 80 chars)
|
|
965
|
+
- "decision": what was decided and why (1-3 sentences)
|
|
966
|
+
- "alternatives": rejected alternatives, if any (1 sentence or empty string)
|
|
967
|
+
- "tags": array of 2-5 lowercase keyword tags
|
|
968
|
+
- "has_reasoning": boolean — true if the decision includes WHY it was made (rationale, constraints, tradeoffs), not just WHAT was decided
|
|
969
|
+
- "has_alternatives": boolean — true if rejected alternatives or considered options are mentioned
|
|
970
|
+
|
|
971
|
+
Strip any credentials, API keys, or tokens from the output.
|
|
972
|
+
|
|
973
|
+
If the conversation has no meaningful decisions (just file reads, simple edits, routine work), return an empty array [].
|
|
974
|
+
|
|
975
|
+
Return ONLY the JSON array, no other text.`;
|
|
976
|
+
|
|
977
|
+
const SHIFT_DETECTION_PROMPT = `You are a VERY conservative context shift detector. Your job is to say "no shift" for almost every session. Only flag genuinely rare, high-impact changes.
|
|
978
|
+
|
|
979
|
+
SHIFTS (hasShift: true) — these are RARE events, maybe 1 in 20 sessions:
|
|
980
|
+
- Spinning up a brand-new repo, service, or major subsystem
|
|
981
|
+
- Team reorgs or personnel changes affecting who works on what
|
|
982
|
+
- Strategic pivots (new market, product direction change, competitive response)
|
|
983
|
+
- Breaking changes to a public API or shared contract
|
|
984
|
+
- New infrastructure dependencies (new database, new cloud service, new CI provider)
|
|
985
|
+
- Discovered security vulnerability or data loss incident
|
|
986
|
+
|
|
987
|
+
NOT SHIFTS (hasShift: false) — this covers 95%+ of sessions:
|
|
988
|
+
- Bug fixes of any size or quantity
|
|
989
|
+
- Feature iterations, enhancements, improvements, polish
|
|
990
|
+
- Version bumps, releases, publishing (even many in one session)
|
|
991
|
+
- Config changes, env var updates, dependency upgrades
|
|
992
|
+
- Refactoring, renaming, restructuring within existing architecture
|
|
993
|
+
- Adding tests, docs, CI tweaks, linting fixes
|
|
994
|
+
- Performance optimization
|
|
995
|
+
- Routine operational work (deploys, monitoring, alerts)
|
|
996
|
+
- Anything described as "incremental", "fix", "update", "clean up", "tweak"
|
|
997
|
+
|
|
998
|
+
When in doubt, return hasShift: false. A session that ships 10 patch versions is routine. A session that fixes 5 bugs is routine. Only flag things that would genuinely surprise a teammate returning from vacation.
|
|
999
|
+
|
|
1000
|
+
Return ONLY a JSON object:
|
|
1001
|
+
{
|
|
1002
|
+
"hasShift": boolean,
|
|
1003
|
+
"summary": "1-2 sentence summary (empty string if no shift)",
|
|
1004
|
+
"stateUpdates": {
|
|
1005
|
+
"team": "text to append to team-state.md, or empty string",
|
|
1006
|
+
"personal": "text to append to personal-state.md, or empty string"
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
Keep state updates concise (2-4 lines max each). Use markdown. Include dates.
|
|
1011
|
+
Return ONLY the JSON object, no other text.`;
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Classify extracted decisions as routine or significant context shifts.
|
|
1015
|
+
* Uses a lightweight LLM call (Haiku) to score decisions.
|
|
1016
|
+
* @param {Array<{ date: string, repo: string, decisions: Array }>} allDecisions
|
|
1017
|
+
* @param {Object} llmConfig - LLM config (will override model to Haiku)
|
|
1018
|
+
* @param {string} [currentStateContext] - Current state file contents for context
|
|
1019
|
+
* @returns {Promise<{ hasShift: boolean, summary: string, stateUpdates: { team: string, personal: string } }>}
|
|
1020
|
+
*/
|
|
1021
|
+
async function detectContextShift(allDecisions, llmConfig, currentStateContext) {
|
|
1022
|
+
if (!allDecisions || allDecisions.length === 0) {
|
|
1023
|
+
return { hasShift: false, summary: '', stateUpdates: { team: '', personal: '' } };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Flatten all decisions into a summary for classification
|
|
1027
|
+
const decisionSummary = allDecisions.map(({ date, repo, decisions }) =>
|
|
1028
|
+
decisions.map(d =>
|
|
1029
|
+
`[${date}] ${repo}: ${d.title} — ${d.decision}` +
|
|
1030
|
+
(d.alternatives ? ` (rejected: ${d.alternatives})` : '')
|
|
1031
|
+
).join('\n')
|
|
1032
|
+
).join('\n');
|
|
1033
|
+
|
|
1034
|
+
if (decisionSummary.trim().length < 50) {
|
|
1035
|
+
return { hasShift: false, summary: '', stateUpdates: { team: '', personal: '' } };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Use Haiku for cost efficiency
|
|
1039
|
+
const shiftConfig = {
|
|
1040
|
+
...llmConfig,
|
|
1041
|
+
model: process.env.TEAM_CONTEXT_SHIFT_MODEL || 'claude-haiku-4-5-20251001',
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const userContent = (currentStateContext
|
|
1045
|
+
? `Current state context:\n${currentStateContext}\n\n`
|
|
1046
|
+
: '') +
|
|
1047
|
+
`Decisions from this session:\n${decisionSummary}`;
|
|
1048
|
+
|
|
1049
|
+
try {
|
|
1050
|
+
const response = await llm.call(shiftConfig, SHIFT_DETECTION_PROMPT, userContent);
|
|
1051
|
+
let jsonStr = response.trim();
|
|
1052
|
+
if (jsonStr.startsWith('```')) {
|
|
1053
|
+
jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
1054
|
+
}
|
|
1055
|
+
const result = JSON.parse(jsonStr);
|
|
1056
|
+
if (typeof result.hasShift !== 'boolean') return { hasShift: false, summary: '', stateUpdates: { team: '', personal: '' } };
|
|
1057
|
+
return {
|
|
1058
|
+
hasShift: result.hasShift,
|
|
1059
|
+
summary: result.summary || '',
|
|
1060
|
+
stateUpdates: {
|
|
1061
|
+
team: (result.stateUpdates && result.stateUpdates.team) || '',
|
|
1062
|
+
personal: (result.stateUpdates && result.stateUpdates.personal) || '',
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
} catch {
|
|
1066
|
+
// Fail-safe: don't block session exit
|
|
1067
|
+
return { hasShift: false, summary: '', stateUpdates: { team: '', personal: '' } };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Apply context shift updates to state files.
|
|
1073
|
+
* Appends shift summary to personal-state.md and/or team-state.md.
|
|
1074
|
+
* @param {{ team: string, personal: string }} stateUpdates
|
|
1075
|
+
* @param {string} repoDir - Repository root directory
|
|
1076
|
+
* @param {string} shiftSummary - One-line summary of the shift
|
|
1077
|
+
* @returns {{ teamUpdated: boolean, personalUpdated: boolean }}
|
|
1078
|
+
*/
|
|
1079
|
+
function applyContextShiftToState(stateUpdates, repoDir, shiftSummary) {
|
|
1080
|
+
const claudeDir = path.join(repoDir, '.claude');
|
|
1081
|
+
const result = { teamUpdated: false, personalUpdated: false };
|
|
1082
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1083
|
+
const header = `\n\n### Context shift detected (${date})\n${shiftSummary}\n\n`;
|
|
1084
|
+
|
|
1085
|
+
// Dedup: check if a shift was already appended today to avoid bloating state files
|
|
1086
|
+
const alreadyAppendedToday = (filePath) => {
|
|
1087
|
+
if (!fs.existsSync(filePath)) return false;
|
|
1088
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1089
|
+
return content.includes(`### Context shift detected (${date})`);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
if (stateUpdates.team) {
|
|
1093
|
+
const teamFile = path.join(claudeDir, 'team-state.md');
|
|
1094
|
+
if (fs.existsSync(teamFile) && !alreadyAppendedToday(teamFile)) {
|
|
1095
|
+
fs.appendFileSync(teamFile, header + stateUpdates.team + '\n');
|
|
1096
|
+
result.teamUpdated = true;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (stateUpdates.personal) {
|
|
1101
|
+
const personalFile = path.join(claudeDir, 'personal-state.md');
|
|
1102
|
+
if (fs.existsSync(personalFile) && !alreadyAppendedToday(personalFile)) {
|
|
1103
|
+
fs.appendFileSync(personalFile, header + stateUpdates.personal + '\n');
|
|
1104
|
+
result.personalUpdated = true;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
return result;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Extract decision points from a transcript using an LLM.
|
|
1113
|
+
* @param {string} transcriptText - Condensed transcript
|
|
1114
|
+
* @param {Object} llmConfig - LLM configuration
|
|
1115
|
+
* @returns {Promise<Array<{ title: string, decision: string, alternatives: string, tags: string[] }>>}
|
|
1116
|
+
*/
|
|
1117
|
+
async function extractDecisions(transcriptText, llmConfig) {
|
|
1118
|
+
if (!transcriptText || transcriptText.trim().length < 100) return [];
|
|
1119
|
+
|
|
1120
|
+
const response = await llm.call(llmConfig, EXTRACTION_PROMPT, transcriptText);
|
|
1121
|
+
|
|
1122
|
+
// Parse JSON from response — handle markdown code fences
|
|
1123
|
+
let jsonStr = response.trim();
|
|
1124
|
+
if (jsonStr.startsWith('```')) {
|
|
1125
|
+
jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
const decisions = JSON.parse(jsonStr);
|
|
1130
|
+
if (!Array.isArray(decisions)) return [];
|
|
1131
|
+
return decisions.filter(
|
|
1132
|
+
(d) => d && d.title && d.decision
|
|
1133
|
+
);
|
|
1134
|
+
} catch {
|
|
1135
|
+
return [];
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Compute a file fingerprint for incremental indexing.
|
|
1141
|
+
* @param {string} filePath
|
|
1142
|
+
* @returns {string} - Hash of path + size + mtime
|
|
1143
|
+
*/
|
|
1144
|
+
function fileFingerprint(filePath) {
|
|
1145
|
+
try {
|
|
1146
|
+
const stat = fs.statSync(filePath);
|
|
1147
|
+
const input = `${filePath}:${stat.size}:${stat.mtimeMs}`;
|
|
1148
|
+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
1149
|
+
} catch {
|
|
1150
|
+
return '';
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Index conversation transcripts. Extracts decision points via LLM and adds to content store.
|
|
1156
|
+
* Incremental — skips transcripts already indexed (by file fingerprint).
|
|
1157
|
+
* @param {Object} options
|
|
1158
|
+
* @param {string} [options.projectsDir] - Claude Code projects directory
|
|
1159
|
+
* @param {string} [options.storePath] - Content store directory
|
|
1160
|
+
* @param {Object} [options.llmConfig] - LLM config for extraction
|
|
1161
|
+
* @param {boolean} [options.embeddings] - Generate embeddings
|
|
1162
|
+
* @param {string} [options.since] - Only index transcripts modified after this date (YYYY-MM-DD)
|
|
1163
|
+
* @param {Function} [options.onProgress] - Progress callback
|
|
1164
|
+
* @returns {Promise<Object>} - Stats: { transcriptsScanned, transcriptsProcessed, decisionsExtracted, skipped, errors }
|
|
1165
|
+
*/
|
|
1166
|
+
async function indexConversations(options = {}) {
|
|
1167
|
+
const projectsDir = options.projectsDir || DEFAULT_PROJECTS_DIR;
|
|
1168
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
1169
|
+
const doEmbeddings = options.embeddings !== undefined
|
|
1170
|
+
? options.embeddings
|
|
1171
|
+
: !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
|
|
1172
|
+
|
|
1173
|
+
if (!projectsDir || !storePath) {
|
|
1174
|
+
throw new Error('Projects directory and store path are required.');
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (!fs.existsSync(projectsDir)) {
|
|
1178
|
+
throw new Error(`Projects directory not found: ${projectsDir}`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Default LLM config for extraction — use Haiku for cost efficiency
|
|
1182
|
+
const llmConfig = options.llmConfig || {
|
|
1183
|
+
provider: 'anthropic',
|
|
1184
|
+
model: process.env.TEAM_CONTEXT_EXTRACTION_MODEL || 'claude-haiku-4-5-20251001',
|
|
1185
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const stats = { transcriptsScanned: 0, transcriptsProcessed: 0, decisionsExtracted: 0, skipped: 0, errors: 0 };
|
|
1189
|
+
|
|
1190
|
+
// Load existing indexes
|
|
1191
|
+
const backend = getBackend(storePath);
|
|
1192
|
+
const existingIndex = backend.loadIndex() || { version: INDEX_VERSION, entries: {}, lastUpdated: Date.now(), entryCount: 0 };
|
|
1193
|
+
const existingEmbeddings = doEmbeddings ? backend.loadEmbeddings() : {};
|
|
1194
|
+
const convIndex = backend.loadConversationIndex();
|
|
1195
|
+
|
|
1196
|
+
// Compute since cutoff
|
|
1197
|
+
let sinceCutoff = 0;
|
|
1198
|
+
if (options.since) {
|
|
1199
|
+
sinceCutoff = new Date(options.since).getTime();
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Scan all project directories for .jsonl files
|
|
1203
|
+
const projectDirs = fs.readdirSync(projectsDir).filter((d) => {
|
|
1204
|
+
const full = path.join(projectsDir, d);
|
|
1205
|
+
return fs.statSync(full).isDirectory() || d.endsWith('.jsonl');
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
const transcriptFiles = [];
|
|
1209
|
+
for (const dir of projectDirs) {
|
|
1210
|
+
const dirPath = path.join(projectsDir, dir);
|
|
1211
|
+
if (dir.endsWith('.jsonl')) {
|
|
1212
|
+
transcriptFiles.push(dirPath);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
try {
|
|
1216
|
+
const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.jsonl'));
|
|
1217
|
+
for (const f of files) {
|
|
1218
|
+
transcriptFiles.push(path.join(dirPath, f));
|
|
1219
|
+
}
|
|
1220
|
+
} catch {
|
|
1221
|
+
// Skip unreadable directories
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
stats.transcriptsScanned = transcriptFiles.length;
|
|
1226
|
+
|
|
1227
|
+
// Phase 1: Collect candidates that need LLM extraction (synchronous, no shared-state risk)
|
|
1228
|
+
const candidates = [];
|
|
1229
|
+
for (const filePath of transcriptFiles) {
|
|
1230
|
+
// Check since cutoff
|
|
1231
|
+
if (sinceCutoff) {
|
|
1232
|
+
try {
|
|
1233
|
+
const stat = fs.statSync(filePath);
|
|
1234
|
+
if (stat.mtimeMs < sinceCutoff) {
|
|
1235
|
+
stats.skipped++;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
} catch {
|
|
1239
|
+
stats.skipped++;
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Check fingerprint for incremental indexing
|
|
1245
|
+
const fp = fileFingerprint(filePath);
|
|
1246
|
+
if (convIndex[filePath] && convIndex[filePath].fingerprint === fp) {
|
|
1247
|
+
stats.skipped++;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Parse transcript
|
|
1252
|
+
const transcript = parseTranscript(filePath);
|
|
1253
|
+
if (transcript.messages.length < 3) {
|
|
1254
|
+
// Too short to contain meaningful decisions
|
|
1255
|
+
convIndex[filePath] = { fingerprint: fp, entryIds: [], extractedAt: Date.now() };
|
|
1256
|
+
stats.skipped++;
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const transcriptText = buildTranscriptText(transcript.messages);
|
|
1261
|
+
candidates.push({ filePath, fp, transcript, transcriptText });
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Phase 2: Fire LLM extraction calls in parallel with concurrency cap
|
|
1265
|
+
// Cap at 5 to avoid API rate limits while still getting parallelism benefit
|
|
1266
|
+
const MAX_CONCURRENT = 5;
|
|
1267
|
+
|
|
1268
|
+
if (options.onProgress) {
|
|
1269
|
+
for (const c of candidates) {
|
|
1270
|
+
options.onProgress({ phase: 'extracting', file: c.filePath, repo: c.transcript.repo });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const extractionResults = [];
|
|
1275
|
+
for (let i = 0; i < candidates.length; i += MAX_CONCURRENT) {
|
|
1276
|
+
const batch = candidates.slice(i, i + MAX_CONCURRENT);
|
|
1277
|
+
const batchResults = await Promise.all(
|
|
1278
|
+
batch.map(async (c) => {
|
|
1279
|
+
try {
|
|
1280
|
+
const decisions = await extractDecisions(c.transcriptText, llmConfig);
|
|
1281
|
+
return { ...c, decisions, error: null };
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
return { ...c, decisions: [], error: err.message };
|
|
1284
|
+
}
|
|
1285
|
+
})
|
|
1286
|
+
);
|
|
1287
|
+
extractionResults.push(...batchResults);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Phase 3: Merge results into shared indexes (sequential — single-threaded, no races)
|
|
1291
|
+
for (const result of extractionResults) {
|
|
1292
|
+
if (result.error) {
|
|
1293
|
+
console.error(`Extraction failed for ${result.filePath}: ${result.error}`);
|
|
1294
|
+
stats.errors++;
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const { filePath, fp, transcript, decisions } = result;
|
|
1299
|
+
|
|
1300
|
+
// Store extracted decisions in the content store
|
|
1301
|
+
const entryIds = [];
|
|
1302
|
+
const date = transcript.timestamp
|
|
1303
|
+
? transcript.timestamp.slice(0, 10)
|
|
1304
|
+
: new Date().toISOString().slice(0, 10);
|
|
1305
|
+
|
|
1306
|
+
for (const decision of decisions) {
|
|
1307
|
+
const id = generateEntryId(date, transcript.repo, decision.title);
|
|
1308
|
+
const content = [
|
|
1309
|
+
`${transcript.repo} — ${decision.title}`,
|
|
1310
|
+
`Date: ${date}`,
|
|
1311
|
+
`Decision: ${decision.decision}`,
|
|
1312
|
+
decision.alternatives ? `Alternatives considered: ${decision.alternatives}` : '',
|
|
1313
|
+
].filter(Boolean).join('\n');
|
|
1314
|
+
|
|
1315
|
+
const hash = contentHash(content);
|
|
1316
|
+
|
|
1317
|
+
existingIndex.entries[id] = {
|
|
1318
|
+
date,
|
|
1319
|
+
repo: transcript.repo,
|
|
1320
|
+
title: decision.title,
|
|
1321
|
+
source: 'conversation',
|
|
1322
|
+
user: '',
|
|
1323
|
+
drifted: false,
|
|
1324
|
+
contentHash: hash,
|
|
1325
|
+
contentLength: content.length,
|
|
1326
|
+
tags: decision.tags || [],
|
|
1327
|
+
hasEmbedding: false,
|
|
1328
|
+
hasReasoning: !!decision.has_reasoning,
|
|
1329
|
+
hasAlternatives: !!decision.has_alternatives,
|
|
1330
|
+
_content: content,
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
if (doEmbeddings) {
|
|
1334
|
+
try {
|
|
1335
|
+
const vec = await llm.generateEmbedding(content);
|
|
1336
|
+
existingEmbeddings[id] = vec;
|
|
1337
|
+
existingIndex.entries[id].hasEmbedding = true;
|
|
1338
|
+
} catch {
|
|
1339
|
+
// Continue without embedding
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
delete existingIndex.entries[id]._content;
|
|
1344
|
+
entryIds.push(id);
|
|
1345
|
+
stats.decisionsExtracted++;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Notify caller of extracted decisions (used for journal export)
|
|
1349
|
+
if (options.onDecisions && decisions.length > 0) {
|
|
1350
|
+
options.onDecisions(date, transcript.repo, decisions);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Update conversation index
|
|
1354
|
+
convIndex[filePath] = { fingerprint: fp, entryIds, extractedAt: Date.now() };
|
|
1355
|
+
stats.transcriptsProcessed++;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Save everything
|
|
1359
|
+
existingIndex.entryCount = Object.keys(existingIndex.entries).length;
|
|
1360
|
+
backend.saveIndex(existingIndex);
|
|
1361
|
+
if (doEmbeddings) {
|
|
1362
|
+
backend.saveEmbeddings(existingEmbeddings);
|
|
1363
|
+
}
|
|
1364
|
+
backend.saveConversationIndex(convIndex);
|
|
1365
|
+
|
|
1366
|
+
return stats;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ── Onboarding context packs ────────────────────────────────────────────────
|
|
1370
|
+
|
|
1371
|
+
/** System prompt for onboarding pack synthesis. */
|
|
1372
|
+
const ONBOARDING_PROMPT = `You are Wayfind, generating an onboarding context pack for a new engineer joining a repo. Synthesize the provided decision trail entries into a structured onboarding document.
|
|
1373
|
+
|
|
1374
|
+
Organize into these sections (skip any section with no relevant content):
|
|
1375
|
+
|
|
1376
|
+
## What this repo does
|
|
1377
|
+
Brief summary inferred from the decisions and session context.
|
|
1378
|
+
|
|
1379
|
+
## Key architecture decisions
|
|
1380
|
+
The most important technical choices and why they were made. Include dates.
|
|
1381
|
+
|
|
1382
|
+
## Recent changes
|
|
1383
|
+
What's been actively worked on in the last few weeks.
|
|
1384
|
+
|
|
1385
|
+
## Open questions and tech debt
|
|
1386
|
+
Unresolved issues, known debt, things to watch out for.
|
|
1387
|
+
|
|
1388
|
+
## Gotchas and conventions
|
|
1389
|
+
Patterns, workarounds, and things that aren't obvious from reading the code.
|
|
1390
|
+
|
|
1391
|
+
## Who works on this
|
|
1392
|
+
People mentioned in the decision trail and what they focus on.
|
|
1393
|
+
|
|
1394
|
+
Rules:
|
|
1395
|
+
- Be concise and specific. This is a reference doc, not a narrative.
|
|
1396
|
+
- Cite dates when referencing decisions.
|
|
1397
|
+
- Under 1000 words total.
|
|
1398
|
+
- Format in markdown.
|
|
1399
|
+
- Do not invent information not in the provided context.`;
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Generate an onboarding context pack for a repo.
|
|
1403
|
+
* Queries the content store for recent entries, fetches full content, and synthesizes.
|
|
1404
|
+
* @param {string} repoQuery - Repo name or partial match (e.g. "SellingService")
|
|
1405
|
+
* @param {Object} options
|
|
1406
|
+
* @param {string} [options.storePath] - Content store directory
|
|
1407
|
+
* @param {string} [options.journalDir] - Journal directory (for full content retrieval)
|
|
1408
|
+
* @param {number} [options.days] - Lookback window in days (default: 90)
|
|
1409
|
+
* @param {Object} [options.llmConfig] - LLM configuration
|
|
1410
|
+
* @returns {Promise<string>} - Synthesized onboarding document in markdown
|
|
1411
|
+
*/
|
|
1412
|
+
async function generateOnboardingPack(repoQuery, options = {}) {
|
|
1413
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
1414
|
+
const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
|
|
1415
|
+
const days = options.days || 90;
|
|
1416
|
+
const llmConfig = options.llmConfig || {
|
|
1417
|
+
provider: 'anthropic',
|
|
1418
|
+
model: process.env.TEAM_CONTEXT_LLM_MODEL || 'claude-sonnet-4-5-20250929',
|
|
1419
|
+
api_key_env: 'ANTHROPIC_API_KEY',
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const index = getBackend(storePath).loadIndex();
|
|
1423
|
+
if (!index || index.entryCount === 0) {
|
|
1424
|
+
throw new Error('Content store is empty. Run "wayfind reindex" first.');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Compute since date
|
|
1428
|
+
const sinceDate = new Date();
|
|
1429
|
+
sinceDate.setDate(sinceDate.getDate() - days);
|
|
1430
|
+
const since = sinceDate.toISOString().slice(0, 10);
|
|
1431
|
+
|
|
1432
|
+
// Find matching entries — fuzzy match on repo name
|
|
1433
|
+
const queryLower = repoQuery.toLowerCase();
|
|
1434
|
+
const matchingEntries = [];
|
|
1435
|
+
for (const [id, entry] of Object.entries(index.entries)) {
|
|
1436
|
+
if (entry.date < since) continue;
|
|
1437
|
+
const repoLower = entry.repo.toLowerCase();
|
|
1438
|
+
if (repoLower.includes(queryLower) || queryLower.includes(repoLower)) {
|
|
1439
|
+
matchingEntries.push({ id, entry });
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (matchingEntries.length === 0) {
|
|
1444
|
+
throw new Error(`No entries found for "${repoQuery}" in the last ${days} days.`);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Sort by date descending, cap at 30 entries
|
|
1448
|
+
matchingEntries.sort((a, b) => b.entry.date.localeCompare(a.entry.date));
|
|
1449
|
+
const topEntries = matchingEntries.slice(0, 30);
|
|
1450
|
+
|
|
1451
|
+
// Fetch full content for each entry
|
|
1452
|
+
const contentParts = [];
|
|
1453
|
+
for (const { id, entry } of topEntries) {
|
|
1454
|
+
const fullContent = getEntryContent(id, { storePath, journalDir });
|
|
1455
|
+
if (fullContent) {
|
|
1456
|
+
contentParts.push(`---\n${fullContent}`);
|
|
1457
|
+
} else {
|
|
1458
|
+
// Metadata-only fallback
|
|
1459
|
+
const source = entry.source === 'conversation' ? ' [decision]' : '';
|
|
1460
|
+
contentParts.push(
|
|
1461
|
+
`---\n${entry.date} | ${entry.repo} | ${entry.title}${source}\n` +
|
|
1462
|
+
`Tags: ${(entry.tags || []).join(', ')}`
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const repoName = topEntries[0].entry.repo;
|
|
1468
|
+
const context = contentParts.join('\n\n');
|
|
1469
|
+
const userContent =
|
|
1470
|
+
`Generate an onboarding context pack for: ${repoName}\n` +
|
|
1471
|
+
`Time range: last ${days} days (${topEntries.length} entries)\n\n` +
|
|
1472
|
+
`Decision trail entries:\n\n${context}`;
|
|
1473
|
+
|
|
1474
|
+
return await llm.call(llmConfig, ONBOARDING_PROMPT, userContent);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* Export extracted decisions as journal-format markdown entries.
|
|
1479
|
+
* Appends to the appropriate date file in the journal directory so they
|
|
1480
|
+
* get git-synced and picked up by the container's journal indexer.
|
|
1481
|
+
* @param {string} date - YYYY-MM-DD
|
|
1482
|
+
* @param {string} repo - Repo name (e.g. "acme-corp/web-api")
|
|
1483
|
+
* @param {Array<{ title: string, decision: string, alternatives: string, tags: string[] }>} decisions
|
|
1484
|
+
* @param {string} journalDir - Journal directory path
|
|
1485
|
+
*/
|
|
1486
|
+
function exportDecisionsAsJournal(date, repo, decisions, journalDir, teamId, author) {
|
|
1487
|
+
if (!decisions || decisions.length === 0) return;
|
|
1488
|
+
|
|
1489
|
+
const authorPart = author ? `-${author}` : '';
|
|
1490
|
+
const teamPart = teamId ? `-${teamId}` : '';
|
|
1491
|
+
const filePath = path.join(journalDir, `${date}${authorPart}${teamPart}.md`);
|
|
1492
|
+
|
|
1493
|
+
// Dedup each decision individually against existing file content
|
|
1494
|
+
const existing = fs.existsSync(filePath)
|
|
1495
|
+
? fs.readFileSync(filePath, 'utf8')
|
|
1496
|
+
: null;
|
|
1497
|
+
|
|
1498
|
+
const newLines = [];
|
|
1499
|
+
for (const d of decisions) {
|
|
1500
|
+
// Skip if this decision's title already appears in the file
|
|
1501
|
+
if (existing && existing.includes(d.title)) continue;
|
|
1502
|
+
|
|
1503
|
+
const qualityTags = [];
|
|
1504
|
+
if (d.has_reasoning) qualityTags.push('rich:reasoning');
|
|
1505
|
+
if (d.has_alternatives) qualityTags.push('rich:alternatives');
|
|
1506
|
+
const qualityLabel = qualityTags.length > 0 ? qualityTags.join(', ') : 'thin';
|
|
1507
|
+
|
|
1508
|
+
newLines.push(`## ${repo} — ${d.title} [decision]`);
|
|
1509
|
+
newLines.push(`**Why:** Extracted from conversation transcript`);
|
|
1510
|
+
newLines.push(`**What:** ${d.decision}`);
|
|
1511
|
+
if (d.alternatives) {
|
|
1512
|
+
newLines.push(`**Outcome:** Alternatives considered: ${d.alternatives}`);
|
|
1513
|
+
} else {
|
|
1514
|
+
newLines.push(`**Outcome:** Decision recorded`);
|
|
1515
|
+
}
|
|
1516
|
+
newLines.push(`**On track?:** N/A (extracted decision point)`);
|
|
1517
|
+
newLines.push(`**Quality:** ${qualityLabel}`);
|
|
1518
|
+
newLines.push(`**Lessons:** ${(d.tags || []).join(', ')}`);
|
|
1519
|
+
newLines.push('');
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (newLines.length === 0) return;
|
|
1523
|
+
|
|
1524
|
+
const content = '\n' + newLines.join('\n');
|
|
1525
|
+
|
|
1526
|
+
if (existing !== null) {
|
|
1527
|
+
fs.appendFileSync(filePath, content);
|
|
1528
|
+
} else {
|
|
1529
|
+
fs.mkdirSync(journalDir, { recursive: true });
|
|
1530
|
+
fs.writeFileSync(filePath, `# ${date}\n${content}`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Index conversations with optional export to journal directory.
|
|
1536
|
+
* When exportDir is set, extracted decisions are also written as journal entries
|
|
1537
|
+
* so they get picked up by git sync and the container's journal indexer.
|
|
1538
|
+
* Passes an onDecisions callback into indexConversations to capture decisions
|
|
1539
|
+
* at extraction time (avoids double LLM calls).
|
|
1540
|
+
* @param {Object} options - Same as indexConversations plus:
|
|
1541
|
+
* @param {string} [options.exportDir] - Journal directory to export decisions to
|
|
1542
|
+
* @returns {Promise<Object>} - Same stats as indexConversations plus exported count
|
|
1543
|
+
*/
|
|
1544
|
+
async function indexConversationsWithExport(options = {}) {
|
|
1545
|
+
const exportDir = options.exportDir;
|
|
1546
|
+
const repoToTeam = options.repoToTeam || (() => null);
|
|
1547
|
+
const author = options.author || null;
|
|
1548
|
+
let exported = 0;
|
|
1549
|
+
let richCount = 0;
|
|
1550
|
+
let thinCount = 0;
|
|
1551
|
+
const pendingExports = [];
|
|
1552
|
+
|
|
1553
|
+
const stats = await indexConversations({
|
|
1554
|
+
...options,
|
|
1555
|
+
onDecisions: exportDir ? (date, repo, decisions) => {
|
|
1556
|
+
pendingExports.push({ date, repo, decisions });
|
|
1557
|
+
} : undefined,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
// Write pending exports — route to per-team journal files
|
|
1561
|
+
for (const { date, repo, decisions } of pendingExports) {
|
|
1562
|
+
const teamId = repoToTeam(repo);
|
|
1563
|
+
exportDecisionsAsJournal(date, repo, decisions, exportDir, teamId, author);
|
|
1564
|
+
exported += decisions.length;
|
|
1565
|
+
for (const d of decisions) {
|
|
1566
|
+
if (d.has_reasoning || d.has_alternatives) {
|
|
1567
|
+
richCount++;
|
|
1568
|
+
} else {
|
|
1569
|
+
thinCount++;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return { ...stats, exported, pendingExports, richCount, thinCount };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// ── Signal file indexing ─────────────────────────────────────────────────────
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Index signal markdown files into the content store.
|
|
1581
|
+
* Each file at signalsDir/channel/YYYY-MM-DD.md is one entry.
|
|
1582
|
+
* Incremental — skips unchanged files via contentHash.
|
|
1583
|
+
* @param {Object} options
|
|
1584
|
+
* @param {string} [options.signalsDir] - Signals directory
|
|
1585
|
+
* @param {string} [options.storePath] - Content store directory
|
|
1586
|
+
* @param {boolean} [options.embeddings] - Generate embeddings (default: true if OPENAI_API_KEY or TEAM_CONTEXT_SIMULATE)
|
|
1587
|
+
* @returns {Promise<Object>} - Stats: { fileCount, newEntries, updatedEntries, skippedEntries }
|
|
1588
|
+
*/
|
|
1589
|
+
async function indexSignals(options = {}) {
|
|
1590
|
+
const signalsDir = options.signalsDir || DEFAULT_SIGNALS_DIR;
|
|
1591
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
1592
|
+
const doEmbeddings = options.embeddings !== undefined
|
|
1593
|
+
? options.embeddings
|
|
1594
|
+
: !!(process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_EMBEDDING_ENDPOINT || llm.isSimulation());
|
|
1595
|
+
|
|
1596
|
+
if (!signalsDir || !storePath) {
|
|
1597
|
+
throw new Error('Signals directory and store path are required.');
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (!fs.existsSync(signalsDir)) {
|
|
1601
|
+
throw new Error(`Signals directory not found: ${signalsDir}`);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Load existing index (contains journal + conversation entries too)
|
|
1605
|
+
const backend = getBackend(storePath);
|
|
1606
|
+
const existingIndex = backend.loadIndex() || { version: INDEX_VERSION, entries: {}, lastUpdated: Date.now(), entryCount: 0 };
|
|
1607
|
+
const existingEmbeddings = doEmbeddings ? backend.loadEmbeddings() : {};
|
|
1608
|
+
|
|
1609
|
+
const stats = { fileCount: 0, newEntries: 0, updatedEntries: 0, skippedEntries: 0 };
|
|
1610
|
+
|
|
1611
|
+
// Scan channel directories
|
|
1612
|
+
let channels;
|
|
1613
|
+
try {
|
|
1614
|
+
channels = fs.readdirSync(signalsDir).filter(d => {
|
|
1615
|
+
return fs.statSync(path.join(signalsDir, d)).isDirectory();
|
|
1616
|
+
});
|
|
1617
|
+
} catch {
|
|
1618
|
+
return stats;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
for (const channel of channels) {
|
|
1622
|
+
const channelDir = path.join(signalsDir, channel);
|
|
1623
|
+
let files;
|
|
1624
|
+
try {
|
|
1625
|
+
files = fs.readdirSync(channelDir).filter(f => f.endsWith('.md')).sort();
|
|
1626
|
+
} catch {
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
for (const file of files) {
|
|
1631
|
+
stats.fileCount++;
|
|
1632
|
+
const filePath = path.join(channelDir, file);
|
|
1633
|
+
let content;
|
|
1634
|
+
try {
|
|
1635
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
1636
|
+
} catch {
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Extract date from filename (YYYY-MM-DD.md) or fall back to filename
|
|
1641
|
+
const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
|
|
1642
|
+
const date = dateMatch ? dateMatch[1] : file.replace(/\.md$/, '');
|
|
1643
|
+
|
|
1644
|
+
// Extract title from first # heading, or fall back to filename
|
|
1645
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
1646
|
+
const title = titleMatch ? titleMatch[1].trim() : file.replace(/\.md$/, '');
|
|
1647
|
+
|
|
1648
|
+
// Extract tags: channel name + any ## section headings
|
|
1649
|
+
const tags = [channel];
|
|
1650
|
+
const sectionRe = /^##\s+(.+)$/gm;
|
|
1651
|
+
let sectionMatch;
|
|
1652
|
+
while ((sectionMatch = sectionRe.exec(content)) !== null) {
|
|
1653
|
+
const tag = sectionMatch[1].trim().toLowerCase();
|
|
1654
|
+
if (!tags.includes(tag)) {
|
|
1655
|
+
tags.push(tag);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const repo = 'signals/' + channel;
|
|
1660
|
+
const id = generateEntryId(date, repo, file.replace(/\.md$/, ''));
|
|
1661
|
+
const hash = contentHash(content);
|
|
1662
|
+
|
|
1663
|
+
const existing = existingIndex.entries[id];
|
|
1664
|
+
|
|
1665
|
+
if (existing && existing.contentHash === hash) {
|
|
1666
|
+
// Unchanged — but generate embedding if missing
|
|
1667
|
+
if (doEmbeddings && !existing.hasEmbedding && content) {
|
|
1668
|
+
try {
|
|
1669
|
+
const vec = await llm.generateEmbedding(content);
|
|
1670
|
+
existingEmbeddings[id] = vec;
|
|
1671
|
+
existing.hasEmbedding = true;
|
|
1672
|
+
stats.updatedEntries++;
|
|
1673
|
+
} catch {
|
|
1674
|
+
stats.skippedEntries++;
|
|
1675
|
+
}
|
|
1676
|
+
} else {
|
|
1677
|
+
stats.skippedEntries++;
|
|
1678
|
+
}
|
|
1679
|
+
} else if (existing) {
|
|
1680
|
+
// Changed — update entry and re-embed
|
|
1681
|
+
existingIndex.entries[id] = {
|
|
1682
|
+
date,
|
|
1683
|
+
repo,
|
|
1684
|
+
title,
|
|
1685
|
+
source: 'signal',
|
|
1686
|
+
user: '',
|
|
1687
|
+
drifted: false,
|
|
1688
|
+
contentHash: hash,
|
|
1689
|
+
contentLength: content.length,
|
|
1690
|
+
tags,
|
|
1691
|
+
hasEmbedding: false,
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
if (doEmbeddings) {
|
|
1695
|
+
try {
|
|
1696
|
+
const vec = await llm.generateEmbedding(content);
|
|
1697
|
+
existingEmbeddings[id] = vec;
|
|
1698
|
+
existingIndex.entries[id].hasEmbedding = true;
|
|
1699
|
+
} catch {
|
|
1700
|
+
delete existingEmbeddings[id];
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
stats.updatedEntries++;
|
|
1704
|
+
} else {
|
|
1705
|
+
// New entry
|
|
1706
|
+
existingIndex.entries[id] = {
|
|
1707
|
+
date,
|
|
1708
|
+
repo,
|
|
1709
|
+
title,
|
|
1710
|
+
source: 'signal',
|
|
1711
|
+
user: '',
|
|
1712
|
+
drifted: false,
|
|
1713
|
+
contentHash: hash,
|
|
1714
|
+
contentLength: content.length,
|
|
1715
|
+
tags,
|
|
1716
|
+
hasEmbedding: false,
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
if (doEmbeddings) {
|
|
1720
|
+
try {
|
|
1721
|
+
const vec = await llm.generateEmbedding(content);
|
|
1722
|
+
existingEmbeddings[id] = vec;
|
|
1723
|
+
existingIndex.entries[id].hasEmbedding = true;
|
|
1724
|
+
} catch {
|
|
1725
|
+
// Continue without embedding
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
stats.newEntries++;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Save
|
|
1734
|
+
existingIndex.entryCount = Object.keys(existingIndex.entries).length;
|
|
1735
|
+
backend.saveIndex(existingIndex);
|
|
1736
|
+
if (doEmbeddings) {
|
|
1737
|
+
backend.saveEmbeddings(existingEmbeddings);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
return stats;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ── Digest feedback ─────────────────────────────────────────────────────────
|
|
1744
|
+
|
|
1745
|
+
function loadFeedback(storePath) {
|
|
1746
|
+
return getBackend(storePath || DEFAULT_STORE_PATH).loadFeedback();
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function saveFeedback(storePath, feedback) {
|
|
1750
|
+
getBackend(storePath || DEFAULT_STORE_PATH).saveFeedback(feedback);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Record a digest delivery — stores channel + ts for reaction tracking.
|
|
1755
|
+
*/
|
|
1756
|
+
function recordDigestDelivery(options) {
|
|
1757
|
+
const { date, persona, channel, ts, storePath } = options;
|
|
1758
|
+
const feedback = loadFeedback(storePath);
|
|
1759
|
+
const key = `${date}/${persona}`;
|
|
1760
|
+
feedback.digests[key] = feedback.digests[key] || {};
|
|
1761
|
+
feedback.digests[key].date = date;
|
|
1762
|
+
feedback.digests[key].persona = persona;
|
|
1763
|
+
feedback.digests[key].channel = channel;
|
|
1764
|
+
feedback.digests[key].ts = ts;
|
|
1765
|
+
feedback.digests[key].deliveredAt = new Date().toISOString();
|
|
1766
|
+
feedback.digests[key].reactions = feedback.digests[key].reactions || {};
|
|
1767
|
+
saveFeedback(storePath, feedback);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Record a reaction on a digest message.
|
|
1772
|
+
* @param {string} messageTs - Slack message timestamp
|
|
1773
|
+
* @param {string} reaction - Emoji name (without colons)
|
|
1774
|
+
* @param {number} delta - +1 for added, -1 for removed
|
|
1775
|
+
*/
|
|
1776
|
+
function recordDigestReaction(options) {
|
|
1777
|
+
const { messageTs, reaction, delta, storePath } = options;
|
|
1778
|
+
const feedback = loadFeedback(storePath);
|
|
1779
|
+
|
|
1780
|
+
// Find the digest entry by message ts
|
|
1781
|
+
const entry = Object.values(feedback.digests).find(d => d.ts === messageTs);
|
|
1782
|
+
if (!entry) return false; // Not a tracked digest message
|
|
1783
|
+
|
|
1784
|
+
entry.reactions = entry.reactions || {};
|
|
1785
|
+
entry.reactions[reaction] = (entry.reactions[reaction] || 0) + delta;
|
|
1786
|
+
if (entry.reactions[reaction] <= 0) delete entry.reactions[reaction];
|
|
1787
|
+
|
|
1788
|
+
saveFeedback(storePath, feedback);
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Record a text feedback reply on a digest thread.
|
|
1794
|
+
* Returns true if the message was on a tracked digest, false otherwise.
|
|
1795
|
+
*/
|
|
1796
|
+
function recordDigestFeedbackText(options) {
|
|
1797
|
+
const { messageTs, user, text, storePath } = options;
|
|
1798
|
+
if (!text) return false;
|
|
1799
|
+
const feedback = loadFeedback(storePath);
|
|
1800
|
+
|
|
1801
|
+
// Find the digest entry by thread parent ts
|
|
1802
|
+
const entry = Object.values(feedback.digests).find(d => d.ts === messageTs);
|
|
1803
|
+
if (!entry) return false;
|
|
1804
|
+
|
|
1805
|
+
entry.comments = entry.comments || [];
|
|
1806
|
+
entry.comments.push({
|
|
1807
|
+
user,
|
|
1808
|
+
text,
|
|
1809
|
+
at: new Date().toISOString(),
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
saveFeedback(storePath, feedback);
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* Get digest feedback summary.
|
|
1818
|
+
* @returns {Array<{ date, persona, deliveredAt, reactions, totalReactions, comments }>}
|
|
1819
|
+
*/
|
|
1820
|
+
function getDigestFeedback(options = {}) {
|
|
1821
|
+
const { storePath, since, limit } = options;
|
|
1822
|
+
const feedback = loadFeedback(storePath);
|
|
1823
|
+
|
|
1824
|
+
let results = Object.values(feedback.digests)
|
|
1825
|
+
.filter(d => !since || d.date >= since)
|
|
1826
|
+
.sort((a, b) => b.date.localeCompare(a.date));
|
|
1827
|
+
|
|
1828
|
+
if (limit) results = results.slice(0, limit);
|
|
1829
|
+
|
|
1830
|
+
return results.map(d => ({
|
|
1831
|
+
date: d.date,
|
|
1832
|
+
persona: d.persona,
|
|
1833
|
+
deliveredAt: d.deliveredAt,
|
|
1834
|
+
reactions: d.reactions || {},
|
|
1835
|
+
totalReactions: Object.values(d.reactions || {}).reduce((s, n) => s + n, 0),
|
|
1836
|
+
comments: d.comments || [],
|
|
1837
|
+
}));
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ── Quality profile ─────────────────────────────────────────────────────────
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* Compute a per-member quality profile from the content store index.
|
|
1844
|
+
* Analyzes conversation entries to find patterns in what's captured vs missing.
|
|
1845
|
+
* @param {Object} [options]
|
|
1846
|
+
* @param {string} [options.storePath] - Content store directory
|
|
1847
|
+
* @param {number} [options.days] - Look back N days (default: 30)
|
|
1848
|
+
* @returns {{ totalDecisions: number, rich: number, thin: number, richRate: number,
|
|
1849
|
+
* reasoning: { present: number, missing: number, rate: number },
|
|
1850
|
+
* alternatives: { present: number, missing: number, rate: number },
|
|
1851
|
+
* weeklyTrend: Array<{ week: string, richRate: number, count: number }>,
|
|
1852
|
+
* focus: string[] }}
|
|
1853
|
+
*/
|
|
1854
|
+
function computeQualityProfile(options = {}) {
|
|
1855
|
+
const storePath = options.storePath || DEFAULT_STORE_PATH;
|
|
1856
|
+
const days = options.days || 30;
|
|
1857
|
+
const index = getBackend(storePath).loadIndex();
|
|
1858
|
+
|
|
1859
|
+
if (!index || index.entryCount === 0) {
|
|
1860
|
+
return {
|
|
1861
|
+
totalDecisions: 0, rich: 0, thin: 0, richRate: 0,
|
|
1862
|
+
reasoning: { present: 0, missing: 0, rate: 0 },
|
|
1863
|
+
alternatives: { present: 0, missing: 0, rate: 0 },
|
|
1864
|
+
weeklyTrend: [],
|
|
1865
|
+
focus: [],
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const cutoff = new Date();
|
|
1870
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
1871
|
+
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
1872
|
+
|
|
1873
|
+
const entries = Object.values(index.entries)
|
|
1874
|
+
.filter(e => e.source === 'conversation' && e.date >= cutoffStr);
|
|
1875
|
+
|
|
1876
|
+
const totalDecisions = entries.length;
|
|
1877
|
+
const withReasoning = entries.filter(e => e.hasReasoning).length;
|
|
1878
|
+
const withAlternatives = entries.filter(e => e.hasAlternatives).length;
|
|
1879
|
+
const rich = entries.filter(e => e.hasReasoning || e.hasAlternatives).length;
|
|
1880
|
+
const thin = totalDecisions - rich;
|
|
1881
|
+
const richRate = totalDecisions > 0 ? Math.round((rich / totalDecisions) * 100) : 0;
|
|
1882
|
+
|
|
1883
|
+
// Weekly trend
|
|
1884
|
+
const weekBuckets = {};
|
|
1885
|
+
for (const entry of entries) {
|
|
1886
|
+
const d = new Date(entry.date);
|
|
1887
|
+
// ISO week start (Monday)
|
|
1888
|
+
const day = d.getDay();
|
|
1889
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
1890
|
+
const weekStart = new Date(d);
|
|
1891
|
+
weekStart.setDate(diff);
|
|
1892
|
+
const weekKey = weekStart.toISOString().slice(0, 10);
|
|
1893
|
+
if (!weekBuckets[weekKey]) weekBuckets[weekKey] = { rich: 0, total: 0 };
|
|
1894
|
+
weekBuckets[weekKey].total++;
|
|
1895
|
+
if (entry.hasReasoning || entry.hasAlternatives) weekBuckets[weekKey].rich++;
|
|
1896
|
+
}
|
|
1897
|
+
const weeklyTrend = Object.entries(weekBuckets)
|
|
1898
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1899
|
+
.map(([week, { rich: r, total }]) => ({
|
|
1900
|
+
week,
|
|
1901
|
+
richRate: total > 0 ? Math.round((r / total) * 100) : 0,
|
|
1902
|
+
count: total,
|
|
1903
|
+
}));
|
|
1904
|
+
|
|
1905
|
+
// Determine focus areas — what's missing most
|
|
1906
|
+
const focus = [];
|
|
1907
|
+
const reasoningRate = totalDecisions > 0 ? Math.round((withReasoning / totalDecisions) * 100) : 0;
|
|
1908
|
+
const alternativesRate = totalDecisions > 0 ? Math.round((withAlternatives / totalDecisions) * 100) : 0;
|
|
1909
|
+
|
|
1910
|
+
if (alternativesRate < 40) {
|
|
1911
|
+
focus.push('alternatives considered — you tend to record what was chosen but not what was rejected');
|
|
1912
|
+
}
|
|
1913
|
+
if (reasoningRate < 50) {
|
|
1914
|
+
focus.push('reasoning and constraints — capture why, not just what');
|
|
1915
|
+
}
|
|
1916
|
+
if (alternativesRate >= 40 && reasoningRate >= 50 && richRate < 70) {
|
|
1917
|
+
focus.push('depth on both dimensions — you capture some context but could go deeper');
|
|
1918
|
+
}
|
|
1919
|
+
if (richRate >= 70) {
|
|
1920
|
+
focus.push('keep it up — your decision context is strong');
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
return {
|
|
1924
|
+
totalDecisions,
|
|
1925
|
+
rich,
|
|
1926
|
+
thin,
|
|
1927
|
+
richRate,
|
|
1928
|
+
reasoning: {
|
|
1929
|
+
present: withReasoning,
|
|
1930
|
+
missing: totalDecisions - withReasoning,
|
|
1931
|
+
rate: reasoningRate,
|
|
1932
|
+
},
|
|
1933
|
+
alternatives: {
|
|
1934
|
+
present: withAlternatives,
|
|
1935
|
+
missing: totalDecisions - withAlternatives,
|
|
1936
|
+
rate: alternativesRate,
|
|
1937
|
+
},
|
|
1938
|
+
weeklyTrend,
|
|
1939
|
+
focus,
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
1944
|
+
|
|
1945
|
+
module.exports = {
|
|
1946
|
+
// Parsing
|
|
1947
|
+
parseJournalFile,
|
|
1948
|
+
isDrifted,
|
|
1949
|
+
generateEntryId,
|
|
1950
|
+
buildContent,
|
|
1951
|
+
extractTags,
|
|
1952
|
+
contentHash,
|
|
1953
|
+
|
|
1954
|
+
// Conversation parsing
|
|
1955
|
+
parseTranscript,
|
|
1956
|
+
buildTranscriptText,
|
|
1957
|
+
extractDecisions,
|
|
1958
|
+
projectDirToRepo,
|
|
1959
|
+
|
|
1960
|
+
// Storage backend
|
|
1961
|
+
getBackend,
|
|
1962
|
+
getBackendType,
|
|
1963
|
+
// Backward-compatible storage wrappers (delegate to backend)
|
|
1964
|
+
loadIndex: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadIndex(),
|
|
1965
|
+
saveIndex: (storePath, index) => getBackend(storePath || DEFAULT_STORE_PATH).saveIndex(index),
|
|
1966
|
+
loadEmbeddings: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadEmbeddings(),
|
|
1967
|
+
saveEmbeddings: (storePath, embeddings) => getBackend(storePath || DEFAULT_STORE_PATH).saveEmbeddings(embeddings),
|
|
1968
|
+
loadConversationIndex: (storePath) => getBackend(storePath || DEFAULT_STORE_PATH).loadConversationIndex(),
|
|
1969
|
+
saveConversationIndex: (storePath, convIndex) => getBackend(storePath || DEFAULT_STORE_PATH).saveConversationIndex(convIndex),
|
|
1970
|
+
|
|
1971
|
+
// Filtering
|
|
1972
|
+
applyFilters,
|
|
1973
|
+
|
|
1974
|
+
// Core operations
|
|
1975
|
+
indexJournals,
|
|
1976
|
+
indexSignals,
|
|
1977
|
+
indexConversations,
|
|
1978
|
+
indexConversationsWithExport,
|
|
1979
|
+
exportDecisionsAsJournal,
|
|
1980
|
+
detectContextShift,
|
|
1981
|
+
applyContextShiftToState,
|
|
1982
|
+
generateOnboardingPack,
|
|
1983
|
+
searchJournals,
|
|
1984
|
+
searchText,
|
|
1985
|
+
queryMetadata,
|
|
1986
|
+
extractInsights,
|
|
1987
|
+
computeQualityProfile,
|
|
1988
|
+
getEntryContent,
|
|
1989
|
+
|
|
1990
|
+
// Constants (for testing)
|
|
1991
|
+
INDEX_VERSION,
|
|
1992
|
+
FIELD_MAP,
|
|
1993
|
+
FIELD_LABELS,
|
|
1994
|
+
DEFAULT_STORE_PATH,
|
|
1995
|
+
DEFAULT_JOURNAL_DIR,
|
|
1996
|
+
DEFAULT_PROJECTS_DIR,
|
|
1997
|
+
DEFAULT_SIGNALS_DIR,
|
|
1998
|
+
|
|
1999
|
+
// Digest feedback
|
|
2000
|
+
loadFeedback,
|
|
2001
|
+
saveFeedback,
|
|
2002
|
+
recordDigestDelivery,
|
|
2003
|
+
recordDigestReaction,
|
|
2004
|
+
recordDigestFeedbackText,
|
|
2005
|
+
getDigestFeedback,
|
|
2006
|
+
};
|