@anirudh242/contextbridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ import { simpleGit } from 'simple-git';
2
+ const EXCLUDED_PATHS = ['node_modules', 'dist', 'AGENTS.md'];
3
+ function createGit(projectPath) {
4
+ return simpleGit({
5
+ baseDir: projectPath,
6
+ binary: 'git',
7
+ trimmed: false,
8
+ });
9
+ }
10
+ function buildPathspec() {
11
+ return ['.', ...EXCLUDED_PATHS.map((entry) => `:(exclude)${entry}`)];
12
+ }
13
+ function getErrorMessage(error) {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+ export async function isGitRepository(projectPath) {
17
+ try {
18
+ return await createGit(projectPath).checkIsRepo();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export async function getCurrentBranch(projectPath) {
25
+ try {
26
+ if (!(await isGitRepository(projectPath))) {
27
+ return 'no-git';
28
+ }
29
+ const branch = await createGit(projectPath).branchLocal();
30
+ return branch.current || 'detached';
31
+ }
32
+ catch {
33
+ return 'unknown';
34
+ }
35
+ }
36
+ async function hasHeadCommit(git) {
37
+ try {
38
+ await git.revparse(['--verify', 'HEAD']);
39
+ return true;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function hasPreviousCommit(git) {
46
+ try {
47
+ await git.revparse(['--verify', 'HEAD~1']);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export async function getGitContext(projectPath) {
55
+ const warnings = [];
56
+ try {
57
+ const repo = await isGitRepository(projectPath);
58
+ if (!repo) {
59
+ warnings.push('Git repository not detected. Skipping git diff and git log capture.');
60
+ return {
61
+ diff: '',
62
+ log: '',
63
+ status: '',
64
+ warnings,
65
+ };
66
+ }
67
+ const git = createGit(projectPath);
68
+ const headExists = await hasHeadCommit(git);
69
+ let diff = '';
70
+ let log = '';
71
+ let status = '';
72
+ if (headExists) {
73
+ log = await git.raw(['log', '--oneline', '-5']);
74
+ if (await hasPreviousCommit(git)) {
75
+ diff = await git.raw(['diff', 'HEAD~1', 'HEAD', '--', ...buildPathspec()]);
76
+ }
77
+ else {
78
+ diff = await git.diff(['HEAD', '--', ...buildPathspec()]);
79
+ }
80
+ }
81
+ else {
82
+ warnings.push('No commits found yet. Capturing unstaged diff only.');
83
+ diff = await git.diff(['--', ...buildPathspec()]);
84
+ status = (await git.raw(['status', '--short', '--', ...buildPathspec()])).trim();
85
+ }
86
+ if (!status) {
87
+ status = (await git.raw(['status', '--short', '--', ...buildPathspec()])).trim();
88
+ }
89
+ return {
90
+ diff,
91
+ log,
92
+ status,
93
+ warnings,
94
+ };
95
+ }
96
+ catch (error) {
97
+ warnings.push(`Git capture failed: ${getErrorMessage(error)}`);
98
+ return {
99
+ diff: '',
100
+ log: '',
101
+ status: '',
102
+ warnings,
103
+ };
104
+ }
105
+ }
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { formatRelativeTime } from '../utils/display.js';
4
+ import { loadRawCapture } from './store.js';
5
+ function renderList(items, emptyLabel = 'Unknown') {
6
+ if (!items || items.length === 0) {
7
+ return `- ${emptyLabel}`;
8
+ }
9
+ return items.map((item) => `- ${item}`).join('\n');
10
+ }
11
+ function getFallbackTask(raw) {
12
+ if (raw.note.trim()) {
13
+ return raw.note.trim();
14
+ }
15
+ return 'Unknown';
16
+ }
17
+ function getPackageDescription(repoGrounding) {
18
+ const match = repoGrounding.match(/^description:\s*(.+)$/im);
19
+ return match?.[1]?.trim() || 'Unknown';
20
+ }
21
+ function compactGrounding(repoGrounding) {
22
+ const lines = repoGrounding
23
+ .split('\n')
24
+ .filter((line) => line.trim() !== 'Project grounding:')
25
+ .map((line) => line.trimEnd());
26
+ const packageStart = lines.findIndex((line) => line.trim() === 'package.json:');
27
+ const readmeStart = lines.findIndex((line) => line.trim() === 'README excerpt:');
28
+ const filesStart = lines.findIndex((line) => line.trim() === 'Relevant files:');
29
+ const packageBlock = packageStart === -1
30
+ ? []
31
+ : lines.slice(packageStart, readmeStart === -1 ? filesStart === -1 ? lines.length : filesStart : readmeStart)
32
+ .filter((line) => line.trim())
33
+ .slice(0, 6);
34
+ const readmeBlock = readmeStart === -1
35
+ ? []
36
+ : [
37
+ 'README excerpt:',
38
+ ...lines
39
+ .slice(readmeStart + 1, filesStart === -1 ? lines.length : filesStart)
40
+ .filter((line) => line.trim())
41
+ .filter((line) => !line.startsWith('```'))
42
+ .slice(0, 8),
43
+ ];
44
+ const filesBlock = filesStart === -1
45
+ ? []
46
+ : [
47
+ 'Relevant files:',
48
+ ...lines
49
+ .slice(filesStart + 1)
50
+ .filter((line) => /^-\s+/.test(line.trim()))
51
+ .filter((line) => !line.includes('package-lock.json'))
52
+ .slice(0, 10),
53
+ ];
54
+ const compacted = [
55
+ packageBlock.join('\n'),
56
+ readmeBlock.join('\n'),
57
+ filesBlock.join('\n'),
58
+ ].filter(Boolean).join('\n\n');
59
+ return compacted || repoGrounding.trim().slice(0, 1200);
60
+ }
61
+ function getTrustedSummary(entry) {
62
+ if (!entry.summary)
63
+ return null;
64
+ const missingCore = entry.summary.projectGoal === 'Unknown' &&
65
+ entry.summary.currentDirection === 'Unknown' &&
66
+ entry.summary.currentFocus === 'Unknown';
67
+ return missingCore ? null : entry.summary;
68
+ }
69
+ function sortNewestFirst(entries) {
70
+ return [...entries].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
71
+ }
72
+ function findTrustedSummaryEntry(entry, entries = []) {
73
+ const candidates = entries.length > 0 ? entries : [entry];
74
+ return sortNewestFirst(candidates).find((candidate) => getTrustedSummary(candidate) !== null) ?? null;
75
+ }
76
+ function buildFallbackDirection(raw, summary, trustedEntry, entryId) {
77
+ if (summary?.currentDirection && trustedEntry?.id === entryId) {
78
+ return summary.currentDirection;
79
+ }
80
+ if (summary?.currentDirection && trustedEntry) {
81
+ return summary.currentDirection;
82
+ }
83
+ const grounding = raw.repoGrounding.trim();
84
+ if (grounding) {
85
+ return 'No trusted summary is available yet. Use the project grounding below as the current source of truth.';
86
+ }
87
+ return 'Unknown';
88
+ }
89
+ export async function formatContextForInjection({ projectName, entry, entries = [] }) {
90
+ const trustedEntry = findTrustedSummaryEntry(entry, entries);
91
+ const summary = trustedEntry ? getTrustedSummary(trustedEntry) : null;
92
+ const raw = await loadRawCapture(entry.id);
93
+ if (!raw) {
94
+ throw new Error('Raw capture not found for the head entry.');
95
+ }
96
+ const grounding = compactGrounding(raw.repoGrounding);
97
+ const fallbackProjectGoal = getPackageDescription(raw.repoGrounding);
98
+ return [
99
+ `[ContextBridge] Project: ${projectName} | Branch: ${entry.branch ?? 'unknown'} | Captured: ${formatRelativeTime(entry.timestamp)}`,
100
+ trustedEntry && trustedEntry.id !== entry.id
101
+ ? `[ContextBridge] Summary source: latest trusted capture from ${formatRelativeTime(trustedEntry.timestamp)}`
102
+ : '',
103
+ '',
104
+ `What we're building: ${summary?.projectGoal ?? fallbackProjectGoal}`,
105
+ '',
106
+ `Current direction: ${buildFallbackDirection(raw, summary, trustedEntry, entry.id)}`,
107
+ '',
108
+ `Current implementation focus: ${summary?.currentFocus ?? getFallbackTask(raw)}`,
109
+ '',
110
+ 'Recent decisions:',
111
+ renderList(summary?.decisions),
112
+ '',
113
+ 'Blockers:',
114
+ renderList(summary?.blockers, 'None'),
115
+ '',
116
+ `Stack: ${summary?.stack ?? 'Unknown'}`,
117
+ '',
118
+ 'Next steps:',
119
+ renderList(summary?.nextSteps),
120
+ '',
121
+ 'Project grounding:',
122
+ grounding || '- Unknown',
123
+ '',
124
+ ].join('\n');
125
+ }
126
+ export async function writeAgentsFile(projectPath, content) {
127
+ const targetPath = path.join(projectPath, 'AGENTS.md');
128
+ await fs.writeFile(targetPath, `${content.trim()}\n`, 'utf8');
129
+ return targetPath;
130
+ }
@@ -0,0 +1,350 @@
1
+ import { z } from 'zod';
2
+ import { zodToJsonSchema } from 'zod-to-json-schema';
3
+ const DEFAULT_MODEL = 'qwen2.5-coder:7b';
4
+ const DEFAULT_URL = 'http://127.0.0.1:11434';
5
+ const DEFAULT_TIMEOUT_MS = 120000;
6
+ const summarySchema = z.object({
7
+ projectGoal: z.string().default('Unknown'),
8
+ currentDirection: z.string().default('Unknown'),
9
+ currentFocus: z.string().default('Unknown'),
10
+ decisions: z.array(z.string()).max(3).default([]),
11
+ blockers: z.array(z.string()).max(3).default(['None']),
12
+ stack: z.string().default('Unknown'),
13
+ nextSteps: z.array(z.string()).max(3).default([]),
14
+ });
15
+ const summaryJsonSchema = zodToJsonSchema(summarySchema, 'ContextSummary');
16
+ function approximateTokens(text) {
17
+ return text.trim().split(/\s+/).filter(Boolean);
18
+ }
19
+ function truncateMiddleTokens(text, maxTokens = 6000, keepPerSide = 2000) {
20
+ const tokens = approximateTokens(text);
21
+ if (tokens.length <= maxTokens) {
22
+ return text;
23
+ }
24
+ const head = tokens.slice(0, keepPerSide).join(' ');
25
+ const tail = tokens.slice(-keepPerSide).join(' ');
26
+ return `${head}\n\n[... truncated middle content ...]\n\n${tail}`;
27
+ }
28
+ function buildPrompt(content) {
29
+ return [
30
+ 'You are a developer context summariser.',
31
+ 'Given the following content from a coding session, return a JSON object that matches the provided schema.',
32
+ 'Field requirements:',
33
+ '- projectGoal: what this project is building overall',
34
+ '- currentDirection: the current product or implementation direction',
35
+ '- currentFocus: the specific work actively being done now',
36
+ '- decisions: up to 3 concrete technical or product decisions made',
37
+ '- blockers: up to 3 active blockers, or ["None"] if absent',
38
+ '- stack: language, frameworks, and the most relevant files touched',
39
+ '- nextSteps: up to 3 concrete implementation steps directly implied by the content',
40
+ 'Be specific and technical. Use actual variable names, file names, and error text when present.',
41
+ 'If information is missing, write "Unknown".',
42
+ 'Do not invent generic workflow advice like creating a branch, committing changes, or writing tests unless the content explicitly says so.',
43
+ `JSON schema: ${JSON.stringify(summaryJsonSchema)}`,
44
+ '',
45
+ 'Content:',
46
+ truncateMiddleTokens(content),
47
+ ].join('\n');
48
+ }
49
+ function buildFallbackPrompt(content) {
50
+ return [
51
+ 'You are a developer context summariser.',
52
+ 'Return a JSON object that matches the provided schema.',
53
+ 'Use short, specific values. If unknown, write "Unknown".',
54
+ 'Do not invent generic workflow advice.',
55
+ `JSON schema: ${JSON.stringify(summaryJsonSchema)}`,
56
+ '',
57
+ 'Session content:',
58
+ truncateMiddleTokens(content, 2500, 800),
59
+ ].join('\n');
60
+ }
61
+ async function callAnthropic(prompt, config) {
62
+ const model = config.model || 'claude-haiku-4-5-20251001';
63
+ const baseUrl = config.baseUrl || 'https://api.anthropic.com';
64
+ const apiKey = config.apiKey;
65
+ if (!apiKey)
66
+ throw new Error('Anthropic API key is required');
67
+ const response = await fetch(`${baseUrl}/v1/messages`, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'x-api-key': apiKey,
71
+ 'anthropic-version': '2023-06-01',
72
+ 'content-type': 'application/json',
73
+ },
74
+ body: JSON.stringify({
75
+ model,
76
+ max_tokens: 1024,
77
+ messages: [{ role: 'user', content: prompt }],
78
+ }),
79
+ });
80
+ if (!response.ok)
81
+ throw new Error(`Anthropic request failed with status ${response.status}`);
82
+ const payload = await response.json();
83
+ return payload.content[0].text;
84
+ }
85
+ async function callOpenAI(prompt, config) {
86
+ const model = config.model || 'gpt-4o-mini';
87
+ const baseUrl = config.baseUrl || 'https://api.openai.com';
88
+ const apiKey = config.apiKey;
89
+ if (!apiKey)
90
+ throw new Error('OpenAI API key is required');
91
+ const response = await fetch(`${baseUrl}/v1/chat/completions`, {
92
+ method: 'POST',
93
+ headers: {
94
+ Authorization: `Bearer ${apiKey}`,
95
+ 'content-type': 'application/json',
96
+ },
97
+ body: JSON.stringify({
98
+ model,
99
+ messages: [{ role: 'user', content: prompt }],
100
+ max_tokens: 1024,
101
+ temperature: 0,
102
+ }),
103
+ });
104
+ if (!response.ok)
105
+ throw new Error(`OpenAI request failed with status ${response.status}`);
106
+ const payload = await response.json();
107
+ return payload.choices[0].message.content;
108
+ }
109
+ async function callGemini(prompt, config) {
110
+ const model = config.model || 'gemini-2.0-flash';
111
+ const baseUrl = config.baseUrl || 'https://generativelanguage.googleapis.com';
112
+ const apiKey = config.apiKey;
113
+ if (!apiKey)
114
+ throw new Error('Gemini API key is required');
115
+ const response = await fetch(`${baseUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`, {
116
+ method: 'POST',
117
+ headers: {
118
+ 'content-type': 'application/json',
119
+ },
120
+ body: JSON.stringify({
121
+ contents: [{ parts: [{ text: prompt }] }],
122
+ generationConfig: { maxOutputTokens: 1024, temperature: 0 },
123
+ }),
124
+ });
125
+ if (!response.ok)
126
+ throw new Error(`Gemini request failed with status ${response.status}`);
127
+ const payload = await response.json();
128
+ return payload.candidates[0].content.parts[0].text;
129
+ }
130
+ async function callOllama(prompt, config) {
131
+ const model = config.model || DEFAULT_MODEL;
132
+ const baseUrl = config.baseUrl || DEFAULT_URL;
133
+ const timeoutMs = config.ollamaTimeoutMs === null
134
+ ? null
135
+ : (typeof config.ollamaTimeoutMs === 'number' && Number.isFinite(config.ollamaTimeoutMs)
136
+ ? config.ollamaTimeoutMs
137
+ : DEFAULT_TIMEOUT_MS);
138
+ const signal = timeoutMs === null ? undefined : AbortSignal.timeout(timeoutMs);
139
+ const response = await fetch(`${baseUrl}/api/generate`, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'Content-Type': 'application/json',
143
+ },
144
+ body: JSON.stringify({
145
+ model,
146
+ prompt,
147
+ stream: false,
148
+ format: summaryJsonSchema,
149
+ options: {
150
+ num_predict: 384,
151
+ temperature: 0,
152
+ },
153
+ }),
154
+ signal,
155
+ });
156
+ if (!response.ok) {
157
+ throw new Error(`Ollama request failed with status ${response.status}`);
158
+ }
159
+ const payload = (await response.json());
160
+ return payload.response ?? '';
161
+ }
162
+ async function callLLM(prompt, config) {
163
+ switch (config.provider) {
164
+ case 'anthropic':
165
+ return callAnthropic(prompt, config);
166
+ case 'openai':
167
+ return callOpenAI(prompt, config);
168
+ case 'gemini':
169
+ return callGemini(prompt, config);
170
+ case 'ollama':
171
+ default:
172
+ return callOllama(prompt, config);
173
+ }
174
+ }
175
+ function normaliseList(value, fallback) {
176
+ if (!Array.isArray(value)) {
177
+ return fallback;
178
+ }
179
+ return value
180
+ .map((item) => `${item}`.trim())
181
+ .filter(Boolean)
182
+ .slice(0, 3);
183
+ }
184
+ function cleanLineValue(value) {
185
+ return value
186
+ .replace(/^[-*]\s*/, '')
187
+ .replace(/^"(.*)"$/, '$1')
188
+ .replace(/^`{1,3}|`{1,3}$/g, '')
189
+ .trim();
190
+ }
191
+ function extractSection(text, label, stopLabels) {
192
+ const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
193
+ const escapedStops = stopLabels.map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
194
+ const pattern = new RegExp(`^\\s*(?:#+\\s*)?${escapedLabel}\\s*:\\s*([\\s\\S]*?)(?=^\\s*(?:#+\\s*)?(?:${escapedStops.join('|')})\\s*:|^\\s*\`\`\`|$)`, 'im');
195
+ const match = text.match(pattern);
196
+ return match?.[1]?.trim() ?? '';
197
+ }
198
+ function extractBullets(section, fallback) {
199
+ const bulletLines = section
200
+ .split('\n')
201
+ .map((line) => line.trim())
202
+ .filter((line) => /^[-*]\s+/.test(line))
203
+ .map((line) => cleanLineValue(line))
204
+ .filter((line) => line.length > 0 && line !== 'None');
205
+ if (bulletLines.length > 0) {
206
+ return bulletLines.slice(0, 3);
207
+ }
208
+ const inlineParts = section
209
+ .split(/[|;]/)
210
+ .map((part) => cleanLineValue(part))
211
+ .filter(Boolean)
212
+ .slice(0, 3);
213
+ return inlineParts.length > 0 ? inlineParts : fallback;
214
+ }
215
+ function sanitizeScalar(value, fallback) {
216
+ const cleaned = cleanLineValue(value)
217
+ .replace(/\s+/g, ' ')
218
+ .trim();
219
+ if (!cleaned
220
+ || cleaned.length > 240
221
+ || /```/.test(cleaned)
222
+ || /^function\s/i.test(cleaned)
223
+ || /return valid json/i.test(cleaned)
224
+ || /plain text responses/i.test(cleaned)
225
+ || /^here is /i.test(cleaned)
226
+ || /^these functions/i.test(cleaned)) {
227
+ return fallback;
228
+ }
229
+ return cleaned;
230
+ }
231
+ function sanitizeList(items, fallback) {
232
+ const cleaned = items
233
+ .map((item) => sanitizeScalar(item, ''))
234
+ .filter(Boolean)
235
+ .filter((item) => !/^(project goal|current direction|current focus|recent decisions|active errors|blockers|stack|next steps)\b/i.test(item))
236
+ .slice(0, 3);
237
+ return cleaned.length > 0 ? cleaned : fallback;
238
+ }
239
+ function parseSummaryFromText(text) {
240
+ const labels = [
241
+ 'Project goal',
242
+ 'Current direction',
243
+ 'Current focus',
244
+ 'Recent decisions',
245
+ 'Blockers',
246
+ 'Active errors',
247
+ 'Active errors or blockers',
248
+ 'Stack',
249
+ 'Stack and key files',
250
+ 'Next steps',
251
+ ];
252
+ const projectGoalSection = extractSection(text, 'Project goal', labels);
253
+ const currentDirectionSection = extractSection(text, 'Current direction', labels);
254
+ const currentFocusSection = extractSection(text, 'Current focus', labels);
255
+ const decisionsSection = extractSection(text, 'Recent decisions', labels);
256
+ const blockersSection = extractSection(text, 'Blockers', labels)
257
+ || extractSection(text, 'Active errors', labels)
258
+ || extractSection(text, 'Active errors or blockers', labels);
259
+ const stackSection = extractSection(text, 'Stack', labels)
260
+ || extractSection(text, 'Stack and key files', labels);
261
+ const nextStepsSection = extractSection(text, 'Next steps', labels);
262
+ const detectedSections = [
263
+ projectGoalSection,
264
+ currentDirectionSection,
265
+ currentFocusSection,
266
+ decisionsSection,
267
+ blockersSection,
268
+ stackSection,
269
+ nextStepsSection,
270
+ ].filter(Boolean).length;
271
+ if (detectedSections < 2) {
272
+ throw new Error('Model response did not contain structured summary headings.');
273
+ }
274
+ return {
275
+ projectGoal: sanitizeScalar(projectGoalSection, 'Unknown'),
276
+ currentDirection: sanitizeScalar(currentDirectionSection, 'Unknown'),
277
+ currentFocus: sanitizeScalar(currentFocusSection, 'Unknown'),
278
+ decisions: sanitizeList(extractBullets(decisionsSection, []), []),
279
+ blockers: sanitizeList(extractBullets(blockersSection, ['None']), ['None']),
280
+ stack: sanitizeScalar(stackSection, 'Unknown'),
281
+ nextSteps: sanitizeList(extractBullets(nextStepsSection, []), []),
282
+ };
283
+ }
284
+ function sanitizeSummary(summary) {
285
+ return {
286
+ projectGoal: sanitizeScalar(summary.projectGoal, 'Unknown'),
287
+ currentDirection: sanitizeScalar(summary.currentDirection, 'Unknown'),
288
+ currentFocus: sanitizeScalar(summary.currentFocus, 'Unknown'),
289
+ decisions: sanitizeList(summary.decisions, []),
290
+ blockers: sanitizeList(summary.blockers, ['None']),
291
+ stack: sanitizeScalar(summary.stack, 'Unknown'),
292
+ nextSteps: sanitizeList(summary.nextSteps, []),
293
+ };
294
+ }
295
+ function isWeakSummary(summary) {
296
+ return (summary.projectGoal === 'Unknown' &&
297
+ summary.currentDirection === 'Unknown' &&
298
+ summary.currentFocus === 'Unknown');
299
+ }
300
+ function parseSummary(text) {
301
+ const cleaned = text.trim();
302
+ const jsonStart = cleaned.indexOf('{');
303
+ const jsonEnd = cleaned.lastIndexOf('}');
304
+ if (jsonStart !== -1 && jsonEnd !== -1) {
305
+ const parsed = JSON.parse(cleaned.slice(jsonStart, jsonEnd + 1));
306
+ const validated = summarySchema.parse({
307
+ projectGoal: `${parsed.projectGoal ?? 'Unknown'}`.trim() || 'Unknown',
308
+ currentDirection: `${parsed.currentDirection ?? 'Unknown'}`.trim() || 'Unknown',
309
+ currentFocus: `${parsed.currentFocus ?? 'Unknown'}`.trim() || 'Unknown',
310
+ decisions: normaliseList(parsed.decisions, []),
311
+ blockers: normaliseList(parsed.blockers ?? parsed.errors, ['None']),
312
+ stack: `${parsed.stack ?? 'Unknown'}`.trim() || 'Unknown',
313
+ nextSteps: normaliseList(parsed.nextSteps, []),
314
+ });
315
+ const summary = sanitizeSummary(validated);
316
+ if (isWeakSummary(summary)) {
317
+ throw new Error('Model returned a generic or low-signal summary.');
318
+ }
319
+ return summary;
320
+ }
321
+ const parsedFromText = parseSummaryFromText(cleaned);
322
+ const hasAnyStructuredContent = parsedFromText.projectGoal !== 'Unknown'
323
+ || parsedFromText.currentDirection !== 'Unknown'
324
+ || parsedFromText.currentFocus !== 'Unknown'
325
+ || parsedFromText.decisions.length > 0
326
+ || parsedFromText.blockers[0] !== 'None'
327
+ || parsedFromText.stack !== 'Unknown'
328
+ || parsedFromText.nextSteps.length > 0;
329
+ if (!hasAnyStructuredContent) {
330
+ throw new Error('Model response did not contain parseable summary data.');
331
+ }
332
+ if (isWeakSummary(parsedFromText)) {
333
+ throw new Error('Model returned a generic or low-signal summary.');
334
+ }
335
+ return parsedFromText;
336
+ }
337
+ export async function summariseContent(content, config = {}) {
338
+ const fullConfig = config;
339
+ try {
340
+ const rawText = await callLLM(buildPrompt(content), fullConfig);
341
+ return parseSummary(rawText);
342
+ }
343
+ catch (error) {
344
+ if (fullConfig.provider === 'ollama' && error instanceof Error && error.name === 'TimeoutError') {
345
+ const rawText = await callLLM(buildFallbackPrompt(content), fullConfig);
346
+ return parseSummary(rawText);
347
+ }
348
+ throw error;
349
+ }
350
+ }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const EXCLUDED_NAMES = new Set([
4
+ 'node_modules',
5
+ 'dist',
6
+ '.git',
7
+ '.agents',
8
+ '.codex',
9
+ 'AGENTS.md',
10
+ 'package-lock.json',
11
+ ]);
12
+ async function readIfPresent(filePath, limit = 1800) {
13
+ try {
14
+ const content = await fs.readFile(filePath, 'utf8');
15
+ return content.slice(0, limit).trim();
16
+ }
17
+ catch {
18
+ return '';
19
+ }
20
+ }
21
+ function compactReadme(content) {
22
+ return content
23
+ .split('\n')
24
+ .map((line) => line.trim())
25
+ .filter(Boolean)
26
+ .filter((line) => !line.startsWith('```'))
27
+ .slice(0, 14)
28
+ .join('\n')
29
+ .slice(0, 900);
30
+ }
31
+ async function collectFiles(root, current = '', output = []) {
32
+ const target = path.join(root, current);
33
+ let entries = await fs.readdir(target, { withFileTypes: true });
34
+ entries = entries
35
+ .filter((entry) => !EXCLUDED_NAMES.has(entry.name))
36
+ .sort((a, b) => a.name.localeCompare(b.name));
37
+ for (const entry of entries) {
38
+ const relativePath = path.join(current, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (output.length >= 12) {
41
+ break;
42
+ }
43
+ await collectFiles(root, relativePath, output);
44
+ continue;
45
+ }
46
+ if (/\.(ts|tsx|js|jsx|json|md)$/i.test(entry.name)) {
47
+ output.push(relativePath);
48
+ }
49
+ if (output.length >= 12) {
50
+ break;
51
+ }
52
+ }
53
+ return output;
54
+ }
55
+ function formatPackageMetadata(content) {
56
+ if (!content) {
57
+ return '';
58
+ }
59
+ try {
60
+ const pkg = JSON.parse(content);
61
+ return [
62
+ `name: ${pkg.name ?? 'Unknown'}`,
63
+ `description: ${pkg.description ?? 'Unknown'}`,
64
+ `scripts: ${Object.keys(pkg.scripts ?? {}).slice(0, 8).join(', ') || 'Unknown'}`,
65
+ `dependencies: ${Object.keys(pkg.dependencies ?? {}).slice(0, 12).join(', ') || 'Unknown'}`,
66
+ ].join('\n');
67
+ }
68
+ catch {
69
+ return content;
70
+ }
71
+ }
72
+ export async function buildProjectGrounding(projectPath) {
73
+ const packageJson = await readIfPresent(path.join(projectPath, 'package.json'));
74
+ const readme = await readIfPresent(path.join(projectPath, 'README.md'));
75
+ const files = await collectFiles(projectPath);
76
+ const parts = [
77
+ packageJson ? `package.json:\n${formatPackageMetadata(packageJson)}` : '',
78
+ readme ? `README excerpt:\n${compactReadme(readme)}` : '',
79
+ files.length > 0 ? `Relevant files:\n${files.map((file) => `- ${file}`).join('\n')}` : '',
80
+ ].filter(Boolean);
81
+ return parts.join('\n\n');
82
+ }