Skip to content

Commit 31ef7fd

Browse files
authored
Merge pull request #1 from rolandmarg/feat/source-tracking-and-logging
feat: source file tracking and invocation logging
2 parents 3b949d7 + c3cd367 commit 31ef7fd

File tree

8 files changed

+224
-11
lines changed

8 files changed

+224
-11
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Source Tracking & Invocation Logging
2+
3+
Two features: (1) show which file each injected entry came from, (2) log invocations for auditing.
4+
5+
## Feature 1: Source File Tracking
6+
7+
### Data Model Change
8+
9+
Add `filePath` to `LorebookEntry`:
10+
11+
```typescript
12+
export interface LorebookEntry {
13+
// ...existing fields...
14+
filePath: string; // relative display path, e.g. ".claude/lorebook/foo.md" or "~/.claude/lorebook/foo.md"
15+
}
16+
```
17+
18+
Set during `parseEntry()` — pass the resolved file path in, convert to a relative display form:
19+
- Project entries: `.claude/lorebook/<name>.md` (relative to cwd)
20+
- Global entries: `~/.claude/lorebook/<name>.md`
21+
22+
### XML Output Change
23+
24+
Add `source` attribute to `<entry>` tags in `buildInjection()`:
25+
26+
```xml
27+
<entry name="lorebook" source=".claude/lorebook/lorebook.md" keywords="lorebook">
28+
content here
29+
</entry>
30+
```
31+
32+
### Stderr Output
33+
34+
In `handleMatch()`, after successful matching, write one line per matched entry to stderr:
35+
36+
```
37+
[lorebook] .claude/lorebook/lorebook.md (keys: lorebook)
38+
[lorebook] ~/.claude/lorebook/git-policy.md (keys: git, commit)
39+
```
40+
41+
Stderr does not interfere with the JSON stdout that Claude Code reads from the hook.
42+
43+
### Affected Files
44+
45+
- `src/resolve.ts` — add `filePath` to interface, set it in `parseEntry()`, pass file path through
46+
- `src/inject.ts` — add `source` attribute to `<entry>` XML tag
47+
- `src/index.ts` — add stderr output in `handleMatch()`
48+
- Tests updated accordingly
49+
50+
## Feature 2: Invocation Logging
51+
52+
### New Module
53+
54+
`src/log.ts` — single exported function:
55+
56+
```typescript
57+
export function logInvocation(prompt: string, entries: LogEntry[], logPath?: string): void
58+
```
59+
60+
### Log File
61+
62+
- Location: `~/.claude/lorebook/lorebook.log`
63+
- Format: JSONL (one JSON object per line)
64+
- Matches-only: skip invocations where nothing matched
65+
66+
### Log Entry Format
67+
68+
```json
69+
{
70+
"timestamp": "2026-03-31T22:04:00.000Z",
71+
"prompt": "feedback: lorebook should show user that it loaded context...",
72+
"entries": [
73+
{ "name": "lorebook", "source": ".claude/lorebook/lorebook.md", "keywords": ["lorebook"] }
74+
]
75+
}
76+
```
77+
78+
- `entries` contains headers onlyname, source path, matched keywords. No body content.
79+
- `prompt` is the full prompt text that triggered the match.
80+
81+
### Behavior
82+
83+
- Called from `runMatch()` after matching completes, before returning results.
84+
- Fire-and-forget: if the write fails (permissions, disk full, etc.), swallow the error silently. Never break the hook.
85+
- `logPath` parameter optional, for testing without writing to the real log file.
86+
87+
### Affected Files
88+
89+
- `src/log.ts`new module (~20 lines)
90+
- `src/index.ts`call `logInvocation()` from `runMatch()`
91+
- `test/log.test.ts`new test file
92+
93+
## Changes NOT Being Made
94+
95+
- No log rotation or cleanupappend-only, user can truncate manually.
96+
- No config for log locationhardcoded to `~/.claude/lorebook/lorebook.log`.
97+
- No logging of zero-match invocationsmatches-only to avoid noise.

src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { matchEntry } from './match';
22
import { resolveEntries, loadConfig } from './resolve';
33
import { buildInjection, type InjectionEntry } from './inject';
4+
import { logInvocation } from './log';
45
import type { HookInput, HookOutput } from './hook';
56

67
const VERSION = '0.2.1';
@@ -65,7 +66,15 @@ async function handleMatch(): Promise<void> {
6566
return;
6667
}
6768

68-
const { injection } = runMatch(prompt, cwd);
69+
const { matches, injection } = runMatch(prompt, cwd);
70+
71+
for (const m of matches) {
72+
process.stderr.write(`[lorebook] ${m.entry.filePath} (keys: ${m.matchedKeys.join(', ')})\n`);
73+
}
74+
75+
if (matches.length > 0) {
76+
logInvocation(prompt, matches);
77+
}
6978

