@kentwynn/kgraph 0.2.4 → 0.2.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.
package/README.md CHANGED
@@ -133,7 +133,7 @@ Agents can also report session activity so KGraph can estimate token waste:
133
133
  kgraph session start --agent codex
134
134
  kgraph session read src/auth.ts --agent codex
135
135
  kgraph session write src/auth.ts --agent codex
136
- kgraph session end --agent codex
136
+ kgraph session end --agent codex --conclude --topic "auth session work"
137
137
  kgraph session
138
138
  ```
139
139
 
@@ -204,10 +204,30 @@ kgraph session start --agent codex
204
204
  kgraph session read src/auth.ts --agent codex
205
205
  kgraph session write src/auth.ts --agent codex
206
206
  kgraph session end --agent codex
207
+ kgraph session end --agent codex --conclude --topic "auth token refresh"
207
208
  ```
208
209
 
209
210
  Track agent-reported read/write activity, repeated reads, and estimated token cost. Supported agents are `codex`, `claude-code`, `copilot`, `cursor`, `gemini`, `windsurf`, and `cline`.
210
- The text report now includes next actions, such as using `kgraph context "<topic>"` before repeated broad file inspection.
211
+ The text report now includes next actions, such as using `kgraph context "<topic>"` before repeated broad file inspection. Add `--conclude` to store a durable session summary with touched files attached as related cognition.
212
+
213
+ ```bash
214
+ kgraph conclude "auth refresh requires rotating the session cookie" \
215
+ --type gotcha \
216
+ --confidence high \
217
+ --domain auth \
218
+ --file src/auth.ts \
219
+ --symbol refreshSession \
220
+ --note "The refresh path must update both the access token and cookie expiry."
221
+ ```
222
+
223
+ Store durable engineering memory directly. Cognition is typed as `finding`, `decision`, `gotcha`, `summary`, or `relationship`, and confidence is `high`, `medium`, or `low`. Keep conclusions concise: preserve expensive-to-rediscover knowledge, not raw chain-of-thought, speculative exploration, or temporary reasoning.
224
+
225
+ ```bash
226
+ kgraph compact --dry-run
227
+ kgraph compact
228
+ ```
229
+
230
+ Merge duplicate cognition records and archive low-confidence stale entries. Compaction keeps memory inspectable under `.kgraph/cognition/` while reducing low-value noise in future context responses.
211
231
 
212
232
  ## Optional Step Commands
213
233
 
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerCompactCommand(program: Command): void;
@@ -0,0 +1,27 @@
1
+ import { compactCognition } from '../../cognition/compact.js';
2
+ import { assertWorkspace } from '../../storage/kgraph-paths.js';
3
+ import { runCommand } from '../errors.js';
4
+ export function registerCompactCommand(program) {
5
+ program
6
+ .command('compact')
7
+ .description('Merge duplicate cognition and archive low-value stale entries')
8
+ .option('--dry-run', 'Preview compaction without changing files')
9
+ .option('--json', 'Print JSON output')
10
+ .action((options) => runCommand(async () => {
11
+ const workspace = await assertWorkspace(process.cwd());
12
+ const result = await compactCognition(workspace, Boolean(options.dryRun));
13
+ if (options.json) {
14
+ console.log(JSON.stringify(result, null, 2));
15
+ return;
16
+ }
17
+ console.log(options.dryRun ? 'KGraph Compact Preview' : 'KGraph Compact Complete');
18
+ console.log(`Merged duplicate groups: ${result.merged.length}`);
19
+ console.log(`Archived stale low-confidence notes: ${result.archived.length}`);
20
+ for (const item of result.merged) {
21
+ console.log(`- merged ${item.sourceIds.length} notes into ${item.title}`);
22
+ }
23
+ for (const item of result.archived) {
24
+ console.log(`- archived ${item.title}: ${item.reason}`);
25
+ }
26
+ }));
27
+ }
@@ -0,0 +1,5 @@
1
+ import type { Command } from 'commander';
2
+ import type { CognitionConfidence, CognitionKind } from '../../types/cognition.js';
3
+ export declare function registerConcludeCommand(program: Command): void;
4
+ export declare function normalizeKind(value: string | undefined): CognitionKind;
5
+ export declare function normalizeConfidence(value: string | undefined): CognitionConfidence;
@@ -0,0 +1,57 @@
1
+ import { concludeTopic } from '../../cognition/conclusion.js';
2
+ import { assertWorkspace } from '../../storage/kgraph-paths.js';
3
+ import { KGraphError, runCommand } from '../errors.js';
4
+ export function registerConcludeCommand(program) {
5
+ program
6
+ .command('conclude <topic>')
7
+ .description('Store durable typed engineering cognition for this repo')
8
+ .option('--type <type>', 'finding, decision, gotcha, summary, or relationship', 'summary')
9
+ .option('--confidence <level>', 'high, medium, or low', 'medium')
10
+ .option('--domain <name>', 'Domain name for this cognition')
11
+ .option('--tag <tag>', 'Tag to attach; repeatable', collect, [])
12
+ .option('--file <path>', 'Related repo file; repeatable', collect, [])
13
+ .option('--symbol <name>', 'Related symbol; repeatable', collect, [])
14
+ .option('--note <text>', 'Concise durable conclusion text')
15
+ .option('--json', 'Print JSON output')
16
+ .action((topic, options) => runCommand(async () => {
17
+ const workspace = await assertWorkspace(process.cwd());
18
+ const note = await concludeTopic(workspace, {
19
+ topic,
20
+ body: options.note,
21
+ kind: normalizeKind(options.type),
22
+ confidence: normalizeConfidence(options.confidence),
23
+ domain: options.domain,
24
+ tags: options.tag ?? [],
25
+ relatedFiles: options.file ?? [],
26
+ relatedSymbols: options.symbol ?? [],
27
+ source: 'conclude',
28
+ });
29
+ if (options.json) {
30
+ console.log(JSON.stringify(note, null, 2));
31
+ return;
32
+ }
33
+ console.log(`Stored ${note.kind} cognition: ${note.title}`);
34
+ console.log(`Confidence: ${note.confidence}`);
35
+ console.log(`Status: ${note.referencesStatus}`);
36
+ }));
37
+ }
38
+ export function normalizeKind(value) {
39
+ if (value === 'finding' ||
40
+ value === 'decision' ||
41
+ value === 'gotcha' ||
42
+ value === 'summary' ||
43
+ value === 'relationship') {
44
+ return value;
45
+ }
46
+ throw new KGraphError('--type must be finding, decision, gotcha, summary, or relationship.');
47
+ }
48
+ export function normalizeConfidence(value) {
49
+ if (value === 'high' || value === 'medium' || value === 'low') {
50
+ return value;
51
+ }
52
+ throw new KGraphError('--confidence must be high, medium, or low.');
53
+ }
54
+ function collect(value, previous) {
55
+ previous.push(value);
56
+ return previous;
57
+ }
@@ -49,7 +49,7 @@ export function renderContextMarkdown(response) {
49
49
  return `- ${s.name} (${kindInfo}) in ${s.filePath}${lineRange} because ${formatReasons(item.reasons)}`;
50
50
  })));
51
51
  lines.push('', '## Relevant Cognition', '');
52
- lines.push(...formatList(response.relevantCognition.map((item) => `- ${item.item.title} [${item.item.referencesStatus}] because ${formatReasons(item.reasons)}`)));
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
53
  lines.push('', '## Relationships', '');
54
54
  lines.push(...formatGroupedRelationships(response.relationships, response.relationshipExplanations));
55
55
  lines.push('', '## Nearby Symbols (1-hop imports)', '');
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { analyzeCognitionQuality, } from '../../cognition/cognition-quality.js';
4
4
  import { loadConfig } from '../../config/config.js';
5
5
  import { listIntegrations } from '../../integrations/integration-store.js';
6
+ import { getCurrentCommit, isGitRepo } from '../../scanner/git-utils.js';
6
7
  import { assertWorkspace, pathExists, resolveWorkspace, } from '../../storage/kgraph-paths.js';
7
8
  import { mapPaths, mapsExist, readMaps } from '../../storage/map-store.js';
8
9
  import { runCommand } from '../errors.js';
