@kentwynn/kgraph 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,8 @@ Persistent repository intelligence for AI coding tools.
4
4
 
5
5
  KGraph gives Codex, GitHub Copilot, Cursor, Claude Code, Gemini CLI, Windsurf, and Cline a local knowledge layer for your repo: file maps, symbols, imports, relationships, and durable knowledge atoms from previous AI sessions. The goal is simple: your assistant should not spend every session re-learning the same codebase.
6
6
 
7
+ The CLI presents this as **Atom Core**: lightweight local atoms plus deterministic repo maps, context packs, and session history that remain inspectable under `.kgraph/`.
8
+
7
9
  ## The Workflow
8
10
 
9
11
  Use KGraph in two steps:
@@ -25,11 +25,20 @@ export function registerContextCommand(program) {
25
25
  }));
26
26
  }
27
27
  export function renderContextMarkdown(response) {
28
- const lines = [`# KGraph Context`, ``, `Query: ${response.query}`, ``];
29
- lines.push('## Matched Domains', '');
30
- lines.push(...formatList(response.matchedDomains.map((item) => `- ${item.item.name} because ${formatReasons(item.reasons)}`)));
31
- lines.push('', '## Relevant Files', '');
32
- lines.push(...formatList(response.relevantFiles.map((item) => {
28
+ const lines = [
29
+ `KGraph Context · ${response.query}`,
30
+ `local-first · deterministic · inspectable`,
31
+ ``,
32
+ `● Signal`,
33
+ ` confidence ${contextConfidence(response)}`,
34
+ ` source ${contextSources(response).join(' + ') || 'file map'}`,
35
+ ` warnings ${response.warnings.length > 0 ? response.warnings.length : 'none'}`,
36
+ ``,
37
+ ];
38
+ lines.push('● Matched Domains', '');
39
+ lines.push(...formatList(response.matchedDomains.map((item) => atomLine(item.item.name, formatReasons(item.reasons)))));
40
+ lines.push('', '● Source', '');
41
+ lines.push(...formatList(limited(response.relevantFiles, 5).map((item) => {
33
42
  const f = item.item;
34
43
  const meta = [
35
44
  f.language,
@@ -37,32 +46,34 @@ export function renderContextMarkdown(response) {
37
46
  ]
38
47
  .filter(Boolean)
39
48
  .join(', ');
40
- return `- ${f.path}${meta ? ` [${meta}]` : ''} because ${formatReasons(item.reasons)}`;
49
+ return atomLine(`${f.path}${meta ? ` [${meta}]` : ''}`, formatReasons(item.reasons));
41
50
  })));
42
- lines.push('', '## Relevant Symbols', '');
43
- lines.push(...formatList(response.relevantSymbols.map((item) => {
51
+ appendMore(lines, response.relevantFiles.length, 5, 'source item');
52
+ lines.push('', '● Symbols', '');
53
+ lines.push(...formatList(limited(response.relevantSymbols, 6).map((item) => {
44
54
  const s = item.item;
45
55
  const kindInfo = [s.kind, s.parentName].filter(Boolean).join(', ');
46
56
  const lineRange = s.startLine != null && s.endLine != null
47
57
  ? `:${s.startLine}-${s.endLine}`
48
58
  : '';
49
- return `- ${s.name} (${kindInfo}) in ${s.filePath}${lineRange} because ${formatReasons(item.reasons)}`;
59
+ return atomLine(`${s.name} (${kindInfo}) in ${s.filePath}${lineRange}`, formatReasons(item.reasons));
50
60
  })));
51
- lines.push('', '## Relevant Cognition', '');
52
- lines.push(...formatList(response.relevantCognition.map((item) => `- ${item.item.title} [${item.item.kind ?? 'summary'}, ${item.item.confidence ?? 'medium'}, ${item.item.referencesStatus}] because ${formatReasons(item.reasons)}`)));
53
- lines.push('', '## Relationships', '');
54
- lines.push(...formatGroupedRelationships(response.relationships, response.relationshipExplanations));
55
- lines.push('', '## Nearby Symbols (1-hop imports)', '');
61
+ appendMore(lines, response.relevantSymbols.length, 6, 'symbol');
62
+ lines.push('', '● Atoms', '');
63
+ lines.push(...formatList(response.relevantCognition.map((item) => atomLine(`${item.item.title} [${item.item.kind ?? 'summary'}, ${item.item.confidence ?? 'medium'}, ${item.item.referencesStatus}]`, formatReasons(item.reasons)))));
64
+ lines.push('', '● Graph', '');
65
+ lines.push(...formatGroupedRelationships(relevantGraphRelationships(response), response.relationshipExplanations));
66
+ lines.push('', '● Nearby Symbols (1-hop imports)', '');
56
67
  lines.push(...formatList(nearbySymbolItems(response).map(({ symbol: s, reasons }) => {
57
68
  const kindInfo = [s.kind, s.parentName].filter(Boolean).join(', ');
58
69
  const lineRange = s.startLine != null && s.endLine != null
59
70
  ? `:${s.startLine}-${s.endLine}`
60
71
  : '';
61
- return `- ${s.name} (${kindInfo}) in ${s.filePath}${lineRange} because ${formatReasons(reasons)}`;
72
+ return atomLine(`${s.name} (${kindInfo}) in ${s.filePath}${lineRange}`, formatReasons(reasons));
62
73
  })));
63
- lines.push('', '## Stale References', '');
64
- lines.push(...formatList(response.staleReferences.map((ref) => `- ${ref}`)));
65
- lines.push('', '## Recent Git Changes', '');
74
+ lines.push('', ' Stale References', '');
75
+ lines.push(...formatList(response.staleReferences.map((ref) => ` ◌ ${ref}`)));
76
+ lines.push('', ' Recent Git Changes', '');
66
77
  if (response.gitChanges && response.gitChanges.length > 0) {
67
78
  const staged = response.gitChanges.filter((c) => c.status === 'staged');
68
79
  const unstaged = response.gitChanges.filter((c) => c.status === 'unstaged');
@@ -70,22 +81,29 @@ export function renderContextMarkdown(response) {
70
81
  if (staged.length > 0) {
71
82
  lines.push('Staged:');
72
83
  for (const c of staged)
73
- lines.push(` ${c.path} (${c.reason})`);
84
+ lines.push(` ${c.path} (${c.reason})`);
74
85
  }
75
86
  if (unstaged.length > 0) {
76
87
  lines.push('Unstaged:');
77
88
  for (const c of unstaged)
78
- lines.push(` ${c.path} (${c.reason})`);
89
+ lines.push(` ${c.path} (${c.reason})`);
79
90
  }
80
91
  if (recent.length > 0) {
81
92
  lines.push('Recent commits:');
82
93
  for (const c of recent)
83
- lines.push(` ${c.path} (${c.reason})`);
94
+ lines.push(` ${c.path} (${c.reason})`);
84
95
  }
85
96
  }
86
97
  else {
87
98
  lines.push('- None');
88
99
  }
100
+ lines.push('', '● Next', '');
101
+ if (response.relevantFiles.some((item) => (item.item.tokenEstimate ?? 0) > 4000)) {
102
+ lines.push(` use budgeted source ranges: kgraph pack "${response.query}" --budget 4000`);
103
+ }
104
+ else {
105
+ lines.push(` read the ranked source, edit, verify, conclude only if durable knowledge changed`);
106
+ }
89
107
  return lines.join('\n');
90
108
  }
91
109
  function formatGroupedRelationships(relationships, explanations) {
@@ -105,33 +123,49 @@ function formatGroupedRelationships(relationships, explanations) {
105
123
  const lines = [];
106
124
  if (imports.length > 0) {
107
125
  lines.push('Imports:');
108
- for (const r of imports) {
126
+ for (const r of imports.slice(0, 6)) {
109
127
  lines.push(` ${r.sourceId} → ${r.targetId}${formatRelationshipReason(r, reasonsByRelationship)}`);
110
128
  }
129
+ appendMore(lines, imports.length, 6, 'import edge');
111
130
  }
112
131
  if (calls.length > 0) {
113
132
  lines.push('Calls:');
114
- for (const r of calls) {
133
+ for (const r of calls.slice(0, 6)) {
115
134
  lines.push(` ${r.sourceId} → ${r.targetId}${formatRelationshipReason(r, reasonsByRelationship)}`);
116
135
  }
136
+ appendMore(lines, calls.length, 6, 'call edge');
117
137
  }
118
138
  if (contains.length > 0) {
119
139
  lines.push('Contains:');
120
- for (const r of contains) {
140
+ for (const r of contains.slice(0, 6)) {
121
141
  lines.push(` ${r.sourceId} contains ${r.targetId}${formatRelationshipReason(r, reasonsByRelationship)}`);
122
142
  }
143
+ appendMore(lines, contains.length, 6, 'containment edge');
123
144
  }
124
145
  if (other.length > 0) {
125
146
  lines.push('Other:');
126
- for (const r of other) {
147
+ for (const r of other.slice(0, 6)) {
127
148
  lines.push(` ${r.sourceId} ${r.relationshipType} ${r.targetId}${formatRelationshipReason(r, reasonsByRelationship)}`);
128
149
  }
150
+ appendMore(lines, other.length, 6, 'graph edge');
129
151
  }
130
152
  return lines.length > 0 ? lines : ['- None'];
131
153
  }
132
154
  function formatList(items) {
133
155
  return items.length > 0 ? items : ['- None'];
134
156
  }
157
+ function limited(items, count) {
158
+ return items.slice(0, count);
159
+ }
160
+ function appendMore(lines, total, shown, label) {
161
+ const remaining = total - shown;
162
+ if (remaining > 0) {
163
+ lines.push(` ◌ ${remaining} more ${label}${remaining === 1 ? '' : 's'} omitted from display`);
164
+ }
165
+ }
166
+ function atomLine(title, detail) {
167
+ return ` ● ${title}\n because ${detail}`;
168
+ }
135
169
  function formatReasons(reasons) {
136
170
  if (reasons.length === 0) {
137
171
  return 'it is near the query';
@@ -151,6 +185,39 @@ function nearbySymbolItems(response) {
151
185
  reasons: ['exported symbol from 1-hop import'],
152
186
  }));
153
187
  }
188
+ function relevantGraphRelationships(response) {
189
+ const anchorPaths = response.relevantFiles
190
+ .filter((item) => !item.reasons.some((reason) => reason.includes('generic path-only match penalty')))
191
+ .slice(0, 3)
192
+ .map((item) => item.item.path);
193
+ const paths = anchorPaths.length > 0
194
+ ? anchorPaths
195
+ : response.relevantFiles.slice(0, 1).map((item) => item.item.path);
196
+ if (paths.length === 0)
197
+ return response.relationships;
198
+ return response.relationships.filter((relationship) => paths.some((path) => relationship.sourceId.includes(path) ||
199
+ relationship.targetId.includes(path)));
200
+ }
201
+ function contextConfidence(response) {
202
+ const confidence = response.relevantCognition
203
+ .map((item) => item.item.confidence)
204
+ .find(Boolean);
205
+ return confidence ?? (response.relevantFiles.length > 0 ? 'medium' : 'low');
206
+ }
207
+ function contextSources(response) {
208
+ const sources = [];
209
+ if (response.relevantCognition.length > 0)
210
+ sources.push('atom');
211
+ if ((response.gitChanges ?? []).length > 0)
212
+ sources.push('git change');
213
+ if (response.relevantSymbols.length > 0)
214
+ sources.push('symbol');
215
+ if (relevantGraphRelationships(response).length > 0)
216
+ sources.push('graph');
217
+ if (response.relevantFiles.length > 0)
218
+ sources.push('file');
219
+ return sources;
220
+ }
154
221
  function formatRelationshipReason(relationship, reasonsByRelationship) {
155
222
  const reasons = reasonsByRelationship.get(relationshipKey(relationship));
156
223
  return reasons && reasons.length > 0
@@ -26,7 +26,7 @@ export function registerPackCommand(program) {
26
26
  readMaps(workspace),
27
27
  ]);
28
28
  const response = await queryContext(workspace, config, maps, task);
29
- const pack = buildContextPack(response, budget);
29
+ const pack = buildContextPack(response, budget, workspace.rootPath);
30
30
  if (options.json) {
31
31
  console.log(JSON.stringify(pack, null, 2));
32
32
  return;
package/dist/cli/help.js CHANGED
@@ -1,31 +1,32 @@
1
1
  import { Chalk } from 'chalk';
2
- import figlet from 'figlet';
3
2
  export function renderRootHelp(useColor = supportsColor()) {
4
3
  const theme = new Chalk({ level: useColor ? 3 : 0 });
5
4
  const command = (name, description) => ` ${theme.green(name.padEnd(42))} ${description}`;
6
- const logo = renderLogo();
5
+ const accent = atomAccent(theme);
7
6
  return [
8
7
  '',
9
- theme.hex('#7dd3fc').bold(logo),
8
+ renderAtomLogo(theme),
10
9
  '',
11
- ` ${theme.bold('KGraph')} ${theme.dim('Persistent repo intelligence for AI coding tools')}`,
10
+ renderSignalPanel(theme, [
11
+ ['purpose', 'durable engineering memory for AI coding tools'],
12
+ ['storage', '.kgraph/ atoms, maps, indexes, and session history'],
13
+ ['stance', 'local-first · deterministic-first · inspectable'],
14
+ ['agents', 'Codex · Copilot · Cursor · Claude Code · Gemini · Windsurf · Cline'],
15
+ ]),
12
16
  '',
13
- ` ${theme.hex('#c084fc')('Build a local knowledge layer that helps Codex, Copilot, Cursor,')}`,
14
- ` ${theme.hex('#c084fc')('Claude Code, Gemini, Windsurf, and Cline reuse repo intelligence.')}`,
15
- '',
16
- theme.bold('Usage'),
17
+ sectionTitle(theme, `${accent} Usage`),
17
18
  ' kgraph [topic]',
18
19
  ' kgraph <command> [options]',
19
20
  '',
20
- theme.bold('Start'),
21
+ sectionTitle(theme, `${accent} Start`),
21
22
  command('init', 'Required once: create .kgraph/ workspace'),
22
23
  command('init --integrations codex,gemini', 'Initialize and connect AI tools'),
23
24
  '',
24
- theme.bold('Daily workflow'),
25
+ sectionTitle(theme, `${accent} Daily workflow`),
25
26
  command('kgraph', 'Refresh scan maps and process pending capture notes'),
26
27
  command('kgraph "auth token refresh"', 'Refresh everything and return compact context for a topic'),
27
28
  '',
28
- theme.bold('Workflows'),
29
+ sectionTitle(theme, `${accent} Workflows`),
29
30
  command('scan', 'Optional: refresh only file, symbol, import, and relationship maps'),
30
31
  command('session', 'Show agent read/write activity and token estimates'),
31
32
  command('session read src/auth.ts --agent codex', 'Record an agent file read'),
@@ -48,7 +49,7 @@ export function renderRootHelp(useColor = supportsColor()) {
48
49
  command('visualize', 'Interactive dependency graph at http://localhost:4242'),
49
50
  command('history "blog button"', 'Search processed cognition sessions'),
50
51
  '',
51
- theme.bold('Integrations'),
52
+ sectionTitle(theme, `${accent} Integrations`),
52
53
  command('integrate list', 'Show configured AI tool integrations'),
53
54
  command('integrate add gemini windsurf cline', 'Write KGraph instructions using always mode by default'),
54
55
  command('integrate add copilot --mode smart', 'Run KGraph for repo-specific Copilot work only'),
@@ -56,21 +57,24 @@ export function renderRootHelp(useColor = supportsColor()) {
56
57
  command('integrate remove cursor', 'Remove KGraph-managed instruction blocks'),
57
58
  command('--mode smart|always|manual|off', 'Control automatic KGraph involvement per integration'),
58
59
  '',
59
- theme.bold('Options'),
60
+ sectionTitle(theme, `${accent} Options`),
60
61
  command('-V, --version', 'Show version'),
61
62
  command('-h, --help', 'Show this help'),
62
63
  '',
63
- `${theme.yellow('Examples')}`,
64
+ sectionTitle(theme, `${accent} Examples`),
64
65
  ' kgraph init --integrations codex,copilot,cursor,claude-code,gemini,windsurf,cline',
65
66
  ' kgraph "blog admin token usage"',
67
+ ' kgraph pack "about page update" --budget 4000',
66
68
  ' kgraph doctor',
67
69
  '',
68
70
  theme.dim('Docs: https://github.com/kentwynn/KGraph#readme'),
71
+ theme.dim('Powered by Kent Wynn: https://kentwynn.com'),
69
72
  '',
70
73
  ].join('\n');
71
74
  }
72
75
  export function renderWorkflowBanner(stats, useColor = supportsColor()) {
73
76
  const theme = new Chalk({ level: useColor ? 3 : 0 });
77
+ const accent = atomAccent(theme);
74
78
  const command = (name, description) => ` ${theme.green(name.padEnd(42))} ${description}`;
75
79
  const integrationLine = stats.integrations && stats.integrations.length > 0
76
80
  ? stats.integrations
@@ -81,11 +85,11 @@ export function renderWorkflowBanner(stats, useColor = supportsColor()) {
81
85
  : 'none configured';
82
86
  return [
83
87
  '',
84
- theme.hex('#7dd3fc').bold(renderLogo()),
88
+ renderAtomLogo(theme),
85
89
  '',
86
- ` ${theme.bold('KGraph')} ${theme.dim('repo intelligence refreshed')}`,
90
+ ` ${theme.bold('KGraph')} ${theme.dim('· repo intelligence refreshed')}`,
87
91
  '',
88
- theme.bold('Refresh Complete'),
92
+ sectionTitle(theme, `${accent} Refresh Complete`),
89
93
  command('files', String(stats.files) +
90
94
  (stats.skippedFiles
91
95
  ? ` (${stats.skippedFiles} unchanged, skipped)`
@@ -94,7 +98,7 @@ export function renderWorkflowBanner(stats, useColor = supportsColor()) {
94
98
  command('capture notes processed', String(stats.cognitionNotes)),
95
99
  command('integration modes', integrationLine),
96
100
  '',
97
- theme.bold('Next'),
101
+ sectionTitle(theme, `${accent} Next`),
98
102
  command('kgraph "auth token refresh"', 'Return compact context for a topic'),
99
103
  command('kgraph doctor', 'Check workspace health'),
100
104
  command('kgraph doctor --quality', 'Check atom quality'),
@@ -104,17 +108,27 @@ export function renderWorkflowBanner(stats, useColor = supportsColor()) {
104
108
  command('kgraph --help', 'Show all commands'),
105
109
  ].join('\n');
106
110
  }
107
- function renderLogo() {
108
- try {
109
- return figlet.textSync('KGraph', {
110
- font: 'ANSI Shadow',
111
- horizontalLayout: 'default',
112
- verticalLayout: 'default',
113
- });
114
- }
115
- catch {
116
- return 'KGraph';
117
- }
111
+ function renderAtomLogo(theme) {
112
+ const title = `${theme.hex('#38bdf8').bold('KGraph')} ${theme.dim('·')} ${theme.hex('#c084fc').bold('Atom Core')}`;
113
+ const atom = theme.hex('#22d3ee').bold('⚛');
114
+ const memory = theme.hex('#a78bfa')('persistent repo intelligence for AI coding tools');
115
+ return [
116
+ ` ${atom} ${theme.dim('atoms · evidence · context packs')}`,
117
+ ` ${title}`,
118
+ ` ${memory}`,
119
+ ].join('\n');
120
+ }
121
+ function renderSignalPanel(theme, rows) {
122
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
123
+ return rows
124
+ .map(([label, value]) => ` ${theme.hex('#22d3ee')('●')} ${theme.bold(label.padEnd(labelWidth))} ${theme.dim(value)}`)
125
+ .join('\n');
126
+ }
127
+ function sectionTitle(theme, title) {
128
+ return theme.bold(title);
129
+ }
130
+ function atomAccent(theme) {
131
+ return theme.hex('#22d3ee')('●');
118
132
  }
119
133
  function supportsColor() {
120
134
  return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
@@ -1,3 +1,3 @@
1
1
  import type { ContextResponse } from '../types/cognition.js';
2
2
  import type { ContextPack } from '../types/knowledge.js';
3
- export declare function buildContextPack(response: ContextResponse, budget: number): ContextPack;
3
+ export declare function buildContextPack(response: ContextResponse, budget: number, rootPath?: string): ContextPack;
@@ -1,5 +1,8 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { estimateTokens } from '../session/token-estimator.js';
2
- export function buildContextPack(response, budget) {
4
+ import { tokenize } from './ranking.js';
5
+ export function buildContextPack(response, budget, rootPath) {
3
6
  const candidates = [
4
7
  ...response.relevantFiles.map((ranked) => ({
5
8
  kind: 'file',
@@ -9,6 +12,7 @@ export function buildContextPack(response, budget) {
9
12
  reasons: ranked.reasons,
10
13
  data: ranked.item,
11
14
  })),
15
+ ...buildFileRangeCandidates(response, budget, rootPath),
12
16
  ...response.relevantSymbols.map((ranked) => ({
13
17
  kind: 'symbol',
14
18
  id: ranked.item.id,
@@ -76,15 +80,17 @@ function comparePackCandidates(left, right) {
76
80
  function packPriority(item) {
77
81
  let score = 0;
78
82
  if (item.kind === 'atom')
79
- score += 40;
83
+ score += 1000;
80
84
  if (item.kind === 'git-change')
81
- score += 35;
85
+ score += 900;
86
+ if (item.kind === 'file-range')
87
+ score += 800;
82
88
  if (item.kind === 'symbol')
83
- score += 25;
89
+ score += 300;
84
90
  if (item.kind === 'file')
85
- score += 15;
91
+ score += 200;
86
92
  if (item.kind === 'relationship')
87
- score += 5;
93
+ score += 100;
88
94
  if (item.reasons.some((reason) => reason.includes('matched atom')))
89
95
  score += 30;
90
96
  if (item.reasons.some((reason) => reason.includes('current git change')))
@@ -96,3 +102,104 @@ function packPriority(item) {
96
102
  score -= Math.floor(item.tokenEstimate / 2000);
97
103
  return score;
98
104
  }
105
+ const GENERIC_RANGE_TOKENS = new Set([
106
+ 'app',
107
+ 'code',
108
+ 'component',
109
+ 'file',
110
+ 'page',
111
+ 'repo',
112
+ 'work',
113
+ ]);
114
+ function buildFileRangeCandidates(response, budget, rootPath) {
115
+ if (!rootPath)
116
+ return [];
117
+ const queryTokens = tokenize(response.query).filter((token) => token.length >= 3 && !GENERIC_RANGE_TOKENS.has(token));
118
+ if (queryTokens.length === 0)
119
+ return [];
120
+ const maxRangeTokens = Math.max(250, Math.min(1200, Math.floor(budget / 3)));
121
+ const candidates = [];
122
+ for (const rankedFile of response.relevantFiles.slice(0, 8)) {
123
+ const file = rankedFile.item;
124
+ const fileTokens = file.tokenEstimate ?? 0;
125
+ if (fileTokens <= Math.max(1000, Math.floor(budget / 2)))
126
+ continue;
127
+ const fullPath = path.join(rootPath, file.path);
128
+ if (!existsSync(fullPath))
129
+ continue;
130
+ let content = '';
131
+ try {
132
+ content = readFileSync(fullPath, 'utf8');
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ const ranges = selectQueryRanges(content, queryTokens, maxRangeTokens, file.path);
138
+ for (const range of ranges) {
139
+ const lines = content.split(/\r?\n/).slice(range.start - 1, range.end);
140
+ const excerpt = lines.join('\n');
141
+ candidates.push({
142
+ kind: 'file-range',
143
+ id: `${file.path}:${range.start}-${range.end}`,
144
+ title: `${file.path}:${range.start}-${range.end}`,
145
+ tokenEstimate: estimateTokens(excerpt, file.path),
146
+ reasons: [
147
+ ...rankedFile.reasons,
148
+ `range selected from oversized file`,
149
+ `line text matched ${range.tokens.map((token) => `"${token}"`).join(', ')}`,
150
+ ],
151
+ data: {
152
+ path: file.path,
153
+ startLine: range.start,
154
+ endLine: range.end,
155
+ excerpt,
156
+ },
157
+ });
158
+ }
159
+ }
160
+ return candidates;
161
+ }
162
+ function selectQueryRanges(content, queryTokens, maxRangeTokens, filePath) {
163
+ const lines = content.split(/\r?\n/);
164
+ const hits = [];
165
+ for (const [index, line] of lines.entries()) {
166
+ const lower = line.toLowerCase();
167
+ const matched = queryTokens.filter((token) => lower.includes(token));
168
+ if (matched.length === 0)
169
+ continue;
170
+ hits.push({
171
+ start: Math.max(1, index + 1 - 8),
172
+ end: Math.min(lines.length, index + 1 + 8),
173
+ tokens: matched,
174
+ });
175
+ }
176
+ const ranges = mergeRanges(hits);
177
+ return ranges
178
+ .sort((left, right) => right.tokens.length - left.tokens.length)
179
+ .slice(0, 3)
180
+ .map((range) => trimRangeToBudget(range, lines, maxRangeTokens, filePath));
181
+ }
182
+ function mergeRanges(ranges) {
183
+ const merged = [];
184
+ for (const range of ranges.sort((left, right) => left.start - right.start)) {
185
+ const current = merged.at(-1);
186
+ if (!current || range.start > current.end + 3) {
187
+ merged.push({ ...range, tokens: [...new Set(range.tokens)] });
188
+ continue;
189
+ }
190
+ current.end = Math.max(current.end, range.end);
191
+ current.tokens = [...new Set([...current.tokens, ...range.tokens])];
192
+ }
193
+ return merged;
194
+ }
195
+ function trimRangeToBudget(range, lines, maxRangeTokens, filePath) {
196
+ let start = range.start;
197
+ let end = Math.min(range.end, start + 79);
198
+ while (end > start + 4) {
199
+ const excerpt = lines.slice(start - 1, end).join('\n');
200
+ if (estimateTokens(excerpt, filePath) <= maxRangeTokens)
201
+ break;
202
+ end -= 5;
203
+ }
204
+ return { ...range, start, end };
205
+ }
@@ -75,7 +75,7 @@ export interface KnowledgeValidationIssue {
75
75
  atomId?: string;
76
76
  }
77
77
  export interface ContextPackItem {
78
- kind: 'file' | 'symbol' | 'atom' | 'relationship' | 'git-change';
78
+ kind: 'file' | 'file-range' | 'symbol' | 'atom' | 'relationship' | 'git-change';
79
79
  id: string;
80
80
  title: string;
81
81
  tokenEstimate: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kentwynn/kgraph",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Persistent repo intelligence for AI coding assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,6 @@
47
47
  "chalk": "^5.6.2",
48
48
  "commander": "^12.1.0",
49
49
  "fast-glob": "^3.3.2",
50
- "figlet": "^1.11.0",
51
50
  "tree-sitter-c": "^0.24.1",
52
51
  "tree-sitter-c-sharp": "^0.23.5",
53
52
  "tree-sitter-cpp": "^0.23.4",