fluffy-context 0.6.0 → 0.7.6

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.
Files changed (44) hide show
  1. package/README.md +494 -115
  2. package/dist/src/agent/api.d.ts +2 -0
  3. package/dist/src/agent/api.js +5 -0
  4. package/dist/src/agent/index.d.ts +3 -0
  5. package/dist/src/agent/index.js +2 -0
  6. package/dist/src/cli/main.js +125 -6
  7. package/dist/src/cognition/filesystem-observer.d.ts +41 -0
  8. package/dist/src/cognition/filesystem-observer.js +164 -0
  9. package/dist/src/cognition/git-observer.d.ts +1 -0
  10. package/dist/src/cognition/git-observer.js +3 -2
  11. package/dist/src/cognition/journal.d.ts +4 -1
  12. package/dist/src/cognition/journal.js +100 -4
  13. package/dist/src/cognition/migration-v1.js +1 -1
  14. package/dist/src/cognition/orient.js +4 -3
  15. package/dist/src/cognition/policy.d.ts +4 -0
  16. package/dist/src/cognition/policy.js +49 -0
  17. package/dist/src/cognition/projections.js +15 -1
  18. package/dist/src/cognition/runtime.d.ts +20 -1
  19. package/dist/src/cognition/runtime.js +229 -53
  20. package/dist/src/cognition/types.d.ts +16 -0
  21. package/dist/src/cognition/v1-bridge.js +3 -2
  22. package/dist/src/compiler/activity.d.ts +3 -0
  23. package/dist/src/compiler/activity.js +79 -0
  24. package/dist/src/compiler/compile.d.ts +2 -0
  25. package/dist/src/compiler/compile.js +353 -0
  26. package/dist/src/compiler/expand.d.ts +2 -0
  27. package/dist/src/compiler/expand.js +187 -0
  28. package/dist/src/compiler/identity.d.ts +6 -0
  29. package/dist/src/compiler/identity.js +36 -0
  30. package/dist/src/compiler/sources.d.ts +19 -0
  31. package/dist/src/compiler/sources.js +126 -0
  32. package/dist/src/compiler/types.d.ts +233 -0
  33. package/dist/src/compiler/types.js +1 -0
  34. package/dist/src/hooks/claude-code.js +7 -15
  35. package/dist/src/mcp/main.js +2 -0
  36. package/dist/src/mcp/server.js +167 -11
  37. package/dist/src/runtime/runtime.js +10 -0
  38. package/dist/src/runtime/types.d.ts +1 -1
  39. package/dist/src/storage/layout.d.ts +1 -0
  40. package/dist/src/storage/layout.js +3 -0
  41. package/dist/src/version.d.ts +1 -1
  42. package/dist/src/version.js +1 -1
  43. package/package.json +1 -1
  44. package/skills/fluffy-context/SKILL.md +10 -3