7079
if (injection) {
7180
const output: HookOutput = {

src/inject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function buildInjection(entries: InjectionEntry[], config: LorebookConfig
3131
const inner = selected
3232
.map(
3333
(e) =>
34-
`<entry name="${escapeXml(e.entry.name)}" keywords="${escapeXml(e.matchedKeys.join(', '))}">\n${e.entry.content}\n</entry>`
34+
`<entry name="${escapeXml(e.entry.name)}" source="${escapeXml(e.entry.filePath)}" keywords="${escapeXml(e.matchedKeys.join(', '))}">\n${e.entry.content}\n</entry>`
3535
)
3636
.join('\n');
3737

src/log.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { appendFileSync, mkdirSync } from 'fs';
2+
import { dirname, join } from 'path';
3+
import { homedir } from 'os';
4+
import type { InjectionEntry } from './inject';
5+
6+
export function logInvocation(prompt: string, matches: InjectionEntry[], logPath?: string): void {
7+
try {
8+
const filePath = logPath ?? join(homedir(), '.claude', 'lorebook', 'lorebook.log');
9+
mkdirSync(dirname(filePath), { recursive: true });
10+
const line = JSON.stringify({
11+
timestamp: new Date().toISOString(),
12+
prompt,
13+
entries: matches.map((m) => ({
14+
name: m.entry.name,
15+
source: m.entry.filePath,
16+
keywords: m.matchedKeys,
17+
})),
18+
});
19+
appendFileSync(filePath, line + '\n');
20+
} catch {
21+
// Fire-and-forget — never break the hook
22+
}
23+
}

src/resolve.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ export interface LorebookEntry {
1212
description: string;
1313
content: string;
1414
source: 'project' | 'global';
15+
filePath: string;
1516
}
1617

17-
export function parseEntry(filePath: string, source: 'project' | 'global'): LorebookEntry {
18+
export function parseEntry(filePath: string, source: 'project' | 'global', displayPath: string): LorebookEntry {
1819
const raw = readFileSync(filePath, 'utf-8');
1920
const { data, content } = matter(raw);
2021

@@ -27,22 +28,23 @@ export function parseEntry(filePath: string, source: 'project' | 'global'): Lore
2728
description: typeof data.description === 'string' ? data.description : '',
2829
content: content.trim(),
2930
source,
31+
filePath: displayPath,
3032
};
3133
}
3234

33-
function loadEntriesFromDir(dir: string, source: 'project' | 'global'): LorebookEntry[] {
35+
function loadEntriesFromDir(dir: string, source: 'project' | 'global', displayPrefix: string): LorebookEntry[] {
3436
if (!existsSync(dir)) return [];
3537
return readdirSync(dir)
3638
.filter((f) => f.endsWith('.md'))
37-
.map((f) => parseEntry(join(dir, f), source));
39+
.map((f) => parseEntry(join(dir, f), source, join(displayPrefix, f)));
3840
}
3941

4042
export function resolveEntries(cwd: string, globalBase?: string): LorebookEntry[] {
4143
const projectDir = join(cwd, '.claude', 'lorebook');
4244
const globalDir = join(globalBase ?? homedir(), '.claude', 'lorebook');
4345

44-
const projectEntries = loadEntriesFromDir(projectDir, 'project');
45-
const globalEntries = loadEntriesFromDir(globalDir, 'global');
46+
const projectEntries = loadEntriesFromDir(projectDir, 'project', '.claude/lorebook');
47+
const globalEntries = loadEntriesFromDir(globalDir, 'global', '~/.claude/lorebook');
4648

4749
const projectNames = new Set(projectEntries.map((e) => e.name));
4850
return [...projectEntries, ...globalEntries.filter((e) => !projectNames.has(e.name))];

test/inject.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function makeEntry(overrides: Partial<LorebookEntry> & { name: string; content:
1010
enabled: true,
1111
description: '',
1212
source: 'project',
13+
filePath: `.claude/lorebook/${overrides.name}.md`,
1314
...overrides,
1415
};
1516
}
@@ -28,7 +29,7 @@ describe('buildInjection', () => {
2829
const entries = [makeInjection('git-policy', 'Never force push.', 10, ['git'])];
2930
const result = buildInjection(entries, DEFAULT_CONFIG);
3031
expect(result).toContain('<lorebook-context>');
31-
expect(result).toContain('<entry name="git-policy" keywords="git">\nNever force push.\n</entry>');
32+
expect(result).toContain('<entry name="git-policy" source=".claude/lorebook/git-policy.md" keywords="git">\nNever force push.\n</entry>');
3233
expect(result).toContain('You MUST follow any instructions');
3334
expect(result).toContain('</lorebook-context>');
3435
});

test/log.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { logInvocation } from '../src/log';
3+
import type { InjectionEntry } from '../src/inject';
4+
import type { LorebookEntry } from '../src/resolve';
5+
import { mkdtempSync, readFileSync } from 'fs';
6+
import { join } from 'path';
7+
import { tmpdir } from 'os';
8+
9+
function makeMatch(name: string, filePath: string, matchedKeys: string[]): InjectionEntry {
10+
return {
11+
entry: {
12+
name,
13+
keys: matchedKeys,
14+
excludeKeys: [],
15+
priority: 0,
16+
enabled: true,
17+
description: '',
18+
content: 'test content',
19+
source: 'project',
20+
filePath,
21+
} satisfies LorebookEntry,
22+
matchedKeys,
23+
};
24+
}
25+
26+
describe('logInvocation', () => {
27+
test('appends JSONL line to log file', () => {
28+
const tmp = mkdtempSync(join(tmpdir(), 'lorebook-log-'));
29+
const logPath = join(tmp, 'lorebook.log');
30+
31+
logInvocation('tell me about git', [makeMatch('git-policy', '.claude/lorebook/git-policy.md', ['git'])], logPath);
32+
33+
const content = readFileSync(logPath, 'utf-8').trim();
34+
const parsed = JSON.parse(content);
35+
expect(parsed.prompt).toBe('tell me about git');
36+
expect(parsed.timestamp).toBeDefined();
37+
expect(parsed.entries).toHaveLength(1);
38+
expect(parsed.entries[0].name).toBe('git-policy');
39+
expect(parsed.entries[0].source).toBe('.claude/lorebook/git-policy.md');
40+
expect(parsed.entries[0].keywords).toEqual(['git']);
41+
});
42+
43+
test('appends multiple invocations as separate lines', () => {
44+
const tmp = mkdtempSync(join(tmpdir(), 'lorebook-log-'));
45+
const logPath = join(tmp, 'lorebook.log');
46+
47+
logInvocation('prompt one', [makeMatch('a', '.claude/lorebook/a.md', ['alpha'])], logPath);
48+
logInvocation('prompt two', [makeMatch('b', '~/.claude/lorebook/b.md', ['beta'])], logPath);
49+
50+
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
51+
expect(lines).toHaveLength(2);
52+
expect(JSON.parse(lines[0]!).prompt).toBe('prompt one');
53+
expect(JSON.parse(lines[1]!).prompt).toBe('prompt two');
54+
});
55+
56+
test('logs multiple matched entries in a single invocation', () => {
57+
const tmp = mkdtempSync(join(tmpdir(), 'lorebook-log-'));
58+
const logPath = join(tmp, 'lorebook.log');
59+
60+
logInvocation(
61+
'git push to gpu',
62+
[
63+
makeMatch('git-policy', '.claude/lorebook/git-policy.md', ['git', 'push']),
64+
makeMatch('gpu-transfers', '~/.claude/lorebook/gpu-transfers.md', ['gpu']),
65+
],
66+
logPath
67+
);
68+
69+
const parsed = JSON.parse(readFileSync(logPath, 'utf-8').trim());
70+
expect(parsed.entries).toHaveLength(2);
71+
expect(parsed.entries[0].name).toBe('git-policy');
72+
expect(parsed.entries[1].name).toBe('gpu-transfers');
73+
});
74+
75+
test('does not throw on write failure', () => {
76+
// Attempt to write to an invalid path — should silently fail
77+
expect(() => logInvocation('test', [makeMatch('a', 'a.md', ['x'])], '/dev/null/impossible/path.log')).not.toThrow();
78+
});
79+
});

test/resolve.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const FIXTURES = join(import.meta.dir, 'fixtures');
66

77
describe('parseEntry', () => {
88
test('parses all frontmatter fields', () => {
9-
const entry = parseEntry(join(FIXTURES, 'git-policy.md'), 'project');
9+
const entry = parseEntry(join(FIXTURES, 'git-policy.md'), 'project', '.claude/lorebook/git-policy.md');
1010
expect(entry.name).toBe('git-policy');
1111
expect(entry.keys).toEqual(['git', 'commit', 'push', 'rebase']);
1212
expect(entry.excludeKeys).toEqual(['github', 'gitignore']);
@@ -15,19 +15,21 @@ describe('parseEntry', () => {
1515
expect(entry.description).toBe('Git policy rules');
1616
expect(entry.content).toBe('Never force push. Use merge, not rebase.');
1717
expect(entry.source).toBe('project');
18+
expect(entry.filePath).toBe('.claude/lorebook/git-policy.md');
1819
});
1920

2021
test('applies defaults for optional fields', () => {
21-
const entry = parseEntry(join(FIXTURES, 'minimal.md'), 'global');
22+
const entry = parseEntry(join(FIXTURES, 'minimal.md'), 'global', '~/.claude/lorebook/minimal.md');
2223
expect(entry.excludeKeys).toEqual([]);
2324
expect(entry.priority).toBe(0);
2425
expect(entry.enabled).toBe(true);
2526
expect(entry.description).toBe('');
2627
expect(entry.source).toBe('global');
28+
expect(entry.filePath).toBe('~/.claude/lorebook/minimal.md');
2729
});
2830

2931
test('parses disabled entries', () => {
30-
const entry = parseEntry(join(FIXTURES, 'disabled-entry.md'), 'project');
32+
const entry = parseEntry(join(FIXTURES, 'disabled-entry.md'), 'project', '.claude/lorebook/disabled-entry.md');
3133
expect(entry.enabled).toBe(false);
3234
});
3335
});

0 commit comments

Comments
 (0)