@@ -37,7 +38,9 @@ export function registerDoctorCommand(program) {
37
38
  checks.push({
38
39
  label: 'maps',
39
40
  ok: mapStatus,
40
- detail: mapStatus ? 'structural maps are present' : 'run `kgraph scan` or just `kgraph`',
41
+ detail: mapStatus
42
+ ? 'structural maps are present'
43
+ : 'run `kgraph scan` or just `kgraph`',
41
44
  });
42
45
  const maps = mapStatus ? await readMaps(workspace) : undefined;
43
46
  if (maps) {
@@ -46,6 +49,19 @@ export function registerDoctorCommand(program) {
46
49
  ok: true,
47
50
  detail: `${maps.fileMap.files.length} files, ${maps.symbolMap.symbols.length} symbols, ${maps.dependencyMap.dependencies.length} dependencies`,
48
51
  });
52
+ // Detect whether the repo has advanced past the commit that was scanned
53
+ if (maps.fileMap.scannedAtCommit && (await isGitRepo(rootPath))) {
54
+ const headCommit = await getCurrentCommit(rootPath);
55
+ const stale = headCommit !== null &&
56
+ headCommit !== maps.fileMap.scannedAtCommit;
57
+ checks.push({
58
+ label: 'scan freshness',
59
+ ok: !stale,
60
+ detail: stale
61
+ ? `scanned at ${maps.fileMap.scannedAtCommit.slice(0, 7)}, HEAD is ${headCommit.slice(0, 7)} — run \`kgraph scan\``
62
+ : `maps current at ${maps.fileMap.scannedAtCommit.slice(0, 7)}`,
63
+ });
64
+ }
49
65
  }