@@ -0,0 +1,79 @@
1
+ import { filterPaths } from '../capture/context-filter.js';
2
+ import { workspaceKey } from '../cognition/projections.js';
3
+ const changeKinds = new Set(['created', 'modified', 'deleted', 'renamed', 'unknown']);
4
+ function normalizedPath(value) {
5
+ if (typeof value !== 'string')
6
+ return null;
7
+ const normalized = value.replaceAll('\\', '/');
8
+ return normalized.length > 0 ? normalized : null;
9
+ }
10
+ function stableCompare(left, right) {
11
+ return left < right ? -1 : left > right ? 1 : 0;
12
+ }
13
+ function metadataNumber(value) {
14
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
15
+ }
16
+ function parsePayload(event) {
17
+ const payload = event.payload;
18
+ return payload.schemaVersion === 1 && typeof payload === 'object' && payload !== null ? payload : null;
19
+ }
20
+ async function activityForEvent(projectRoot, event, focus) {
21
+ const payload = parsePayload(event);
22
+ if (!payload)
23
+ return null;
24
+ const rawPaths = Array.isArray(payload.paths) ? payload.paths.map(normalizedPath).filter((value) => value !== null) : [];
25
+ const rawChanges = Array.isArray(payload.changes) ? payload.changes : [];
26
+ const changePaths = rawChanges.map((change) => normalizedPath(change?.path)).filter((value) => value !== null);
27
+ const allowed = await filterPaths(projectRoot, [...new Set([...rawPaths, ...changePaths])]);
28
+ if (allowed.length === 0)
29
+ return null;
30
+ const allowedSet = new Set(allowed);
31
+ const changes = [];
32
+ for (const change of rawChanges) {
33
+ const path = normalizedPath(change?.path);
34
+ if (!path || !allowedSet.has(path))
35
+ continue;
36
+ changes.push({
37
+ path,
38
+ kind: changeKinds.has(change?.kind) ? change.kind : 'unknown',
39
+ fileType: 'file',
40
+ ...(metadataNumber(change?.sizeBytes) !== undefined ? { sizeBytes: metadataNumber(change?.sizeBytes) } : {}),
41
+ ...(metadataNumber(change?.mtimeMs) !== undefined ? { mtimeMs: metadataNumber(change?.mtimeMs) } : {}),
42
+ });
43
+ }
44
+ const fallbackChanges = changes.length > 0 ? changes : allowed.map((path) => ({ path, kind: 'unknown', fileType: 'file' }));
45
+ const paths = [...new Set(fallbackChanges.map((change) => change.path))].sort(stableCompare);
46
+ return {
47
+ eventId: event.eventId,
48
+ occurredAt: event.occurredAt,
49
+ anchor: {
50
+ projectRoot,
51
+ worktreeId: event.git.worktreeId,
52
+ branch: event.git.branch,
53
+ ref: event.git.ref,
54
+ head: event.git.head,
55
+ },
56
+ paths,
57
+ changes: fallbackChanges.sort((left, right) => stableCompare(left.path, right.path)),
58
+ pathCount: typeof payload.pathCount === 'number' && Number.isSafeInteger(payload.pathCount) && payload.pathCount >= paths.length ? payload.pathCount : paths.length,
59
+ coalesced: payload.coalesced === true,
60
+ truncated: payload.truncated === true,
61
+ focusMatch: paths.some((path) => focus.includes(path)),
62
+ };
63
+ }
64
+ export async function extractActivity(projectRoot, events, anchor, workspace, limit) {
65
+ if (limit <= 0 || !workspace)
66
+ return [];
67
+ const currentWorkspace = workspaceKey(anchor);
68
+ const matching = events
69
+ .filter((event) => event.type === 'workspace.paths.changed' && workspaceKey(event.git) === currentWorkspace)
70
+ .sort((left, right) => right.occurredAt.localeCompare(left.occurredAt) || right.eventId.localeCompare(left.eventId))
71
+ .slice(0, limit);
72
+ const records = [];
73
+ for (const event of matching) {
74
+ const record = await activityForEvent(projectRoot, event, workspace.pathFocus);
75
+ if (record)
76
+ records.push(record);
77
+ }
78
+ return records;
79
+ }
@@ -0,0 +1,2 @@
1
+ import type { ContextCompileOptions, ContextManifest } from './types.js';
2
+ export declare function compileContext(startPath?: string, options?: ContextCompileOptions): Promise<ContextManifest>;
@@ -0,0 +1,353 @@
1
+ import { filterPaths } from '../capture/context-filter.js';
2
+ import { contentHash, estimateTokens, rebuildManifestHash } from './identity.js';
3
+ import { queryTerms } from '../cognition/retrieval.js';
4
+ import { listDeadends, listKnowledge } from '../runtime/knowledge.js';
5
+ import { listNotes } from '../runtime/notes.js';
6
+ import { extractActivity } from './activity.js';
7
+ import { latestHostIntent, loadCompilerSources, sourceDescriptor } from './sources.js';
8
+ const scenarioKeywords = [
9
+ { scenario: 'debugging', words: ['bug', 'error', 'fail', 'failure', 'exception', 'regression', 'debug', '失败', '报错', '异常', '修复'] },
10
+ { scenario: 'verification', words: ['test', 'verify', 'check', 'build', '测试', '验证', '检查', '构建'] },
11
+ { scenario: 'handoff', words: ['handoff', 'summary', '交接', '总结', '移交'] },
12
+ { scenario: 'implementation', words: ['implement', 'add', 'change', 'feature', '实现', '新增', '修改', '功能'] },
13
+ { scenario: 'exploration', words: ['search', 'investigate', 'find', 'explore', '查找', '调查', '分析', '探索'] },
14
+ { scenario: 'resume', words: ['resume', 'continue', 'restore', '继续', '恢复'] },
15
+ ];
16
+ const priorities = {
17
+ summary: 100,
18
+ pending: 95,
19
+ risk: 94,
20
+ decision: 92,
21
+ note: 85,
22
+ knowledge: 75,
23
+ deadend: 73,
24
+ activity: 70,
25
+ };
26
+ function clean(value) {
27
+ return value.normalize('NFC').trim().toLowerCase();
28
+ }
29
+ function stableCompare(left, right) {
30
+ return left < right ? -1 : left > right ? 1 : 0;
31
+ }
32
+ function anchorFromSources(sources) {
33
+ return sources.anchor;
34
+ }
35
+ function signal(source, value, origin, confidence) {
36
+ return { source, value, origin, confidence };
37
+ }
38
+ function inferScenario(query, explicit) {
39
+ if (explicit)
40
+ return { scenario: explicit, signals: [signal('explicit-scenario', explicit, 'explicit', 'high')] };
41
+ if (!query)
42
+ return { scenario: 'resume', signals: [] };
43
+ const normalized = clean(query);
44
+ const matches = scenarioKeywords.flatMap(({ scenario, words }) => words.filter((word) => normalized.includes(clean(word))).map((word) => ({ scenario, word })));
45
+ if (matches.length === 0)
46
+ return { scenario: 'unknown', signals: [] };
47
+ const selected = matches[0].scenario;
48
+ return {
49
+ scenario: selected,
50
+ signals: matches.map(({ scenario, word }) => signal('context-state', `${scenario}:${word}`, 'deterministic', 'medium')),
51
+ };
52
+ }
53
+ function sourcePaths(record) {
54
+ const applicability = record.applicability;
55
+ if (!applicability || typeof applicability !== 'object' || Array.isArray(applicability))
56
+ return [];
57
+ const paths = applicability.paths;
58
+ return Array.isArray(paths) ? paths.filter((value) => typeof value === 'string') : [];
59
+ }
60
+ function sourceApplicability(record, descriptor) {
61
+ const applicability = record.applicability;
62
+ if (!applicability || typeof applicability !== 'object' || Array.isArray(applicability))
63
+ return descriptor;
64
+ const value = applicability;
65
+ const branchMode = value.branchMode === 'global' || value.branchMode === 'exact' ? value.branchMode : descriptor.applicability.branchMode;
66
+ const branches = Array.isArray(value.branches) ? value.branches.filter((item) => typeof item === 'string').map(clean).sort(stableCompare) : descriptor.applicability.branches;
67
+ const paths = sourcePaths(record).map((item) => item.replaceAll('\\', '/')).sort(stableCompare);
68
+ return {
69
+ ...descriptor,
70
+ scope: typeof record.scope === 'string' ? record.scope : descriptor.scope,
71
+ paths,
72
+ applicability: {
73
+ ...descriptor.applicability,
74
+ branchMode,
75
+ branches,
76
+ paths,
77
+ ref: descriptor.applicability.ref,
78
+ },
79
+ };
80
+ }
81
+ function matched(text, terms) {
82
+ const normalized = clean(text);
83
+ const matchedTerms = terms.filter((term) => normalized.includes(term));
84
+ return { score: matchedTerms.length, matchedTerms };
85
+ }
86
+ function makeCandidate(section, source, content, text, terms, matchedFields, discriminator) {
87
+ const hit = matched(text, terms);
88
+ const candidateId = `${section}:${source.sourceId}:${matchedFields.join('.')}:${discriminator}`;
89
+ return {
90
+ candidateId,
91
+ section,
92
+ source,
93
+ content,
94
+ text,
95
+ priority: priorities[section],
96
+ score: hit.score,
97
+ matchedTerms: hit.matchedTerms,
98
+ matchedFields,
99
+ originalChars: text.length,
100
+ selectedChars: 0,
101
+ selected: false,
102
+ truncated: false,
103
+ level: 'summary',
104
+ itemHash: contentHash({ candidateId, source, content, text }),
105
+ tokenEstimate: estimateTokens(text),
106
+ expandable: true,
107
+ };
108
+ }
109
+ function applicable(candidate, intent) {
110
+ const source = candidate.source;
111
+ if (source.privacy.classification === 'unknown')
112
+ return { ok: false, reason: 'v2_unavailable' };
113
+ if (source.status && ['candidate', 'deprecated', 'rejected', 'obsolete'].includes(source.status))
114
+ return { ok: false, reason: 'status_not_verified' };
115
+ if (source.applicability.branchMode === 'unsupported')
116
+ return { ok: false, reason: 'unsupported_applicability' };
117
+ if (source.applicability.branchMode === 'exact') {
118
+ const branch = intent.anchor.ref ?? intent.anchor.branch ?? 'detached';
119
+ if (source.applicability.branches.length > 0 && !source.applicability.branches.includes(branch) && !source.applicability.branches.includes(intent.anchor.branch ?? ''))
120
+ return { ok: false, reason: 'applicability_mismatch' };
121
+ if (source.applicability.worktreeId && source.applicability.worktreeId !== intent.anchor.worktreeId)
122
+ return { ok: false, reason: 'applicability_mismatch' };
123
+ if (source.applicability.ref && source.applicability.ref !== intent.anchor.ref)
124
+ return { ok: false, reason: 'applicability_mismatch' };
125
+ }
126
+ if (intent.scope && source.scope && clean(intent.scope) !== clean(source.scope))
127
+ return { ok: false, reason: 'scope_mismatch' };
128
+ if (intent.paths.length > 0 && source.paths.length > 0 && !source.paths.some((path) => intent.paths.includes(path)))
129
+ return { ok: false, reason: 'path_mismatch' };
130
+ if (intent.query && ['knowledge', 'deadend'].includes(candidate.section) && candidate.matchedTerms.length === 0)
131
+ return { ok: false, reason: 'query_no_match' };
132
+ return { ok: true };
133
+ }
134
+ function sortCandidates(left, right) {
135
+ return right.priority - left.priority
136
+ || right.score - left.score
137
+ || right.matchedTerms.length - left.matchedTerms.length
138
+ || stableCompare(left.candidateId, right.candidateId);
139
+ }
140
+ function truncateText(value, maxChars) {
141
+ if (value.length <= maxChars)
142
+ return { text: value, truncated: false };
143
+ if (maxChars <= 0)
144
+ return { text: '', truncated: true };
145
+ return { text: `${value.slice(0, maxChars - 1)}…`, truncated: true };
146
+ }
147
+ function textForSummary(label, value) {
148
+ return value ? `${label}: ${value}` : '';
149
+ }
150
+ function activityText(record) {
151
+ const changes = record.changes.map((change) => `${change.path} (${change.kind})`).join(', ');
152
+ return `activity: ${changes}`;
153
+ }
154
+ function createCandidates(sources, intent, notes, knowledge, deadends, activities) {
155
+ if (sources.orientation.status !== 'ready')
156
+ return [];
157
+ const { context, snapshot, resumeSummary } = sources.orientation;
158
+ const terms = intent.normalizedTerms;
159
+ const candidates = [];
160
+ const contextSource = sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'current-context', context.id, context.id, snapshot.snapshotId, context.status, null, resumeSummary.relatedFiles);
161
+ const summaryFields = [
162
+ ['title', context.title, 'summary'],
163
+ ['progressSummary', resumeSummary.progressSummary, 'summary'],
164
+ ['lastError', resumeSummary.lastError, 'risk'],
165
+ ];
166
+ for (const [field, value, section] of summaryFields) {
167
+ const text = textForSummary(field, value);
168
+ if (text)
169
+ candidates.push(makeCandidate(section, contextSource, { field, value }, text, terms, [field], field));
170
+ }
171
+ const listFields = [
172
+ ['pendingTasks', resumeSummary.pendingTasks, 'pending'],
173
+ ['completed', resumeSummary.completed, 'summary'],
174
+ ['decisions', resumeSummary.decisions, 'decision'],
175
+ ['risks', resumeSummary.risks, 'risk'],
176
+ ['relatedFiles', resumeSummary.relatedFiles, 'summary'],
177
+ ];
178
+ for (const [field, values, section] of listFields) {
179
+ for (const [index, value] of values.entries()) {
180
+ candidates.push(makeCandidate(section, contextSource, { field, value }, `${field}: ${value}`, terms, [field], `${field}:${index}`));
181
+ }
182
+ }
183
+ for (const note of notes) {
184
+ const source = sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'note', note.noteId, note.contextId, note.snapshotId, note.status, null, note.relatedFiles);
185
+ candidates.push(makeCandidate('note', source, note, `${note.kind}: ${note.message}`, terms, ['kind', 'message'], note.noteId));
186
+ }
187
+ for (const item of knowledge) {
188
+ const source = sourceApplicability(item, sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'knowledge', item.knowledgeId, item.sourceContextIds[0] ?? null, item.sourceSnapshotIds[0] ?? null, item.status, item.scope, sourcePaths(item)));
189
+ candidates.push(makeCandidate('knowledge', source, item, item.statement, terms, ['statement', 'kind', 'scope'], item.knowledgeId));
190
+ }
191
+ for (const item of deadends) {
192
+ const source = sourceApplicability(item, sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'deadend', item.deadendId, item.sourceContextId, item.sourceSnapshotId, item.status, item.scope, sourcePaths(item)));
193
+ candidates.push(makeCandidate('deadend', source, item, `${item.attempt}: ${item.reason}`, terms, ['attempt', 'reason', 'scope'], item.deadendId));
194
+ }
195
+ for (const item of activities) {
196
+ const source = sourceDescriptor(sources.anchor.projectRoot, sources.anchor, sources.events, 'journal', item.eventId, null, null, null, null, item.paths);
197
+ candidates.push(makeCandidate('activity', source, item, activityText(item), terms, ['paths'], item.eventId));
198
+ }
199
+ return candidates;
200
+ }
201
+ function emptyTruncation() {
202
+ return { truncated: false, summary: false, pending: false, decisions: false, risks: false, notes: false, knowledge: false, deadends: false, activity: false, candidatesOmittedByBudget: 0, candidatesPartiallyTruncated: 0 };
203
+ }
204
+ function manifestText(selected) {
205
+ return selected.map((candidate) => candidate.text).join('\n');
206
+ }
207
+ function intentFor(sources, options, normalizedPaths) {
208
+ const orientation = sources.orientation;
209
+ const host = latestHostIntent(sources.events, sources.anchor);
210
+ const query = options.query?.trim() || host?.value.trim() || (orientation.status === 'ready' ? orientation.context.title : null);
211
+ const inferred = inferScenario(query, options.scenario);
212
+ const signals = [
213
+ ...(options.query ? [signal('explicit-query', options.query.trim(), 'explicit', 'high')] : []),
214
+ ...(host && !options.query ? [signal('host-prompt', host.value, 'deterministic', 'medium')] : []),
215
+ ...(normalizedPaths.map((value) => signal('path', value, 'explicit', 'high'))),
216
+ ...(inferred.signals),
217
+ ];
218
+ return {
219
+ schemaVersion: 1,
220
+ trigger: options.trigger ?? 'manual',
221
+ query,
222
+ normalizedTerms: query ? queryTerms(query).map(clean).sort(stableCompare) : [],
223
+ scenario: inferred.scenario,
224
+ scope: options.scope ?? null,
225
+ paths: normalizedPaths,
226
+ contextId: options.contextId ?? (orientation.status === 'ready' ? orientation.context.id : null),
227
+ anchor: anchorFromSources(sources),
228
+ signals,
229
+ requestedSections: ['summary', 'pending', 'decision', 'risk', 'note', 'knowledge', 'deadend', 'activity'],
230
+ budget: { unit: 'chars', total: Math.max(0, options.maxChars ?? 4000), reservedForRenderer: 0 },
231
+ adapter: options.adapter ?? 'generic',
232
+ sessionId: options.sessionId ?? null,
233
+ eventId: options.eventId ?? null,
234
+ };
235
+ }
236
+ export async function compileContext(startPath, options = {}) {
237
+ const sources = await loadCompilerSources(startPath, options);
238
+ const normalizedPaths = options.paths ? await filterPaths(sources.anchor.projectRoot, options.paths) : [];
239
+ const intent = intentFor(sources, options, normalizedPaths);
240
+ const orientation = sources.orientation;
241
+ const maxChars = intent.budget.total;
242
+ const notes = orientation.status === 'ready' ? (await listNotes(sources.anchor.projectRoot, { contextId: orientation.context.id, openOnly: true, limit: options.noteLimit ?? 50, maxChars: Number.MAX_SAFE_INTEGER })).filter((note) => orientation.notes.some((selected) => selected.noteId === note.noteId)) : [];
243
+ const knowledge = orientation.status === 'ready' && intent.query ? await listKnowledge(sources.anchor.projectRoot, true) : [];
244
+ const deadends = orientation.status === 'ready' && intent.query ? await listDeadends(sources.anchor.projectRoot, true) : [];
245
+ const activities = await extractActivity(sources.anchor.projectRoot, sources.events, sources.anchor, sources.workspace, options.activityLimit ?? 20);
246
+ const all = createCandidates(sources, intent, notes, knowledge, deadends, activities);
247
+ const excluded = [];
248
+ const eligible = all.filter((candidate) => {
249
+ const check = applicable(candidate, intent);
250
+ if (!check.ok)
251
+ excluded.push({ candidateId: candidate.candidateId, source: candidate.source, reason: check.reason ?? 'query_no_match' });
252
+ return check.ok;
253
+ }).sort(sortCandidates);
254
+ const selected = [];
255
+ const truncation = emptyTruncation();
256
+ const sectionStats = {};
257
+ let usedChars = 0;
258
+ for (const candidate of eligible) {
259
+ const separator = selected.length > 0 ? 1 : 0;
260
+ const available = maxChars - usedChars - separator;
261
+ if (available <= 0) {
262
+ excluded.push({ candidateId: candidate.candidateId, source: candidate.source, reason: 'budget' });
263
+ truncation.candidatesOmittedByBudget += 1;
264
+ truncation.truncated = true;
265
+ continue;
266
+ }
267
+ const limited = truncateText(candidate.text, available);
268
+ if (!limited.text) {
269
+ excluded.push({ candidateId: candidate.candidateId, source: candidate.source, reason: 'budget' });
270
+ truncation.candidatesOmittedByBudget += 1;
271
+ truncation.truncated = true;
272
+ continue;
273
+ }
274
+ const item = { ...candidate, text: limited.text, selectedChars: limited.text.length, selected: true, truncated: limited.truncated };
275
+ selected.push(item);
276
+ usedChars += separator + item.selectedChars;
277
+ const prior = sectionStats[item.section] ?? { allocated: maxChars, consumed: 0, truncated: false };
278
+ sectionStats[item.section] = { ...prior, consumed: prior.consumed + item.selectedChars + separator, truncated: prior.truncated || limited.truncated };
279
+ if (limited.truncated) {
280
+ truncation.candidatesPartiallyTruncated += 1;
281
+ truncation.truncated = true;
282
+ if (item.section === 'summary')
283
+ truncation.summary = true;
284
+ if (item.section === 'pending')
285
+ truncation.pending = true;
286
+ if (item.section === 'decision')
287
+ truncation.decisions = true;
288
+ if (item.section === 'risk')
289
+ truncation.risks = true;
290
+ if (item.section === 'note')
291
+ truncation.notes = true;
292
+ if (item.section === 'knowledge')
293
+ truncation.knowledge = true;
294
+ if (item.section === 'deadend')
295
+ truncation.deadends = true;
296
+ if (item.section === 'activity')
297
+ truncation.activity = true;
298
+ }
299
+ }
300
+ const sections = {
301
+ summary: selected.filter((item) => item.section === 'summary'),
302
+ pending: selected.filter((item) => item.section === 'pending'),
303
+ decision: selected.filter((item) => item.section === 'decision'),
304
+ risk: selected.filter((item) => item.section === 'risk'),
305
+ notes: selected.filter((item) => item.section === 'note'),
306
+ knowledge: selected.filter((item) => item.section === 'knowledge'),
307
+ deadends: selected.filter((item) => item.section === 'deadend'),
308
+ activity: selected.filter((item) => item.section === 'activity'),
309
+ };
310
+ const tokenEstimate = estimateTokens(manifestText(selected));
311
+ const selectedItemHashes = selected.map((item) => item.itemHash);
312
+ const manifestHash = rebuildManifestHash({
313
+ status: orientation.status,
314
+ projectRoot: sources.anchor.projectRoot,
315
+ mode: orientation.explanation.mode,
316
+ anchor: sources.anchor,
317
+ intent,
318
+ selected,
319
+ budget: { requested: intent.budget, usedChars, remainingChars: Math.max(0, maxChars - usedChars), sections: sectionStats },
320
+ truncation,
321
+ });
322
+ return {
323
+ schemaVersion: 1,
324
+ compilerVersion: '0.7.6',
325
+ rankingVersion: 'compiler-ranking-v2',
326
+ status: orientation.status,
327
+ projectRoot: sources.anchor.projectRoot,
328
+ mode: orientation.explanation.mode,
329
+ anchor: sources.anchor,
330
+ intent,
331
+ context: orientation.context,
332
+ snapshot: orientation.snapshot,
333
+ resumeSummary: orientation.resumeSummary,
334
+ sections,
335
+ selected,
336
+ excluded,
337
+ identity: {
338
+ manifestHash,
339
+ selectedItemHashes,
340
+ tokenEstimate: { input: tokenEstimate, output: tokenEstimate, unit: 'estimated-tokens', method: 'chars-div-four' },
341
+ },
342
+ budget: { requested: intent.budget, usedChars, remainingChars: Math.max(0, maxChars - usedChars), sections: sectionStats },
343
+ truncation,
344
+ provenance: {
345
+ journalRead: sources.events.length > 0,
346
+ journalEventCount: sources.events.length,
347
+ selectedSourceIds: [...new Set(selected.map((item) => item.source.sourceId))].sort(stableCompare),
348
+ selectedEventIds: [...new Set(selected.flatMap((item) => item.source.provenance.eventId ? [item.source.provenance.eventId] : []))].sort(stableCompare),
349
+ fallbackReason: sources.fallbackReason,
350
+ },
351
+ text: manifestText(selected),
352
+ };
353
+ }
@@ -0,0 +1,2 @@
1
+ import type { ContextExpandInput, ContextExpandResult } from './types.js';
2
+ export declare function expandContext(startPath: string | undefined, input: ContextExpandInput): Promise<ContextExpandResult>;
@@ -0,0 +1,187 @@
1
+ import { compileContext } from './compile.js';
2
+ import { extractActivity } from './activity.js';
3
+ import { validateManifestIdentity } from './identity.js';
4
+ import { readJournal } from '../cognition/journal.js';
5
+ import { projectEvents, workspaceKey } from '../cognition/projections.js';
6
+ import { rebuildSnapshot } from '../runtime/runtime.js';
7
+ import { listDeadends, listKnowledge } from '../runtime/knowledge.js';
8
+ import { listNotes } from '../runtime/notes.js';
9
+ function truncateText(value, maxChars) {
10
+ if (value.length <= maxChars)
11
+ return { text: value, truncated: false };
12
+ if (maxChars <= 0)
13
+ return { text: '', truncated: true };
14
+ return { text: `${value.slice(0, maxChars - 1)}…`, truncated: true };
15
+ }
16
+ function isExpandLevel(value) {
17
+ return value === 'structured' || value === 'evidence';
18
+ }
19
+ function validateInput(input) {
20
+ if (!input.manifestHash.trim())
21
+ throw new Error('context expand manifest hash must not be empty');
22
+ if (!input.candidateId?.trim() && !input.itemHash?.trim())
23
+ throw new Error('context expand requires candidateId or itemHash');
24
+ if (!isExpandLevel(input.level))
25
+ throw new Error('context expand level must be structured or evidence');
26
+ if (input.maxChars !== undefined && (!Number.isSafeInteger(input.maxChars) || input.maxChars < 0))
27
+ throw new Error('context expand maxChars must be a non-negative integer');
28
+ }
29
+ function selectedCandidate(manifest, input) {
30
+ const byId = input.candidateId ? manifest.selected.find((item) => item.candidateId === input.candidateId) : undefined;
31
+ const byHash = input.itemHash ? manifest.selected.find((item) => item.itemHash === input.itemHash) : undefined;
32
+ if (byId && byHash && byId.candidateId !== byHash.candidateId)
33
+ throw new Error('context expand candidate selectors do not match');
34
+ const candidate = byId ?? byHash;
35
+ if (!candidate)
36
+ throw new Error('context expand candidate not selected');
37
+ if (!candidate.expandable)
38
+ throw new Error('context expand candidate is not expandable');
39
+ if (candidate.level !== 'summary')
40
+ throw new Error('context expand candidate is already expanded');
41
+ return candidate;
42
+ }
43
+ function contextField(content, candidate) {
44
+ const field = candidate.content.field;
45
+ if (typeof field !== 'string')
46
+ throw new Error('context expand current context field is invalid');
47
+ if (field === 'title')
48
+ return candidate.content.value ?? '';
49
+ if (field === 'progressSummary' || field === 'lastError')
50
+ return content[field];
51
+ if (field === 'pendingTasks' || field === 'completed' || field === 'decisions' || field === 'risks' || field === 'relatedFiles') {
52
+ const discriminator = candidate.candidateId.split(':').at(-1) ?? '';
53
+ const index = Number(discriminator);
54
+ const values = content[field];
55
+ if (!Number.isInteger(index) || index < 0 || index >= values.length)
56
+ throw new Error(`context expand current context item is unavailable: ${field}`);
57
+ return values[index];
58
+ }
59
+ throw new Error(`context expand unsupported current context field: ${field}`);
60
+ }
61
+ function currentContextText(value, candidate) {
62
+ const field = candidate.content.field;
63
+ if (typeof field !== 'string')
64
+ throw new Error('context expand current context field is invalid');
65
+ return `${field}: ${Array.isArray(value) ? value.join(', ') : value ?? ''}`;
66
+ }
67
+ function recordText(source, value) {
68
+ if (source.kind === 'note') {
69
+ const note = value;
70
+ return `${note.kind}: ${note.message}`;
71
+ }
72
+ if (source.kind === 'knowledge')
73
+ return value.statement;
74
+ const deadend = value;
75
+ return `${deadend.attempt}: ${deadend.reason}`;
76
+ }
77
+ function evidenceText(source, value, structuredText) {
78
+ return `${structuredText}\nevidence: ${JSON.stringify({
79
+ source: source.provenance,
80
+ ...(source.kind === 'knowledge' ? { supportingEvidence: value.supportingEvidence } : {}),
81
+ ...(source.kind === 'deadend' ? { observedEvidence: value.observedEvidence } : {}),
82
+ ...(source.kind === 'note' ? { relatedFiles: value.relatedFiles } : {}),
83
+ })}`;
84
+ }
85
+ function recordApplicability(value) {
86
+ if (!value || typeof value !== 'object' || Array.isArray(value))
87
+ return undefined;
88
+ const applicability = value.applicability;
89
+ if (!applicability || typeof applicability !== 'object' || Array.isArray(applicability))
90
+ return undefined;
91
+ return applicability;
92
+ }
93
+ function ensureApplicable(source, value, manifest) {
94
+ const applicability = recordApplicability(value);
95
+ if (!applicability)
96
+ return;
97
+ if (applicability.branchMode !== source.applicability.branchMode)
98
+ throw new Error(`context expand source applicability changed: ${source.entityId}`);
99
+ if (applicability.branchMode === 'exact') {
100
+ const branch = manifest.anchor.ref ?? manifest.anchor.branch ?? 'detached';
101
+ const branches = (applicability.branches ?? []).map((item) => item.toLowerCase());
102
+ if (branches.length > 0 && !branches.includes(branch.toLowerCase()) && !branches.includes((manifest.anchor.branch ?? '').toLowerCase()))
103
+ throw new Error(`context expand source is not applicable: ${source.entityId}`);
104
+ if (applicability.sourceCommit && applicability.sourceCommit !== manifest.anchor.head)
105
+ throw new Error(`context expand source commit is not applicable: ${source.entityId}`);
106
+ if (source.applicability.worktreeId && source.applicability.worktreeId !== manifest.anchor.worktreeId)
107
+ throw new Error(`context expand source worktree is not applicable: ${source.entityId}`);
108
+ if (source.applicability.ref && source.applicability.ref !== manifest.anchor.ref)
109
+ throw new Error(`context expand source ref is not applicable: ${source.entityId}`);
110
+ }
111
+ if (source.scope && typeof value.scope === 'string' && source.scope !== value.scope)
112
+ throw new Error(`context expand source scope changed: ${source.entityId}`);
113
+ const paths = applicability.paths ?? [];
114
+ if (source.paths.length > 0 && paths.length > 0 && !source.paths.some((path) => paths.includes(path)))
115
+ throw new Error(`context expand source paths changed: ${source.entityId}`);
116
+ }
117
+ async function resolveCandidate(projectRoot, manifest, candidate, level) {
118
+ const source = candidate.source;
119
+ if (source.kind === 'current-context') {
120
+ if (!source.contextId || !source.snapshotId)
121
+ throw new Error('context expand current context source is incomplete');
122
+ const content = await rebuildSnapshot(projectRoot, source.contextId, source.snapshotId);
123
+ const value = contextField(content, candidate);
124
+ const text = currentContextText(value, candidate);
125
+ return { content: level === 'evidence' ? { field: candidate.content.field, value, source: source.provenance } : { field: candidate.content.field, value }, text: level === 'evidence' ? evidenceText(source, { value }, text) : text };
126
+ }
127
+ if (source.kind === 'note') {
128
+ const note = (await listNotes(projectRoot, { contextId: source.contextId ?? undefined, limit: Number.MAX_SAFE_INTEGER, maxChars: Number.MAX_SAFE_INTEGER })).find((item) => item.noteId === source.entityId);
129
+ if (!note)
130
+ throw new Error(`context expand note not found: ${source.entityId}`);
131
+ const text = recordText(source, note);
132
+ return { content: level === 'evidence' ? { ...note, provenance: source.provenance } : note, text: level === 'evidence' ? evidenceText(source, note, text) : text };
133
+ }
134
+ if (source.kind === 'knowledge') {
135
+ const knowledge = (await listKnowledge(projectRoot, true)).find((item) => item.knowledgeId === source.entityId);
136
+ if (!knowledge || knowledge.status !== 'verified')
137
+ throw new Error(`context expand knowledge is no longer verified: ${source.entityId}`);
138
+ ensureApplicable(source, knowledge, manifest);
139
+ const text = recordText(source, knowledge);
140
+ return { content: level === 'evidence' ? { ...knowledge, provenance: source.provenance } : knowledge, text: level === 'evidence' ? evidenceText(source, knowledge, text) : text };
141
+ }
142
+ if (source.kind === 'deadend') {
143
+ const deadend = (await listDeadends(projectRoot, true)).find((item) => item.deadendId === source.entityId);
144
+ if (!deadend || deadend.status !== 'verified')
145
+ throw new Error(`context expand deadend is no longer verified: ${source.entityId}`);
146
+ ensureApplicable(source, deadend, manifest);
147
+ const text = recordText(source, deadend);
148
+ return { content: level === 'evidence' ? { ...deadend, provenance: source.provenance } : deadend, text: level === 'evidence' ? evidenceText(source, deadend, text) : text };
149
+ }
150
+ if (source.kind === 'journal') {
151
+ const events = manifest.provenance.journalRead ? await readJournal(projectRoot) : [];
152
+ const projection = events.length > 0 ? projectEvents(events) : null;
153
+ const workspace = projection?.workspaces[workspaceKey(manifest.anchor)] ?? null;
154
+ const records = await extractActivity(projectRoot, events, manifest.anchor, workspace, Number.MAX_SAFE_INTEGER);
155
+ const activity = records.find((item) => item.eventId === source.entityId);
156
+ if (!activity)
157
+ throw new Error(`context expand activity not found: ${source.entityId}`);
158
+ return { content: activity, text: `activity: ${activity.changes.map((change) => `${change.path} (${change.kind})`).join(', ')}` };
159
+ }
160
+ throw new Error(`context expand source kind is not supported: ${source.kind}`);
161
+ }
162
+ export async function expandContext(startPath, input) {
163
+ validateInput(input);
164
+ const manifest = await compileContext(startPath, input.compile);
165
+ if (!validateManifestIdentity(manifest) || manifest.identity.manifestHash !== input.manifestHash)
166
+ throw new Error('context expand manifest hash mismatch');
167
+ const candidate = selectedCandidate(manifest, input);
168
+ const resolved = await resolveCandidate(manifest.projectRoot, manifest, candidate, input.level);
169
+ const limited = truncateText(resolved.text, input.maxChars ?? Number.MAX_SAFE_INTEGER);
170
+ const content = limited.truncated ? null : resolved.content;
171
+ const requestedChars = input.maxChars ?? resolved.text.length;
172
+ return {
173
+ status: limited.truncated ? 'partial' : 'complete',
174
+ projectRoot: manifest.projectRoot,
175
+ manifestHash: manifest.identity.manifestHash,
176
+ candidateId: candidate.candidateId,
177
+ itemHash: candidate.itemHash,
178
+ previousLevel: candidate.level,
179
+ requestedLevel: input.level,
180
+ resolvedLevel: input.level,
181
+ source: candidate.source,
182
+ content,
183
+ text: limited.text,
184
+ budget: { requestedChars, usedChars: limited.text.length, remainingChars: Math.max(0, requestedChars - limited.text.length), truncated: limited.truncated },
185
+ provenance: manifest.provenance,
186
+ };
187
+ }
@@ -0,0 +1,6 @@
1
+ import type { ContextManifest } from './types.js';
2
+ export declare function canonicalJson(value: unknown): string;
3
+ export declare function contentHash(value: unknown): string;
4
+ export declare function estimateTokens(value: string): number;
5
+ export declare function rebuildManifestHash(manifest: Pick<ContextManifest, 'status' | 'projectRoot' | 'mode' | 'anchor' | 'intent' | 'selected' | 'budget' | 'truncation'>): string;
6
+ export declare function validateManifestIdentity(manifest: ContextManifest): boolean;