50
66
  else {
51
67
  const paths = mapPaths(workspace);
@@ -125,6 +141,7 @@ function printChecks(checks) {
125
141
  export function printQualityReport(report) {
126
142
  console.log(`Notes: ${report.noteCount}`);
127
143
  console.log(`Mixed/stale/unresolved notes: ${report.mixedOrStaleCount}`);
144
+ console.log(`Orphaned notes (all refs dead): ${report.orphanedNoteCount}`);
128
145
  console.log(`Noisy file refs: ${report.noisyFileRefCount}`);
129
146
  console.log(`Noisy symbol refs: ${report.noisySymbolRefCount}`);
130
147
  console.log(`Unresolved local imports: ${report.unresolvedLocalImportCount}`);
@@ -152,6 +169,9 @@ export function printQualityReport(report) {
152
169
  }
153
170
  function summarizeQualityFindings(report) {
154
171
  const findings = [];
172
+ if (report.orphanedNoteCount > 0) {
173
+ findings.push(`${report.orphanedNoteCount} orphaned cognition note(s) (all refs dead); run \`kgraph repair\` to archive`);
174
+ }
155
175
  if (report.mixedOrStaleCount > 0) {
156
176
  findings.push(`${report.mixedOrStaleCount} stale/mixed/unresolved note(s)`);
157
177
  }
@@ -1,7 +1,9 @@
1
+ import { buildActiveSessionConclusion, concludeTopic, } from '../../cognition/conclusion.js';
1
2
  import { assertSessionAgent, buildSessionReport, recordSessionEvent, resetSession, } from '../../session/session-store.js';
2
3
  import { assertWorkspace } from '../../storage/kgraph-paths.js';
3
4
  import { readMaps } from '../../storage/map-store.js';
4
5
  import { KGraphError, runCommand } from '../errors.js';
6
+ import { normalizeConfidence, normalizeKind } from './conclude.js';
5
7
  export function registerSessionCommand(program) {
6
8
  const session = program
7
9
  .command('session')
@@ -62,14 +64,32 @@ export function registerSessionCommand(program) {
62
64
  .command('end')
63
65
  .requiredOption('--agent <name>', 'KGraph integration agent name')
64
66
  .option('--source <source>', 'automatic, agent-reported, or manual', 'manual')
67
+ .option('--conclude', 'Store a durable typed summary for this session')
68
+ .option('--topic <topic>', 'Conclusion topic when using --conclude')
69
+ .option('--type <type>', 'finding, decision, gotcha, summary, or relationship', 'summary')
70
+ .option('--confidence <level>', 'high, medium, or low', 'medium')
71
+ .option('--note <text>', 'Concise durable conclusion text')
65
72
  .action((options) => runCommand(async () => {
66
73
  const workspace = await assertWorkspace(process.cwd());
74
+ let pendingConclusion;
75
+ if (options.conclude) {
76
+ pendingConclusion = await buildActiveSessionConclusion(workspace, requireAgent(options.agent), {
77
+ topic: options.topic ?? `${options.agent} session summary`,
78
+ body: options.note,
79
+ kind: normalizeKind(options.type),
80
+ confidence: normalizeConfidence(options.confidence),
81
+ });
82
+ }
67
83
  const event = await recordSessionEvent(workspace, {
68
84
  agent: requireAgent(options.agent),
69
85
  type: 'end',
70
86
  captureSource: normalizeSource(options.source),
71
87
  });
72
88
  console.log(`KGraph session ended for ${event.agent}.`);
89
+ if (pendingConclusion) {
90
+ const note = await concludeTopic(workspace, pendingConclusion);
91
+ console.log(`Stored session cognition: ${note.title}`);
92
+ }
73
93
  }));
74
94
  session
75
95
  .command('reset')
package/dist/cli/help.js CHANGED
@@ -29,6 +29,9 @@ export function renderRootHelp(useColor = supportsColor()) {
29
29
  command('scan', 'Optional: refresh only file, symbol, import, and relationship maps'),
30
30
  command('session', 'Show agent read/write activity and token estimates'),
31
31
  command('session read src/auth.ts --agent codex', 'Record an agent file read'),
32
+ command('session end --agent codex --conclude', 'End tracking and store a durable session summary'),
33
+ command('conclude "auth refresh gotcha"', 'Store typed engineering cognition'),
34
+ command('compact', 'Merge duplicate cognition and archive stale noise'),
32
35
  command('context "auth token refresh"', 'Optional: return context without scanning or updating'),
33
36
  command('impact "Button"', 'Show imports, callers, calls, cognition, and risk'),
34
37
  command('update', 'Optional: process only .kgraph/inbox Markdown cognition notes'),
package/dist/cli/index.js CHANGED
@@ -3,6 +3,8 @@ import { Command } from 'commander';
3
3
  import { realpathSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { registerCompactCommand } from './commands/compact.js';
7
+ import { registerConcludeCommand } from './commands/conclude.js';
6
8
  import { registerContextCommand } from './commands/context.js';
7
9
  import { registerDoctorCommand } from './commands/doctor.js';
8
10
  import { registerHistoryCommand } from './commands/history.js';
@@ -40,6 +42,8 @@ export function createProgram() {
40
42
  registerInitCommand(program);
41
43
  registerScanCommand(program);
42
44
  registerSessionCommand(program);
45
+ registerConcludeCommand(program);
46
+ registerCompactCommand(program);
43
47
  registerUpdateCommand(program);
44
48
  registerContextCommand(program);
45
49
  registerImpactCommand(program);
@@ -1,5 +1,5 @@
1
- import type { KGraphWorkspace } from '../types/config.js';
2
1
  import type { ReferenceStatus } from '../types/cognition.js';
2
+ import type { KGraphWorkspace } from '../types/config.js';
3
3
  import type { DependencyMap, FileMap, RelationshipMap, SymbolMap } from '../types/maps.js';
4
4
  export interface CognitionRepairChange {
5
5
  noteId: string;
@@ -21,6 +21,7 @@ export interface CognitionQualityReport {
21
21
  sessionRepeatedReadCount: number;
22
22
  sessionEstimatedReadTokens: number;
23
23
  sessionEstimatedRepeatedReadTokens: number;
24
+ orphanedNoteCount: number;
24
25
  changes: CognitionRepairChange[];
25
26
  }
26
27
  export declare function analyzeCognitionQuality(workspace: KGraphWorkspace, maps: {
@@ -1,5 +1,7 @@
1
- import { overwriteDomainRecord, readCognitionNotes, readDomainRecords, writeCognitionNote, } from '../storage/cognition-store.js';
1
+ import { mkdir, rename } from 'node:fs/promises';
2
+ import path from 'node:path';
2
3
  import { buildSessionReport } from '../session/session-store.js';
4
+ import { overwriteDomainRecord, readCognitionNotes, readDomainRecords, writeCognitionNote, } from '../storage/cognition-store.js';
3
5
  export async function analyzeCognitionQuality(workspace, maps) {
4
6
  const notes = await readCognitionNotes(workspace);
5
7
  const session = await buildSessionReport(workspace);
@@ -7,6 +9,7 @@ export async function analyzeCognitionQuality(workspace, maps) {
7
9
  .map((note) => analyzeNote(note, maps))
8
10
  .filter((change) => change.removedFileRefs.length > 0 ||
9
11
  change.removedSymbolRefs.length > 0);
12
+ const orphanedNoteCount = notes.filter((note) => note.referencesStatus === 'stale').length;
10
13
  return {
11
14
  noteCount: notes.length,
12
15
  mixedOrStaleCount: notes.filter((note) => ['mixed', 'stale', 'unresolved'].includes(note.referencesStatus)).length,
@@ -20,6 +23,7 @@ export async function analyzeCognitionQuality(workspace, maps) {
20
23
  sessionRepeatedReadCount: session.repeatedReadCount,
21
24
  sessionEstimatedReadTokens: session.estimatedReadTokens,
22
25
  sessionEstimatedRepeatedReadTokens: session.estimatedRepeatedReadTokens,
26
+ orphanedNoteCount,
23
27
  changes,
24
28
  };
25
29
  }
@@ -40,9 +44,18 @@ export async function repairCognition(workspace, maps, dryRun = false) {
40
44
  }
41
45
  }
42
46
  }
43
- if (!dryRun && changes.length > 0) {
44
- await repairDomainRecords(workspace, nextNotes, maps);
47
+ // Archive fully-orphaned notes (all refs dead) so they no longer appear in context
48
+ const orphanedNotes = nextNotes.filter((note) => note.referencesStatus === 'stale');
49
+ if (!dryRun && (changes.length > 0 || orphanedNotes.length > 0)) {
50
+ // Exclude fully-orphaned notes from domain records — they are being archived
51
+ await repairDomainRecords(workspace, nextNotes.filter((n) => n.referencesStatus !== 'stale'), maps);
52
+ }
53
+ if (!dryRun) {
54
+ for (const note of orphanedNotes) {
55
+ await archiveOrphanedNote(workspace, note);
56
+ }
45
57
  }
58
+ const orphanedNoteCount = orphanedNotes.length;
46
59
  return {
47
60
  noteCount: notes.length,
48
61
  mixedOrStaleCount: nextNotes.filter((note) => ['mixed', 'stale', 'unresolved'].includes(note.referencesStatus)).length,
@@ -56,6 +69,7 @@ export async function repairCognition(workspace, maps, dryRun = false) {
56
69
  sessionRepeatedReadCount: session.repeatedReadCount,
57
70
  sessionEstimatedReadTokens: session.estimatedReadTokens,
58
71
  sessionEstimatedRepeatedReadTokens: session.estimatedRepeatedReadTokens,
72
+ orphanedNoteCount,
59
73
  changes,
60
74
  };
61
75
  }
@@ -98,7 +112,8 @@ function countGeneratedScannedFiles(fileMap) {
98
112
  ].some((prefix) => file.path === prefix || file.path.startsWith(prefix))).length;
99
113
  }
100
114
  function countExpensiveFiles(fileMap) {
101
- return fileMap.files.filter((file) => (file.tokenEstimate ?? 0) >= 1000).length;
115
+ return fileMap.files.filter((file) => (file.tokenEstimate ?? 0) >= 1000)
116
+ .length;
102
117
  }
103
118
  function analyzeNote(note, maps) {
104
119
  const filePaths = new Set(maps.fileMap.files.map((file) => file.path));
@@ -161,11 +176,27 @@ function evaluateReferenceStatus(relatedFiles, relatedSymbols, maps) {
161
176
  return 'mixed';
162
177
  }
163
178
  function isNoisyFileRef(ref) {
164
- return !ref.includes('/') && /^[A-Z][A-Za-z0-9_-]*\.[A-Za-z0-9_-]+$/.test(ref);
179
+ return (!ref.includes('/') && /^[A-Z][A-Za-z0-9_-]*\.[A-Za-z0-9_-]+$/.test(ref));
165
180
  }
166
181
  function isNoisySymbolRef(ref) {
167
- return /^[a-z][A-Za-z0-9_$]*$/.test(ref);
182
+ // Only treat as noise if the ref is a short all-lowercase word (no camelCase, no _ or $).
183
+ // Preserve camelCase refs even when unresolved — the symbol may have been renamed.
184
+ if (/[A-Z_$]/.test(ref))
185
+ return false;
186
+ return ref.length <= 5;
168
187
  }
169
188
  function unique(items) {
170
189
  return [...new Set(items)];
171
190
  }
191
+ async function archiveOrphanedNote(workspace, note) {
192
+ const archivedDir = path.join(workspace.cognitionPath, 'archived');
193
+ await mkdir(archivedDir, { recursive: true });
194
+ const source = path.join(workspace.cognitionPath, `${note.id}.md`);
195
+ const target = path.join(archivedDir, `${note.id}.md`);
196
+ try {
197
+ await rename(source, target);
198
+ }
199
+ catch {
200
+ // source file may already be missing — ignore
201
+ }
202
+ }
@@ -11,7 +11,11 @@ export async function updateCognition(workspace, currentMaps, dryRun = false) {
11
11
  const raw = await readFile(inboxPath, 'utf8');
12
12
  const parsed = parseMarkdownNote(raw);
13
13
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
14
- const id = `${timestamp}-${slugify(parsed.title) || path.basename(inboxPath, '.md')}`;
14
+ // Always include the inbox basename as a per-note unique suffix so two notes with
15
+ // the same title processed in the same millisecond never receive the same ID.
16
+ const base = path.basename(inboxPath, '.md');
17
+ const slug = slugify(parsed.title);
18
+ const id = slug ? `${timestamp}-${slug}-${base}` : `${timestamp}-${base}`;
15
19
  const archivedPath = path.join(workspace.processedInteractionsPath, `${timestamp}-${path.basename(inboxPath)}`);
16
20
  const note = {
17
21
  ...parsed,
@@ -25,6 +29,7 @@ export async function updateCognition(workspace, currentMaps, dryRun = false) {
25
29
  .split(path.sep)
26
30
  .join('/'),
27
31
  createdAt: new Date().toISOString(),
32
+ source: 'inbox',
28
33
  referencesStatus: evaluateReferenceStatus(parsed.relatedFiles, parsed.relatedSymbols, currentMaps),
29
34
  };
30
35
  processed.push(note);
@@ -39,6 +44,11 @@ export async function updateCognition(workspace, currentMaps, dryRun = false) {
39
44
  warnings.push(`${path.basename(inboxPath)}: ${error instanceof Error ? error.message : String(error)}`);
40
45
  }
41
46
  }
47
+ // Refresh reference statuses on all existing notes so that notes which
48
+ // became stale since the last scan reflect the current map state.
49
+ if (!dryRun) {
50
+ await refreshCognitionReferenceStatuses(workspace, currentMaps);
51
+ }
42
52
  return { processed, warnings };
43
53
  }
44
54
  export async function refreshCognitionReferenceStatuses(workspace, currentMaps) {
@@ -64,6 +74,7 @@ export async function refreshCognitionReferenceStatuses(workspace, currentMaps)
64
74
  await writeCognitionNote(workspace, {
65
75
  ...note,
66
76
  relatedSymbols,
77
+ updatedAt: new Date().toISOString(),
67
78
  referencesStatus: nextStatus,
68
79
  });
69
80
  }
@@ -0,0 +1,14 @@
1
+ import type { KGraphWorkspace } from '../types/config.js';
2
+ export interface CompactResult {
3
+ merged: Array<{
4
+ targetId: string;
5
+ sourceIds: string[];
6
+ title: string;
7
+ }>;
8
+ archived: Array<{
9
+ id: string;
10
+ title: string;
11
+ reason: string;
12
+ }>;
13
+ }
14
+ export declare function compactCognition(workspace: KGraphWorkspace, dryRun?: boolean): Promise<CompactResult>;
@@ -0,0 +1,167 @@
1
+ import { mkdir, rename } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { overwriteDomainRecord, readCognitionNotes, readDomainRecords, slugify, writeCognitionNote, } from '../storage/cognition-store.js';
4
+ import { pathExists } from '../storage/kgraph-paths.js';
5
+ import { readMaps } from '../storage/map-store.js';
6
+ import { evaluateReferenceStatus } from './cognition-updater.js';
7
+ export async function compactCognition(workspace, dryRun = false) {
8
+ const notes = await readCognitionNotes(workspace);
9
+ const maps = await readMaps(workspace);
10
+ const groups = groupDuplicates(notes);
11
+ const result = { merged: [], archived: [] };
12
+ const consumed = new Set();
13
+ const archived = new Set();
14
+ const mergedNotes = [];
15
+ for (const group of groups.filter((items) => items.length > 1)) {
16
+ const merged = mergeNotes(group, {
17
+ files: maps.fileMap.files,
18
+ symbols: maps.symbolMap.symbols,
19
+ });
20
+ const sourceIds = group.map((note) => note.id);
21
+ result.merged.push({
22
+ targetId: merged.id,
23
+ sourceIds,
24
+ title: merged.title,
25
+ });
26
+ sourceIds.forEach((id) => consumed.add(id));
27
+ mergedNotes.push(merged);
28
+ if (!dryRun) {
29
+ await writeCognitionNote(workspace, merged);
30
+ for (const note of group) {
31
+ await archiveNote(workspace, note, `superseded-by-${merged.id}`);
32
+ }
33
+ }
34
+ }
35
+ for (const note of notes) {
36
+ if (consumed.has(note.id))
37
+ continue;
38
+ if (note.confidence === 'low' &&
39
+ (note.referencesStatus === 'stale' || note.referencesStatus === 'unresolved')) {
40
+ result.archived.push({
41
+ id: note.id,
42
+ title: note.title,
43
+ reason: 'low-confidence stale cognition',
44
+ });
45
+ archived.add(note.id);
46
+ if (!dryRun) {
47
+ await archiveNote(workspace, note, 'low-confidence-stale');
48
+ }
49
+ }
50
+ }
51
+ if (!dryRun && (consumed.size > 0 || archived.size > 0)) {
52
+ const activeNotes = [
53
+ ...notes.filter((note) => !consumed.has(note.id) && !archived.has(note.id)),
54
+ ...mergedNotes,
55
+ ];
56
+ await rebuildDomainRecords(workspace, activeNotes, {
57
+ files: maps.fileMap.files,
58
+ symbols: maps.symbolMap.symbols,
59
+ });
60
+ }
61
+ return result;
62
+ }
63
+ function groupDuplicates(notes) {
64
+ const byKey = new Map();
65
+ for (const note of notes) {
66
+ if (note.supersededBy)
67
+ continue;
68
+ const key = [
69
+ note.kind ?? 'summary',
70
+ note.domain ?? 'general',
71
+ normalizeTitle(note.title),
72
+ normalizeText(note.summary ?? ''),
73
+ stableList(note.relatedFiles),
74
+ stableList(note.relatedSymbols),
75
+ ].join('\0');
76
+ const group = byKey.get(key) ?? [];
77
+ group.push(note);
78
+ byKey.set(key, group);
79
+ }
80
+ return [...byKey.values()];
81
+ }
82
+ function mergeNotes(notes, currentMaps) {
83
+ const sorted = [...notes].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
84
+ const base = sorted[sorted.length - 1];
85
+ const now = new Date().toISOString();
86
+ const id = `${now.replace(/[:.]/g, '-')}-${slugify(base.title) || 'compacted'}`;
87
+ const summaries = unique(sorted
88
+ .map((note) => note.summary)
89
+ .filter((summary) => Boolean(summary?.trim())));
90
+ const relatedFiles = unique(sorted.flatMap((note) => note.relatedFiles));
91
+ const relatedSymbols = unique(sorted.flatMap((note) => note.relatedSymbols));
92
+ return {
93
+ ...base,
94
+ id,
95
+ source: 'compact',
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ supersedes: sorted.map((note) => note.id),
99
+ supersededBy: undefined,
100
+ confidence: highestConfidence(sorted.map((note) => note.confidence)),
101
+ relatedFiles,
102
+ relatedSymbols,
103
+ tags: unique(sorted.flatMap((note) => note.tags)),
104
+ summary: summaries[0] ?? base.summary,
105
+ sections: {
106
+ Summary: summaries.map((summary) => `- ${summary}`).join('\n'),
107
+ 'Compacted From': sorted.map((note) => `- ${note.id}`).join('\n'),
108
+ },
109
+ warnings: unique(sorted.flatMap((note) => note.warnings)),
110
+ referencesStatus: evaluateReferenceStatus(relatedFiles, relatedSymbols, currentMaps),
111
+ };
112
+ }
113
+ async function rebuildDomainRecords(workspace, notes, currentMaps) {
114
+ const existingDomains = await readDomainRecords(workspace);
115
+ const existingByName = new Map(existingDomains.map((domain) => [domain.name, domain]));
116
+ const domainNames = new Set([
117
+ ...existingDomains.map((domain) => domain.name),
118
+ ...notes.map((note) => note.domain ?? 'general'),
119
+ ]);
120
+ const fileSet = new Set(currentMaps.files.map((file) => file.path));
121
+ const symbolSet = new Set(currentMaps.symbols.map((symbol) => symbol.name));
122
+ for (const name of domainNames) {
123
+ const relatedNotes = notes.filter((note) => (note.domain ?? 'general') === name);
124
+ const existing = existingByName.get(name);
125
+ const next = {
126
+ name,
127
+ description: existing?.description,
128
+ pathHints: unique(relatedNotes.flatMap((note) => note.relatedFiles)),
129
+ tags: unique(relatedNotes.flatMap((note) => note.tags)),
130
+ files: unique(relatedNotes
131
+ .flatMap((note) => note.relatedFiles)
132
+ .filter((file) => fileSet.has(file))),
133
+ symbols: unique(relatedNotes
134
+ .flatMap((note) => note.relatedSymbols)
135
+ .filter((symbol) => symbolSet.has(symbol))),
136
+ cognitionNotes: relatedNotes.map((note) => note.id),
137
+ };
138
+ await overwriteDomainRecord(workspace, next);
139
+ }
140
+ }
141
+ async function archiveNote(workspace, note, reason) {
142
+ const source = path.join(workspace.cognitionPath, `${note.id}.md`);
143
+ if (!(await pathExists(source)))
144
+ return;
145
+ const archivedDir = path.join(workspace.cognitionPath, 'archived');
146
+ await mkdir(archivedDir, { recursive: true });
147
+ await rename(source, path.join(archivedDir, `${reason}-${note.id}.md`));
148
+ }
149
+ function highestConfidence(values) {
150
+ if (values.includes('high'))
151
+ return 'high';
152
+ if (values.includes('medium'))
153
+ return 'medium';
154
+ return 'low';
155
+ }
156
+ function normalizeTitle(value) {
157
+ return normalizeText(value);
158
+ }
159
+ function normalizeText(value) {
160
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
161
+ }
162
+ function stableList(values) {
163
+ return [...values].sort().join('\0');
164
+ }
165
+ function unique(items) {
166
+ return [...new Set(items)];
167
+ }
@@ -0,0 +1,16 @@
1
+ import type { CognitionConfidence, CognitionKind, CognitionNote, CognitionSource } from '../types/cognition.js';
2
+ import type { KGraphWorkspace } from '../types/config.js';
3
+ export interface ConclusionInput {
4
+ topic: string;
5
+ body?: string;
6
+ kind?: CognitionKind;
7
+ confidence?: CognitionConfidence;
8
+ domain?: string;
9
+ tags?: string[];
10
+ relatedFiles?: string[];
11
+ relatedSymbols?: string[];
12
+ source: CognitionSource;
13
+ }
14
+ export declare function concludeTopic(workspace: KGraphWorkspace, input: ConclusionInput): Promise<CognitionNote>;
15
+ export declare function concludeActiveSession(workspace: KGraphWorkspace, agent: string, input: Omit<ConclusionInput, 'source' | 'relatedFiles' | 'relatedSymbols'>): Promise<CognitionNote>;
16
+ export declare function buildActiveSessionConclusion(workspace: KGraphWorkspace, agent: string, input: Omit<ConclusionInput, 'source' | 'relatedFiles' | 'relatedSymbols'>): Promise<ConclusionInput>;
@@ -0,0 +1,96 @@
1
+ import { readMaps } from '../storage/map-store.js';
2
+ import { slugify, writeCognitionNote, writeDomainRecord } from '../storage/cognition-store.js';
3
+ import { KGraphError } from '../cli/errors.js';
4
+ import { evaluateReferenceStatus } from './cognition-updater.js';
5
+ import { readSessionState } from '../session/session-store.js';
6
+ export async function concludeTopic(workspace, input) {
7
+ const maps = await readMaps(workspace);
8
+ const now = new Date().toISOString();
9
+ const timestamp = now.replace(/[:.]/g, '-');
10
+ const title = input.topic.trim();
11
+ const summary = normalizeBody(input.body) ?? title;
12
+ const note = {
13
+ title,
14
+ kind: input.kind ?? 'summary',
15
+ confidence: input.confidence ?? 'medium',
16
+ domain: input.domain,
17
+ tags: input.tags ?? [],
18
+ summary,
19
+ sections: {
20
+ Summary: summary,
21
+ ...(input.relatedFiles?.length ? { 'Related Files': input.relatedFiles.map((file) => `- ${file}`).join('\n') } : {}),
22
+ ...(input.relatedSymbols?.length ? { 'Key Symbols': input.relatedSymbols.map((symbol) => `- \`${symbol}\``).join('\n') } : {}),
23
+ },
24
+ relatedFiles: input.relatedFiles ?? [],
25
+ relatedSymbols: input.relatedSymbols ?? [],
26
+ warnings: [],
27
+ id: `${timestamp}-${slugify(title) || 'conclusion'}`,
28
+ sourceInboxPath: '',
29
+ processedPath: `.kgraph/cognition/${timestamp}-${slugify(title) || 'conclusion'}.md`,
30
+ createdAt: now,
31
+ source: input.source,
32
+ referencesStatus: evaluateReferenceStatus(input.relatedFiles ?? [], input.relatedSymbols ?? [], { files: maps.fileMap.files, symbols: maps.symbolMap.symbols }),
33
+ };
34
+ await writeCognitionNote(workspace, note);
35
+ await writeDomainRecord(workspace, toDomainRecord(note, {
36
+ files: maps.fileMap.files,
37
+ symbols: maps.symbolMap.symbols,
38
+ }));
39
+ return note;
40
+ }
41
+ export async function concludeActiveSession(workspace, agent, input) {
42
+ return concludeTopic(workspace, await buildActiveSessionConclusion(workspace, agent, input));
43
+ }
44
+ export async function buildActiveSessionConclusion(workspace, agent, input) {
45
+ const state = await readSessionState(workspace);
46
+ const active = state.active[agent];
47
+ if (!active) {
48
+ throw new KGraphError(`No active session for agent "${agent}".`);
49
+ }
50
+ const events = state.events.filter((event) => event.agent === agent && event.timestamp >= active.startedAt);
51
+ const touchedFiles = [
52
+ ...new Set(events
53
+ .filter((event) => event.type === 'read' || event.type === 'write')
54
+ .map((event) => event.path)
55
+ .filter((file) => Boolean(file))),
56
+ ];
57
+ const writtenFiles = [
58
+ ...new Set(events
59
+ .filter((event) => event.type === 'write')
60
+ .map((event) => event.path)
61
+ .filter((file) => Boolean(file))),
62
+ ];
63
+ const body = [
64
+ normalizeBody(input.body) ?? `Session concluded for ${input.topic}.`,
65
+ touchedFiles.length
66
+ ? `Touched files: ${touchedFiles.join(', ')}.`
67
+ : undefined,
68
+ writtenFiles.length
69
+ ? `Changed files: ${writtenFiles.join(', ')}.`
70
+ : undefined,
71
+ ]
72
+ .filter(Boolean)
73
+ .join('\n\n');
74
+ return {
75
+ ...input,
76
+ body,
77
+ source: 'session-conclude',
78
+ relatedFiles: touchedFiles,
79
+ };
80
+ }
81
+ function normalizeBody(value) {
82
+ const trimmed = value?.trim();
83
+ return trimmed ? trimmed : undefined;
84
+ }
85
+ function toDomainRecord(note, currentMaps) {
86
+ const fileSet = new Set(currentMaps.files.map((file) => file.path));
87
+ const symbolSet = new Set(currentMaps.symbols.map((symbol) => symbol.name));
88
+ return {
89
+ name: note.domain ?? 'general',
90
+ pathHints: note.relatedFiles,
91
+ tags: note.tags,
92
+ files: note.relatedFiles.filter((file) => fileSet.has(file)),
93
+ symbols: note.relatedSymbols.filter((symbol) => symbolSet.has(symbol)),
94
+ cognitionNotes: [note.id],
95
+ };
96
+ }
@@ -10,15 +10,39 @@ export function parseMarkdownNote(markdown) {
10
10
  const combined = Object.values(sections).join('\n');
11
11
  return {
12
12
  title,
13
+ kind: normalizeKind(frontmatter.type ?? frontmatter.kind, warnings),
14
+ confidence: normalizeConfidence(frontmatter.confidence, warnings),
13
15
  domain: typeof frontmatter.domain === 'string' ? frontmatter.domain : undefined,
14
16
  tags: Array.isArray(frontmatter.tags) ? frontmatter.tags.map(String) : [],
15
17
  summary: sections.Summary,
16
18
  sections,
17
- relatedFiles: unique(extractMatches(combined, PATH_REF)),
19
+ relatedFiles: unique(extractMatches(stripCodeFences(combined), PATH_REF)),
18
20
  relatedSymbols: unique(extractSymbolRefs(sections)),
19
21
  warnings,
20
22
  };
21
23
  }
24
+ function normalizeKind(value, warnings) {
25
+ if (value === 'finding' ||
26
+ value === 'decision' ||
27
+ value === 'gotcha' ||
28
+ value === 'summary' ||
29
+ value === 'relationship') {
30
+ return value;
31
+ }
32
+ if (value !== undefined) {
33
+ warnings.push(`Unsupported cognition type "${String(value)}"; defaulted to summary.`);
34
+ }
35
+ return 'summary';
36
+ }
37
+ function normalizeConfidence(value, warnings) {
38
+ if (value === 'high' || value === 'medium' || value === 'low') {
39
+ return value;
40
+ }
41
+ if (value !== undefined) {
42
+ warnings.push(`Unsupported confidence "${String(value)}"; defaulted to medium.`);
43
+ }
44
+ return 'medium';
45
+ }
22
46
  function splitFrontmatter(markdown, warnings) {
23
47
  if (!markdown.startsWith('---\n')) {
24
48
  return { frontmatter: {}, body: markdown };
@@ -70,3 +94,8 @@ function extractSymbolRefs(sections) {
70
94
  function unique(items) {
71
95
  return [...new Set(items)];
72
96
  }
97
+ function stripCodeFences(text) {
98
+ // Remove triple-backtick code blocks so paths inside code examples are not
99
+ // mistaken for real file references, which would create phantom stale refs.
100
+ return text.replace(/```[\s\S]*?```/g, '');
101
+ }
@@ -1,14 +1,30 @@
1
1
  import { getRecentlyCommittedFiles, getWorkingTreeChangesDetailed, isGitRepo, } from '../scanner/git-utils.js';
2
2
  import { readCognitionNotes, readDomainRecords, } from '../storage/cognition-store.js';
3
+ import { readSessionState } from '../session/session-store.js';
3
4
  import { rankByFields } from './ranking.js';
4
5
  export async function queryContext(workspace, config, maps, query) {
5
6
  const cognition = await readCognitionNotes(workspace);
6
7
  const domains = await readDomainRecords(workspace);
8
+ const session = await readSessionState(workspace);
9
+ const sessionTouchedPaths = new Set(session.events
10
+ .map((event) => event.path)
11
+ .filter((path) => Boolean(path)));
7
12
  const max = config.maxContextItems;
8
- const relevantFiles = rankByFields(query, maps.fileMap.files, [
13
+ let relevantFiles = rankByFields(query, maps.fileMap.files, [
9
14
  { name: 'path', value: (file) => file.path },
10
15
  { name: 'language', value: (file) => file.language },
11
- ]).slice(0, max);
16
+ ])
17
+ .map((ranked) => ({
18
+ ...ranked,
19
+ score: ranked.score -
20
+ Math.floor((ranked.item.tokenEstimate ?? 0) / 2000) +
21
+ (sessionTouchedPaths.has(ranked.item.path) ? 3 : 0),
22
+ reasons: sessionTouchedPaths.has(ranked.item.path)
23
+ ? [...ranked.reasons, 'touched in current session']
24
+ : ranked.reasons,
25
+ }))
26
+ .sort((a, b) => b.score - a.score)
27
+ .slice(0, max);
12
28
  const relevantSymbols = rankByFields(query, maps.symbolMap.symbols, [
13
29
  { name: 'name', value: (symbol) => symbol.name },
14
30
  { name: 'path', value: (symbol) => symbol.filePath },
@@ -17,17 +33,73 @@ export async function queryContext(workspace, config, maps, query) {
17
33
  ]).slice(0, max);
18
34
  const relevantCognition = rankByFields(query, cognition, [
19
35
  { name: 'title', value: (note) => note.title },
36
+ { name: 'type', value: (note) => note.kind },
37
+ { name: 'confidence', value: (note) => note.confidence },
20
38
  { name: 'domain', value: (note) => note.domain },
21
39
  { name: 'tags', value: (note) => note.tags },
22
40
  { name: 'files', value: (note) => note.relatedFiles },
23
41
  { name: 'symbols', value: (note) => note.relatedSymbols },
24
42
  { name: 'summary', value: (note) => note.summary },
25
- ]).slice(0, max);
43
+ ])
44
+ .map((ranked) => applyCognitionRankAdjustments(ranked))
45
+ .sort((a, b) => b.score - a.score)
46
+ .slice(0, max);
26
47
  const matchedDomains = rankByFields(query, domains, [
27
48
  { name: 'name', value: (domain) => domain.name },
28
49
  { name: 'tags', value: (domain) => domain.tags },
29
50
  { name: 'path', value: (domain) => domain.pathHints },
30
51
  ]).slice(0, max);
52
+ // Inject files linked by matched cognition notes/domains that didn't score on name alone
53
+ const rankedFilePaths = new Set(relevantFiles.map((f) => f.item.path));
54
+ const cognitionLinkedMap = new Map();
55
+ for (const ranked of relevantCognition) {
56
+ for (const fp of ranked.item.relatedFiles) {
57
+ if (!rankedFilePaths.has(fp)) {
58
+ const reasons = cognitionLinkedMap.get(fp) ?? [];
59
+ reasons.push(`linked by cognition note "${ranked.item.title}"`);
60
+ cognitionLinkedMap.set(fp, reasons);
61
+ }
62
+ }
63
+ }
64
+ for (const ranked of matchedDomains) {
65
+ for (const fp of ranked.item.files) {
66
+ if (!rankedFilePaths.has(fp)) {
67
+ const reasons = cognitionLinkedMap.get(fp) ?? [];
68
+ reasons.push(`in domain "${ranked.item.name}"`);
69
+ cognitionLinkedMap.set(fp, reasons);
70
+ }
71
+ }
72
+ }
73
+ // Apply domainHints from config: inject paths for hints whose name matches the query
74
+ const queryTokens = new Set(query
75
+ .toLowerCase()
76
+ .split(/[^a-z0-9]+/)
77
+ .filter(Boolean));
78
+ for (const [hintName, hint] of Object.entries(config.domainHints)) {
79
+ const hintWords = hintName
80
+ .toLowerCase()
81
+ .split(/[^a-z0-9]+/)
82
+ .filter(Boolean);
83
+ if (!hintWords.some((w) => queryTokens.has(w)))
84
+ continue;
85
+ for (const fp of hint.paths ?? []) {
86
+ if (!rankedFilePaths.has(fp)) {
87
+ const reasons = cognitionLinkedMap.get(fp) ?? [];
88
+ reasons.push(`in configured domain hint "${hintName}"`);
89
+ cognitionLinkedMap.set(fp, reasons);
90
+ }
91
+ }
92
+ }
93
+ relevantFiles = [
94
+ ...relevantFiles,
95
+ ...maps.fileMap.files
96
+ .filter((f) => cognitionLinkedMap.has(f.path))
97
+ .map((f) => ({
98
+ item: f,
99
+ score: 1,
100
+ reasons: cognitionLinkedMap.get(f.path),
101
+ })),
102
+ ];
31
103
  const relatedIds = new Set([
32
104
  ...relevantFiles.map((file) => file.item.path),
33
105
  ...relevantSymbols.map((symbol) => symbol.item.id),
@@ -69,10 +141,12 @@ export async function queryContext(workspace, config, maps, query) {
69
141
  });
70
142
  const filePaths = new Set(maps.fileMap.files.map((f) => f.path));
71
143
  const symbolNames = new Set(maps.symbolMap.symbols.map((s) => s.name));
144
+ const matchedCognitionIds = new Set(relevantCognition.map((r) => r.item.id));
72
145
  const staleReferences = cognition
73
- .filter((note) => note.referencesStatus === 'stale' ||
74
- note.referencesStatus === 'unresolved' ||
75
- note.referencesStatus === 'mixed')
146
+ .filter((note) => matchedCognitionIds.has(note.id) &&
147
+ (note.referencesStatus === 'stale' ||
148
+ note.referencesStatus === 'unresolved' ||
149
+ note.referencesStatus === 'mixed'))
76
150
  .flatMap((note) => [
77
151
  ...note.relatedFiles
78
152
  .filter((f) => !filePaths.has(f))
@@ -98,9 +172,18 @@ export async function queryContext(workspace, config, maps, query) {
98
172
  // Remove files already in the matched set
99
173
  for (const p of matchedFilePaths)
100
174
  importedFilePaths.delete(p);
175
+ // Skip generic utility/barrel files with many exports — surface only focused modules
176
+ const exportCountByFile = new Map();
177
+ for (const s of maps.symbolMap.symbols) {
178
+ if (s.exported) {
179
+ exportCountByFile.set(s.filePath, (exportCountByFile.get(s.filePath) ?? 0) + 1);
180
+ }
181
+ }
182
+ const MAX_NEARBY_FILE_EXPORTS = 15;
183
+ const relevantImportedFilePaths = new Set([...importedFilePaths].filter((fp) => (exportCountByFile.get(fp) ?? 0) <= MAX_NEARBY_FILE_EXPORTS));
101
184
  const nearbySymbols = maps.symbolMap.symbols
102
185
  .filter((s) => s.exported &&
103
- importedFilePaths.has(s.filePath) &&
186
+ relevantImportedFilePaths.has(s.filePath) &&
104
187
  !matchedSymbolIds.has(s.id))
105
188
  .slice(0, max);
106
189
  const nearbySymbolExplanations = nearbySymbols.map((symbol) => ({
@@ -209,6 +292,36 @@ function explainRelationships(relationships, context) {
209
292
  return { relationship, reasons: [...reasons] };
210
293
  });
211
294
  }
295
+ function applyCognitionRankAdjustments(ranked) {
296
+ const reasons = [...ranked.reasons];
297
+ let score = ranked.score;
298
+ if (ranked.item.confidence === 'high') {
299
+ score += 3;
300
+ reasons.push('high confidence cognition');
301
+ }
302
+ else if (ranked.item.confidence === 'low') {
303
+ score -= 2;
304
+ reasons.push('low confidence penalty');
305
+ }
306
+ if (ranked.item.referencesStatus === 'current') {
307
+ score += 2;
308
+ reasons.push('current references');
309
+ }
310
+ else if (ranked.item.referencesStatus === 'mixed') {
311
+ score -= 1;
312
+ reasons.push('mixed reference penalty');
313
+ }
314
+ else if (ranked.item.referencesStatus === 'stale' ||
315
+ ranked.item.referencesStatus === 'unresolved') {
316
+ score -= 4;
317
+ reasons.push('stale reference penalty');
318
+ }
319
+ if (ranked.item.kind === 'decision' || ranked.item.kind === 'gotcha') {
320
+ score += 1;
321
+ reasons.push(`${ranked.item.kind} cognition`);
322
+ }
323
+ return { ...ranked, score, reasons };
324
+ }
212
325
  function dependenciesForImportedSymbol(symbol, dependencies) {
213
326
  return dependencies
214
327
  .filter((dependency) => dependency.kind === 'local' &&
@@ -5,7 +5,7 @@ export const claudeCodeAdapter = {
5
5
  targetPath: 'CLAUDE.md',
6
6
  instructions: `## KGraph Workflow
7
7
 
8
- {{KGRAPH_CONTEXT_POLICY}} Use /kgraph for the full automated workflow. Run \`kgraph doctor\` when setup or generated maps look wrong. Run \`kgraph scan\`, \`kgraph update\`, and \`kgraph context\` manually only when you need one specific step.
8
+ {{KGRAPH_CONTEXT_POLICY}} Use /kgraph for the full automated workflow. Run \`kgraph conclude\` for durable typed engineering memory and \`kgraph compact --dry-run\` when cognition looks duplicated or stale. Run \`kgraph doctor\` when setup or generated maps look wrong. Run \`kgraph scan\`, \`kgraph update\`, and \`kgraph context\` manually only when you need one specific step.
9
9
  `,
10
10
  commandFiles: [
11
11
  {
@@ -23,6 +23,11 @@ ${numberedWorkflow('claude-code', { sessionQualifier: 'when native hooks are una
23
23
  {
24
24
  path: '.claude/commands/kgraph-repair.md',
25
25
  content: `Run \`kgraph repair --dry-run\` first and summarize the proposed cognition cleanup. Run \`kgraph repair\` only when the user asks to apply the cleanup.
26
+ `,
27
+ },
28
+ {
29
+ path: '.claude/commands/kgraph-compact.md',
30
+ content: `Run \`kgraph compact --dry-run\` first and summarize duplicate cognition groups and stale low-confidence notes. Run \`kgraph compact\` only when the user asks to apply compaction.
26
31
  `,
27
32
  },
28
33
  {
@@ -47,7 +52,12 @@ ${numberedWorkflow('claude-code', { sessionQualifier: 'when native hooks are una
47
52
  },
48
53
  {
49
54
  path: '.claude/commands/kgraph-session.md',
50
- content: `Use \`kgraph session\` to inspect session read/write/token estimates. Record meaningful events with \`kgraph session start --agent claude-code\`, \`kgraph session read <path> --agent claude-code\`, \`kgraph session write <path> --agent claude-code\`, and \`kgraph session end --agent claude-code\`.
55
+ content: `Use \`kgraph session\` to inspect session read/write/token estimates. Record meaningful events with \`kgraph session start --agent claude-code\`, \`kgraph session read <path> --agent claude-code\`, \`kgraph session write <path> --agent claude-code\`, and \`kgraph session end --agent claude-code --conclude --topic "<topic>"\` when durable session memory is useful.
56
+ `,
57
+ },
58
+ {
59
+ path: '.claude/commands/kgraph-conclude.md',
60
+ content: `Use \`kgraph conclude "$ARGUMENTS"\` when the session produced reusable engineering knowledge. Choose one type from finding, decision, gotcha, summary, relationship, and one confidence from high, medium, low. Store only durable conclusions, not raw chain-of-thought, temporary reasoning, speculative exploration, or low-value observations.
51
61
  `,
52
62
  },
53
63
  {
@@ -5,7 +5,7 @@ export const codexAdapter = {
5
5
  targetPath: 'AGENTS.md',
6
6
  instructions: `## KGraph Workflow
7
7
 
8
- {{KGRAPH_CONTEXT_POLICY}} The /kgraph skill handles the full automated workflow. Run \`kgraph doctor\` when setup or generated maps look wrong. Run \`kgraph scan\`, \`kgraph update\`, and \`kgraph context\` manually only when you need one specific step.
8
+ {{KGRAPH_CONTEXT_POLICY}} The /kgraph skill handles the full automated workflow. Run \`kgraph conclude\` for durable typed engineering memory and \`kgraph compact --dry-run\` when cognition looks duplicated or stale. Run \`kgraph doctor\` when setup or generated maps look wrong. Run \`kgraph scan\`, \`kgraph update\`, and \`kgraph context\` manually only when you need one specific step.
9
9
  `,
10
10
  commandFiles: [
11
11
  {
@@ -12,7 +12,7 @@ ${numberedWorkflow('copilot')}
12
12
  path: '.github/agents/kgraph.agent.md',
13
13
  content: `---
14
14
  name: kgraph
15
- description: Use KGraph persistent repo intelligence to answer questions about this codebase. Runs kgraph context, scan, update, impact, history, and session commands to ground responses in durable local knowledge.
15
+ description: Use KGraph persistent repo intelligence to answer questions about this codebase. Runs kgraph context, scan, update, conclude, compact, impact, history, and session commands to ground responses in durable local knowledge.
16
16
  tools:
17
17
  - run_in_terminal
18
18
  - read_file
@@ -47,6 +47,17 @@ argument-hint: "--dry-run or apply"
47
47
  ---
48
48
 
49
49
  Run \`kgraph repair --dry-run\` first and summarize the proposed cognition cleanup. Run \`kgraph repair\` only when the user asks to apply the cleanup.
50
+ `,
51
+ },
52
+ {
53
+ path: '.github/prompts/kgraph-compact.prompt.md',
54
+ content: `---
55
+ description: Merge duplicate KGraph cognition and archive stale low-value entries
56
+ agent: agent
57
+ argument-hint: "--dry-run or apply"
58
+ ---
59
+
60
+ Run \`kgraph compact --dry-run\` first and summarize duplicate cognition groups and stale low-confidence notes. Run \`kgraph compact\` only when the user asks to apply compaction.
50
61
  `,
51
62
  },
52
63
  {
@@ -90,6 +101,17 @@ argument-hint: "Brief description of what was done"
90
101
  Capture this session into KGraph cognition.
91
102
 
92
103
  {{KGRAPH_CAPTURE_POLICY}}
104
+ `,
105
+ },
106
+ {
107
+ path: '.github/prompts/kgraph-conclude.prompt.md',
108
+ content: `---
109
+ description: Store a typed durable KGraph engineering conclusion
110
+ agent: agent
111
+ argument-hint: "Topic plus optional type, confidence, files, and symbols"
112
+ ---
113
+
114
+ Use \`kgraph conclude "$ARGUMENTS"\` when the session produced reusable engineering knowledge. Choose one type from finding, decision, gotcha, summary, relationship, and one confidence from high, medium, low. Store only durable conclusions, not raw chain-of-thought, temporary reasoning, speculative exploration, or low-value observations.
93
115
  `,
94
116
  },
95
117
  {
@@ -111,7 +133,7 @@ agent: agent
111
133
  argument-hint: "start, read <path>, write <path>, end, or status"
112
134
  ---
113
135
 
114
- Use \`kgraph session\` to inspect current session activity. Record meaningful events with \`kgraph session start --agent copilot\`, \`kgraph session read <path> --agent copilot\`, \`kgraph session write <path> --agent copilot\`, and \`kgraph session end --agent copilot\`.
136
+ Use \`kgraph session\` to inspect current session activity. Record meaningful events with \`kgraph session start --agent copilot\`, \`kgraph session read <path> --agent copilot\`, \`kgraph session write <path> --agent copilot\`, and \`kgraph session end --agent copilot --conclude --topic "<topic>"\` when durable session memory is useful.
115
137
  `,
116
138
  },
117
139
  {
@@ -50,14 +50,20 @@ export function renderContextPolicy(mode) {
50
50
  }
51
51
  export function renderCapturePolicy() {
52
52
  return `Capture policy:
53
- - At the end of any session that changed repository files, write one concise Markdown note to \`.kgraph/inbox/<slug>.md\` before finishing.
54
- - A \`.kgraph/inbox/*.md\` note is KGraph runtime capture, not project documentation. It is allowed and required by this workflow unless the user explicitly says not to capture to KGraph.
55
- - Do not skip capture for UI text, button, link, route, styling, or small file edits. Skip capture only when no repository files changed.
53
+ - At the end of any session that changed repository files, store durable engineering memory with \`kgraph conclude "<topic>" --type <finding|decision|gotcha|summary|relationship> --confidence <high|medium|low>\` or \`kgraph session end --agent <agent> --conclude --topic "<topic>"\`.
54
+ - Preserve only expensive-to-rediscover findings, decisions, gotchas, summaries, and relationships. Do not store raw chain-of-thought, temporary reasoning, speculative exploration, or low-value observations.
55
+ - Use \`.kgraph/inbox/<slug>.md\` only when a longer structured note is clearer than a single \`kgraph conclude\` command.
56
+ - A \`.kgraph/inbox/*.md\` note is KGraph runtime capture, not project documentation. It is allowed by this workflow unless the user explicitly says not to capture to KGraph.
57
+ - Do not skip capture for meaningful UI text, button, link, route, styling, or small file edits. Skip capture only when no reusable repository knowledge was created.
56
58
  - Do not run KGraph repeatedly. Run it once at the start with \`kgraph "<topic>"\`. If repo files changed, write the inbox note first, then run \`kgraph\` once at the end.
57
- - After the final \`kgraph\` run, mention whether the inbox note was processed.
59
+ - After the final \`kgraph\` run, mention whether durable cognition was stored or processed.
58
60
 
59
- The inbox note must use this structure:
61
+ When using an inbox note, use this structure:
60
62
  \`\`\`markdown
63
+ ---
64
+ type: finding
65
+ confidence: medium
66
+ ---
61
67
  # <Short Title>
62
68
 
63
69
  ## Summary
@@ -5,9 +5,10 @@
5
5
  const DOCTOR_STEP = `Run \`kgraph doctor\` when setup, maps, inbox processing, or integrations look wrong. Run \`kgraph doctor --quality\` when context shows stale/noisy cognition references.`;
6
6
  const IMPACT_STEP = `Run \`kgraph impact "<file-or-symbol>"\` when the user asks what a change may affect. Run \`kgraph history "<topic>"\` when prior work or decisions matter.`;
7
7
  const REPAIR_STEP = `Run \`kgraph repair --dry-run\` before cleanup when stale/noisy cognition needs fixing. Run \`kgraph repair\` only when the user asks to apply that cleanup.`;
8
+ const COMPACT_STEP = `Run \`kgraph compact --dry-run\` when cognition looks duplicated, noisy, or stale. Run \`kgraph compact\` only when the user asks to merge/archive cognition.`;
8
9
  const HISTORY_STEP = `Run \`kgraph history\` or \`kgraph history "<topic>"\` to review past cognition sessions with git author attribution.`;
9
10
  function sessionStep(agentName, qualifier) {
10
- const base = `Track meaningful session activity with \`kgraph session start --agent ${agentName}\`, \`kgraph session read <path> --agent ${agentName}\`, \`kgraph session write <path> --agent ${agentName}\`, and \`kgraph session end --agent ${agentName}\``;
11
+ const base = `Track meaningful session activity with \`kgraph session start --agent ${agentName}\`, \`kgraph session read <path> --agent ${agentName}\`, \`kgraph session write <path> --agent ${agentName}\`, and \`kgraph session end --agent ${agentName} --conclude --topic "<topic>"\` when durable session memory is useful`;
11
12
  return qualifier ? `${base} ${qualifier}.` : `${base}.`;
12
13
  }
13
14
  /**
@@ -25,8 +26,9 @@ export function numberedWorkflow(agentName, options = {}) {
25
26
  {{KGRAPH_CAPTURE_POLICY}}
26
27
 
27
28
  7. ${REPAIR_STEP}
28
- 8. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph at http://localhost:4242 with PNG export.
29
- 9. ${HISTORY_STEP}`;
29
+ 8. ${COMPACT_STEP}
30
+ 9. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph at http://localhost:4242 with PNG export.
31
+ 10. ${HISTORY_STEP}`;
30
32
  }
31
33
  /**
32
34
  * Returns the bullet-list workflow for rules files.
@@ -39,6 +41,7 @@ export function bulletWorkflow(agentName, options = {}) {
39
41
  - ${IMPACT_STEP}
40
42
  {{KGRAPH_CAPTURE_POLICY}}
41
43
  - ${REPAIR_STEP}
44
+ - ${COMPACT_STEP}
42
45
  - Run \`kgraph visualize\` to open the interactive dependency graph at http://localhost:4242 with PNG export.
43
46
  - ${HISTORY_STEP}`;
44
47
  }
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { KGraphError } from '../cli/errors.js';
3
4
  import { getIntegrationAdapter } from '../integrations/integration-registry.js';
4
5
  import { pathExists } from '../storage/kgraph-paths.js';
5
- import { KGraphError } from '../cli/errors.js';
6
6
  import { estimateTokens } from './token-estimator.js';
7
7
  const EMPTY_STATE = {
8
8
  active: {},
@@ -32,6 +32,15 @@ export async function readSessionLedger(workspace) {
32
32
  export async function recordSessionEvent(workspace, input) {
33
33
  const now = new Date().toISOString();
34
34
  const state = await readSessionState(workspace);
35
+ if (input.type === 'end' && !state.active[input.agent]) {
36
+ throw new KGraphError(`No active session for agent "${input.agent}".`);
37
+ }
38
+ // Auto-close any open session for this agent before starting a new one so
39
+ // the ledger entry is never silently lost on repeated start calls.
40
+ if (input.type === 'start' && state.active[input.agent]) {
41
+ await appendLedgerEntry(workspace, summarizeAgentSession(input.agent, state, now));
42
+ delete state.active[input.agent];
43
+ }
35
44
  const active = state.active[input.agent] ?? {
36
45
  agent: input.agent,
37
46
  sessionId: `${input.agent}-${now.replace(/[:.]/g, '-')}`,
@@ -146,7 +155,11 @@ function topRepeatedReads(events) {
146
155
  for (const event of events) {
147
156
  if (!event.path)
148
157
  continue;
149
- const current = byPath.get(event.path) ?? { path: event.path, count: 0, estimatedTokens: 0 };
158
+ const current = byPath.get(event.path) ?? {
159
+ path: event.path,
160
+ count: 0,
161
+ estimatedTokens: 0,
162
+ };
150
163
  current.count += 1;
151
164
  current.estimatedTokens += event.tokenEstimate ?? 0;
152
165
  byPath.set(event.path, current);
@@ -53,7 +53,7 @@ export async function readCognitionNotes(workspace) {
53
53
  const raw = await readFile(filePath, "utf8");
54
54
  const encoded = parseEmbeddedJson(raw);
55
55
  if (encoded) {
56
- notes.push(encoded);
56
+ notes.push(normalizeCognitionNote(encoded));
57
57
  }
58
58
  }
59
59
  return notes;
@@ -80,6 +80,30 @@ function parseEmbeddedJson(raw) {
80
80
  const encoded = raw.match(/```json\n([\s\S]*?)\n```/);
81
81
  return encoded ? JSON.parse(encoded[1]) : undefined;
82
82
  }
83
+ function normalizeCognitionNote(note) {
84
+ const title = note.title ?? 'Untitled Cognition Note';
85
+ return {
86
+ title,
87
+ kind: note.kind ?? 'summary',
88
+ confidence: note.confidence ?? 'medium',
89
+ domain: note.domain,
90
+ tags: note.tags ?? [],
91
+ summary: note.summary,
92
+ sections: note.sections ?? {},
93
+ relatedFiles: note.relatedFiles ?? [],
94
+ relatedSymbols: note.relatedSymbols ?? [],
95
+ warnings: note.warnings ?? [],
96
+ id: (note.id ?? slugify(title)) || 'cognition-note',
97
+ sourceInboxPath: note.sourceInboxPath ?? '',
98
+ processedPath: note.processedPath ?? '',
99
+ createdAt: note.createdAt ?? '',
100
+ updatedAt: note.updatedAt,
101
+ source: note.source ?? 'inbox',
102
+ supersedes: note.supersedes,
103
+ supersededBy: note.supersededBy,
104
+ referencesStatus: note.referencesStatus ?? 'unresolved',
105
+ };
106
+ }
83
107
  function mergeDomainRecords(existing, next) {
84
108
  return {
85
109
  ...existing,
@@ -109,7 +133,7 @@ function renderCognitionNote(note) {
109
133
  const sectionText = Object.entries(note.sections)
110
134
  .map(([heading, content]) => `## ${heading}\n\n${content.trim()}`)
111
135
  .join("\n\n");
112
- return `# ${note.title}\n\nStatus: ${note.referencesStatus}\n\n${sectionText}\n\n## KGraph Metadata\n\n\`\`\`json\n${JSON.stringify(note, null, 2)}\n\`\`\`\n`;
136
+ return `# ${note.title}\n\nType: ${note.kind ?? 'summary'}\nConfidence: ${note.confidence ?? 'medium'}\nStatus: ${note.referencesStatus}\nSource: ${note.source ?? 'inbox'}\n\n${sectionText}\n\n## KGraph Metadata\n\n\`\`\`json\n${JSON.stringify(note, null, 2)}\n\`\`\`\n`;
113
137
  }
114
138
  function renderDomainRecord(domain) {
115
139
  return `# ${domain.name}\n\n${domain.description ?? ""}\n\n## Files\n\n${domain.files
@@ -1,7 +1,12 @@
1
1
  import type { CodeSymbol, Relationship, RepositoryFile } from './maps.js';
2
2
  export type ReferenceStatus = 'current' | 'stale' | 'unresolved' | 'mixed';
3
+ export type CognitionKind = 'finding' | 'decision' | 'gotcha' | 'summary' | 'relationship';
4
+ export type CognitionConfidence = 'high' | 'medium' | 'low';
5
+ export type CognitionSource = 'inbox' | 'conclude' | 'session-conclude' | 'compact';
3
6
  export interface ParsedCognitionNote {
4
7
  title: string;
8
+ kind: CognitionKind;
9
+ confidence: CognitionConfidence;
5
10
  domain?: string;
6
11
  tags: string[];
7
12
  summary?: string;
@@ -15,6 +20,10 @@ export interface CognitionNote extends ParsedCognitionNote {
15
20
  sourceInboxPath: string;
16
21
  processedPath: string;
17
22
  createdAt: string;
23
+ updatedAt?: string;
24
+ source: CognitionSource;
25
+ supersedes?: string[];
26
+ supersededBy?: string;
18
27
  referencesStatus: ReferenceStatus;
19
28
  }
20
29
  export interface DomainRecord {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kentwynn/kgraph",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Persistent repo intelligence for AI coding assistants.",
5
5
  "type": "module",
6
6
  "bin": {