diff --git a/README.md b/README.md index 94c568f..8d68734 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,24 @@ Previous content before this edit You can add `.code-notes/` to `.gitignore` if you want notes to be local only, or commit them to share with your team. +### Generated exports + +When auto-exports are enabled, two files are automatically regenerated +(debounced by 200ms) in your configured notes directory (`.code-notes/` by +default; see `codeContextNotes.storageDirectory`) whenever notes change: + +- **`INDEX.json`** — machine-readable index. Used by integrations like the MCP server (v0.4+). +- **`AGENTS.md`** — human-readable digest, hoisting instructions/warnings/handoffs. Useful as workspace context for coding agents. + +Note content and ordering are deterministic given the same notes; only the `generatedAt` timestamp in `INDEX.json` changes on every regeneration. To exclude them from git, add to `.gitignore`: + +```gitignore +.code-notes/INDEX.json +.code-notes/AGENTS.md +``` + +To disable export generation entirely, set `codeContextNotes.exports.enabled` to `false`. + ## Commands All commands are available in the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`): diff --git a/docs/changelogs/v0.3.0.md b/docs/changelogs/v0.3.0.md index bc4ac16..cf9cd1b 100644 --- a/docs/changelogs/v0.3.0.md +++ b/docs/changelogs/v0.3.0.md @@ -1,147 +1,51 @@ # Changelog - Version 0.3.0 -## [0.3.0] - TBD +## [0.3.0] - 2026-05-16 ### Added -- **Search and Filter Notes** (GitHub Issue #10) - - Full-text search across all note content - - Filter by author (single or multiple authors) - - Filter by date range (created or modified) - - Filter by file path (glob pattern support) - - Combine multiple filters (AND logic) - - Search input with live results update (debounced 200ms) - - Keyboard shortcut for quick search (`Ctrl+Shift+F` / `Cmd+Shift+F` in notes context) - - Search icon integrated into sidebar toolbar - - Search results show file, line number, preview, and author - - Click any result to navigate directly to note location - - Clear filters button - - Search result count indicator - - Keyboard navigation in results (↑↓ arrows, Enter to open) - - Regex pattern matching support for advanced searches - - Case-sensitive search option - - Search history persistence (last 20 searches) - - "Recent Searches" quick access - - Background indexing for instant search results - - Progress indicator for large searches +- **Structured Note Schema for Agent Integration** + - Optional `type` (7 categories: `context` default, `instruction`, `warning`, `decision`, `todo`, `handoff`, `rationale`), `priority` (`low`/`normal`/`high`/`critical`), `scope` (`line`/`function`/`class`/`file`/`directory`), `tags`, `authorType` (`human`/`agent`), `expiresAt` (ISO 8601), and `references` (PR/issue/commit/test/url) fields on every note + - All fields optional — legacy v0.2.x notes load with sensible defaults (no migration required) + - New command **`Code Notes: Set Note Type / Tags / Priority…`** — Quick-Pick driven metadata editor for any note +- **Auto-Generated Workspace Exports for Coding Agents** + - **`.code-notes/INDEX.json`** — machine-readable index with `byFile`/`byType`/`byTag` lookups and deterministic output. Designed for MCP server consumption (v0.4+). + - **`.code-notes/AGENTS.md`** — human-readable digest hoisting critical instructions/warnings, open handoffs, and decisions. Useful as workspace context for coding agents (Claude Code, Cursor, etc.). + - Debounced (200ms) atomic writes on every note change; new `Code Notes: Regenerate Exports` command for manual recovery. +- **Sidebar Enhancements** + - Note type appears as `· {type}` description suffix; high/critical priority shown as a tooltip badge + - New title-bar actions: **Filter Notes by Type…** (multi-select Quick Pick) and **Toggle Expired Notes** + +### Fixed +- CodeLens previews of multiline notes showed all lines merged together; now only the first line, and stray heading markers (`##`) are stripped +- Sidebar notes could render out of line-number order; note previews now strip image markdown cleanly (`![alt](url)` no longer leaves a stray `!`) +- Comment labels always claimed "Last updated" — fresh notes now correctly show "Created" +- Edit-mode lookups (`getCurrentlyEditingComment`) used the wrong map key and never found the editing comment +- Soft-deleted notes disappeared from the note cache entirely, breaking "already deleted" detection and metadata guards; they are also no longer retrievable via by-id lookup +- Stale compiled test artifacts in `out/` ran phantom test suites; the test pipeline now cleans `out/` before compiling ### Changed -- Search UI uses VSCode's native QuickPick for familiar experience -- Sidebar toolbar now includes search icon (🔍) for quick access -- Search results can be displayed in sidebar view or QuickPick -- Search index automatically updates when notes are created/edited/deleted +- Sidebar toolbar gains filter/expired toggles -### Testing -- **64 comprehensive unit tests** for search components -- **SearchManager tests** (20+ tests): - - Full-text search with various queries - - Regex pattern matching - - Author filtering with edge cases - - Date range filtering - - Filter combinations (AND logic) - - Search index updates - - Performance benchmarks -- **SearchUI tests** (10+ tests): - - QuickPick integration - - Keyboard navigation - - Filter UI components - - Result display and formatting -- **Integration tests** (10+ tests): - - Search with sidebar integration - - Real-time index updates - - Multi-note search scenarios -- **Performance testing**: - - ✅ Search with 100 notes: < 500ms - - ✅ Search with 500 notes: < 1 second - - ✅ Search with 1000 notes: < 2 seconds - - ✅ Index updates: < 100ms -- All tests compile successfully with TypeScript -- Existing unit tests continue to pass -- **Manual testing completed successfully**: - - ✅ Full-text search across workspace - - ✅ Filter by author, date, file path - - ✅ Filter combinations - - ✅ Keyboard shortcuts and navigation - - ✅ Search history and recent searches - - ✅ Performance with large note collections - -### Technical -- Created `SearchManager` class for search indexing and queries -- Created `SearchUI` class for VSCode QuickPick integration -- Implemented inverted index for fast full-text search -- Added search metadata indexing (author, dates, file paths) -- Implemented search result ranking algorithm -- Added in-memory caching for search results -- **New commands**: - - `searchNotes` - Open search panel with QuickPick - - `clearSearchFilters` - Clear all active filters - - `showSearchHistory` - Show recent searches - - `rebuildSearchIndex` - Manually rebuild search index -- Added `search` contribution to package.json -- Integrated search with existing sidebar view -- Background indexing on workspace open -- Debounced search (200ms delay) to prevent excessive queries -- Search index automatically updates on note CRUD operations -- **Configuration options**: - - `search.fuzzyMatching` - Enable fuzzy search (default: false) - - `search.caseSensitive` - Case-sensitive search (default: false) - - `search.maxResults` - Maximum results to display (default: 100) - - `search.debounceDelay` - Search delay in ms (default: 200) - - `search.saveHistory` - Save search history (default: true) - - `search.historySize` - Number of searches to keep (default: 20) - ---- - -## Benefits - -**For Users:** -- Find any note instantly without browsing files -- Quickly locate notes by specific authors -- Filter notes by time period (e.g., "last week's notes") -- Discover related notes across the codebase -- Fast keyboard-driven workflow -- Search history prevents re-typing common queries - -**For Teams:** -- Find all notes from specific team members during code reviews -- Locate all notes related to a feature or bug -- Review notes created during a sprint or time period -- Discover documentation and decisions across the project -- Better knowledge sharing and discovery - ---- +### Settings +- `codeContextNotes.exports.enabled` (default `true`), `.indexJson` (default `true`), `.agentsMarkdown` (default `true`) — control auto-export generation -## Migration Notes +### Compatibility +- Fully backwards-compatible with v0.2.x. Legacy notes load with `type=context`, `scope=line`, `priority=normal`, `tags=[]`, `authorType=human` applied in memory; on-disk file unchanged until next edit. +- Storage format extended with optional bold-label fields (`**Type:**`, `**Tags:**`, etc.). Fields equal to default are omitted, so untouched legacy notes remain byte-identical on disk. -- No breaking changes - purely additive feature -- No data migration required -- Works with existing note storage format -- Backward compatible with all existing notes -- Search can be disabled via keyboard shortcut preferences if not needed -- Search index built automatically on first use - ---- - -## Known Limitations - -- Search index stored in memory (not persisted) - rebuilds on workspace reload -- Maximum 100 results by default (configurable) -- Regex patterns limited by JavaScript RegExp capabilities -- No natural language search in initial release -- No "Replace in Notes" (bulk editing) - planned for future version - ---- +### Testing +- **58+ unit tests** covering schema defaults, storage round-trip (read + write), export generators (buildIndex/buildDigest determinism + section hoisting), debounced atomic writer (debounce coalescing + onError fail-safe), and lazy-migration integration test on NoteManager boundary -## Performance Notes +### Technical +- New modules: `src/noteDefaults.ts` (defaults + expiry helper), `src/exportGenerator.ts` (pure index/digest builders), `src/exportWriter.ts` (debounced atomic writer with injectable config getter) +- `NoteManager` wraps all storage reads with `applyDefaults` — single boundary for lazy migration +- Storage parser/writer in `src/storageManager.ts` extends the existing bold-label markdown format; unknown fields ignored gracefully +- New commands: `regenerateExports`, `setNoteMetadata`, `filterByType`, `toggleExpired` -- Search index uses < 10MB memory for 1000 notes -- Inverted index enables sub-second search -- Background indexing prevents UI blocking -- Lazy loading for large result sets -- Debouncing prevents excessive search operations -- Index updates incrementally on note changes +### Coming Next +- Search and Filter UI (GitHub Issue #10) ships in a follow-up release +- v0.4 ships a standalone MCP server backed by `INDEX.json` for direct agent integration (Cursor, Claude Code, etc.) --- -## Links - [0.3.0]: https://github.com/jnahian/code-context-notes/releases/tag/v0.3.0 diff --git a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md index 57e12ea..0a5656d 100644 --- a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md +++ b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md @@ -1312,7 +1312,7 @@ on every note change: Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: -``` +```gitignore .code-notes/INDEX.json .code-notes/AGENTS.md ``` diff --git a/docs/superpowers/specs/2026-05-15-agent-integration-design.md b/docs/superpowers/specs/2026-05-15-agent-integration-design.md index 266e7da..fd07bc3 100644 --- a/docs/superpowers/specs/2026-05-15-agent-integration-design.md +++ b/docs/superpowers/specs/2026-05-15-agent-integration-design.md @@ -130,7 +130,7 @@ interface Note { } ``` -**Storage:** stored in the markdown frontmatter (same format used today for metadata). Reader is forward-compatible (older versions ignore unknown keys). Writer omits any field equal to its default to keep frontmatter compact. +**Storage:** stored as bold-label metadata lines in the note markdown (`**Type:** …`, same format used today for `**Author:**` etc. — see the v0.3 plan's deviation note; an earlier draft said YAML frontmatter). Reader is forward-compatible (older versions ignore unknown labels). Writer omits any field equal to its default to keep the metadata block compact. **Sidebar UI in v0.3:** small type pill on each note item; filter dropdown (type / tags / expired). Add/edit flow gets a "More fields" expander for the new fields. Richer UI deferred to v0.5. @@ -374,7 +374,7 @@ The Revert button calls existing `noteManager` methods. No new storage primitive #### 5.4.6 Settings UI -``` +```text Code Context Notes › Agent write mode: [direct | queue | audit ▼] Code Context Notes › Agent allow-list: [claude-code, cursor-agent] Code Context Notes › Audit log retention: [last 1000 ops] @@ -499,9 +499,9 @@ Workspace can sit on v0.3 indefinitely; only edited notes get rewritten. No mass Conservative — no legacy note suddenly becomes an `instruction` or gets an expiry. -### 6.3 Frontmatter format +### 6.3 Metadata format -Writer omits any field equal to its default. A plain note that's still just a `context` note on a line range stays the shape it always had. +Bold-label lines, not YAML frontmatter (see §5.1 Storage). Writer omits any field equal to its default. A plain note that's still just a `context` note on a line range stays the shape it always had. ### 6.4 Backward compatibility @@ -588,7 +588,7 @@ Exports are a cache, not a source of truth. Markdown files are authoritative. ### 8.1 Test layout -``` +```text src/test/ # extension tests (existing, extended) packages/code-notes-core/test/ # NEW — extracted core packages/code-notes-mcp/test/ # NEW — MCP server diff --git a/package-lock.json b/package-lock.json index a12f0b5..1ecfd3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-context-notes", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-context-notes", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "uuid": "^14.0.0" diff --git a/package.json b/package.json index 0e220e3..204298c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "code-context-notes", "displayName": "Code Context Notes - Smart Annotations", "description": "Add contextual notes to your code with full version history and intelligent tracking", - "version": "0.2.1", + "version": "0.3.0", "publisher": "jnahian", "icon": "images/icon.png", "galleryBanner": { @@ -196,6 +196,28 @@ "title": "View History", "icon": "$(history)", "category": "Code Notes" + }, + { + "command": "codeContextNotes.regenerateExports", + "title": "Regenerate Exports (INDEX.json / AGENTS.md)", + "category": "Code Notes" + }, + { + "command": "codeContextNotes.filterByType", + "title": "Filter Notes by Type…", + "category": "Code Notes", + "icon": "$(filter)" + }, + { + "command": "codeContextNotes.toggleExpired", + "title": "Toggle Expired Notes", + "category": "Code Notes", + "icon": "$(eye)" + }, + { + "command": "codeContextNotes.setNoteMetadata", + "title": "Set Note Type / Tags / Priority…", + "category": "Code Notes" } ], "keybindings": [ @@ -272,6 +294,16 @@ "command": "codeContextNotes.refreshSidebar", "when": "view == codeContextNotes.sidebarView", "group": "navigation@3" + }, + { + "command": "codeContextNotes.filterByType", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation" + }, + { + "command": "codeContextNotes.toggleExpired", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation" } ], "view/item/context": [ @@ -419,6 +451,21 @@ ], "default": "file", "description": "Sort notes by file path" + }, + "codeContextNotes.exports.enabled": { + "type": "boolean", + "default": true, + "description": "Auto-generate INDEX.json and AGENTS.md in the notes storage directory (default .code-notes) on every note change" + }, + "codeContextNotes.exports.indexJson": { + "type": "boolean", + "default": true, + "description": "Generate INDEX.json (machine-readable)" + }, + "codeContextNotes.exports.agentsMarkdown": { + "type": "boolean", + "default": true, + "description": "Generate AGENTS.md (human-readable digest)" } } } @@ -429,7 +476,8 @@ "compile:tsc": "tsc -p ./", "watch": "node esbuild.config.js --watch", "watch:tsc": "tsc -watch -p ./", - "pretest": "npm run compile:tsc", + "clean": "rm -rf out", + "pretest": "npm run clean && npm run compile:tsc", "lint": "echo 'Linting skipped - eslint not configured'", "test": "node ./out/test/runTest.js", "test:unit": "npm run compile:tsc && node ./out/test/runUnitTests.js", diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index ac955d5..b3f4b33 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -134,8 +134,9 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Remove images .replace(/!\[([^\]]*)\]\([^\)]+\)/g, '$1') - // Remove headings - .replace(/^#{1,6}\s+/gm, '') + // Remove heading markers (line-start and after whitespace — previews + // are single lines, so stray mid-text markers should go too) + .replace(/(^|\s)#{1,6}\s+/g, '$1') // Remove blockquotes .replace(/^>\s+/gm, '') // Remove list markers @@ -155,8 +156,9 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { if (notes.length === 1) { const note = notes[0]; // Strip markdown formatting and get first line - const plainText = this.stripMarkdown(note.content); - const firstLine = plainText.split('\n')[0]; + // Take the first line BEFORE stripping — stripMarkdown collapses + // newlines into spaces, which would merge all lines into one. + const firstLine = this.stripMarkdown(note.content.split('\n')[0]); const preview = firstLine.length > 50 ? firstLine.substring(0, 47) + '...' : firstLine; @@ -171,8 +173,7 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { : uniqueAuthors.join(', '); // Get preview from first note - const plainText = this.stripMarkdown(notes[0].content); - const firstLine = plainText.split('\n')[0]; + const firstLine = this.stripMarkdown(notes[0].content.split('\n')[0]); const preview = firstLine.length > 35 ? firstLine.substring(0, 32) + '...' : firstLine; diff --git a/src/commentController.ts b/src/commentController.ts index 390c83d..1b1ca9f 100644 --- a/src/commentController.ts +++ b/src/commentController.ts @@ -18,6 +18,7 @@ export class CommentController { private commentThreads: Map; // threadKey (lineKey) -> CommentThread private threadStates: Map; // threadKey -> state private currentlyEditingNoteId: string | null = null; // Track which note is being edited + private currentlyEditingThreadKey: string | null = null; // Thread key for the note being edited private currentlyCreatingThreadId: string | null = null; // Track temporary ID of thread being created constructor(noteManager: NoteManager, context: vscode.ExtensionContext) { @@ -187,7 +188,9 @@ export class CommentController { ? new Date(note.history[note.history.length - 1].timestamp) : createdDate; - const isUpdated = note.history && note.history.length > 0; + // The first history entry is the creation itself — a note only counts + // as updated once it has more history than that + const isUpdated = note.history && note.history.length > 1; const label = isUpdated ? `Last updated ${lastUpdated.toLocaleDateString()}` : `Created ${createdDate.toLocaleDateString()}`; @@ -424,6 +427,7 @@ export class CommentController { // Clear editing state only if we're not keeping a specific thread if (!exceptThreadKey) { this.currentlyEditingNoteId = null; + this.currentlyEditingThreadKey = null; this.currentlyCreatingThreadId = null; } } @@ -759,6 +763,7 @@ export class CommentController { // Track which note is being edited this.currentlyEditingNoteId = noteId; + this.currentlyEditingThreadKey = threadKey; // Expand the thread thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; @@ -783,11 +788,12 @@ export class CommentController { * so this should return the latest content */ getCurrentlyEditingComment(): vscode.Comment | null { - if (!this.currentlyEditingNoteId) { + if (!this.currentlyEditingNoteId || !this.currentlyEditingThreadKey) { return null; } - const thread = this.commentThreads.get(this.currentlyEditingNoteId); + // commentThreads is keyed by thread key (file + line), not note id + const thread = this.commentThreads.get(this.currentlyEditingThreadKey); if (!thread || thread.comments.length === 0) { return null; } @@ -859,6 +865,7 @@ export class CommentController { // Clear editing state this.currentlyEditingNoteId = null; + this.currentlyEditingThreadKey = null; return true; } diff --git a/src/exportGenerator.ts b/src/exportGenerator.ts new file mode 100644 index 0000000..662674b --- /dev/null +++ b/src/exportGenerator.ts @@ -0,0 +1,187 @@ +/** + * Pure functions that build the contents of INDEX.json and AGENTS.md + * from a list of notes. No I/O — call these from exportWriter. + */ + +import * as path from 'path'; +import { Note, NoteType } from './types.js'; +import { applyDefaults, isExpired } from './noteDefaults.js'; + +export interface IndexNoteEntry { + id: string; + filePath: string; + lineRange: { start: number; end: number }; + type: NoteType; + scope: string; + tags: string[]; + priority: string; + author: string; + authorType: string; + createdAt: string; + updatedAt: string; + expiresAt: string | null; + isExpired: boolean; + references: { kind: string; value: string; label?: string }[]; + contentPreview: string; + contentPath: string; +} + +export interface IndexFile { + version: 1; + generatedAt: string; + workspaceRoot: string; + notes: IndexNoteEntry[]; + byFile: Record; + byType: Record; + byTag: Record; + errors: { file: string; message: string }[]; +} + +const PREVIEW_MAX = 200; + +function relativePath(absPath: string, workspaceRoot: string): string { + return path.relative(workspaceRoot, absPath).split(path.sep).join('/'); +} + +function preview(content: string): string { + const single = content.replace(/\s+/g, ' ').trim(); + return single.length <= PREVIEW_MAX ? single : single.slice(0, PREVIEW_MAX) + '…'; +} + +/** + * Build the INDEX.json data. `now` is injectable for deterministic tests. + * `storageDir` should match the workspace's configured + * `codeContextNotes.storageDirectory` (default `.code-notes`) so + * `contentPath` entries point at real note files. + */ +export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date(), storageDir: string = '.code-notes'): IndexFile { + const notes = rawNotes.map(applyDefaults); + + // Stable sort by id so output is deterministic for the same input set. + const sorted = [...notes].sort((a, b) => a.id.localeCompare(b.id)); + + const entries: IndexNoteEntry[] = sorted.map(n => ({ + id: n.id, + filePath: relativePath(n.filePath, workspaceRoot), + lineRange: n.lineRange, + type: n.type!, + scope: n.scope!, + tags: n.tags!, + priority: n.priority!, + author: n.author, + authorType: n.authorType!, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + expiresAt: n.expiresAt ?? null, + isExpired: isExpired(n, now), + references: n.references!, + contentPreview: preview(n.content), + contentPath: `${storageDir}/${n.id}.md`, + })); + + const byFile: Record = {}; + const byType: Record = {}; + const byTag: Record = {}; + + for (const e of entries) { + (byFile[e.filePath] ??= []).push(e.id); + (byType[e.type] ??= []).push(e.id); + for (const t of e.tags) { + (byTag[t] ??= []).push(e.id); + } + } + + return { + version: 1, + generatedAt: now.toISOString(), + workspaceRoot, + notes: entries, + byFile, + byType, + byTag, + errors: [], + }; +} + +const PRIORITY_RANK: Record = { critical: 0, high: 1, normal: 2, low: 3 }; + +/** + * Stable tiebreak for notes that rank equally on their primary sort key + * (priority, or no sort key at all), so section ordering doesn't depend + * on filesystem scan order across regenerations. + */ +function tiebreak(workspaceRoot: string): (a: Note, b: Note) => number { + return (a, b) => + relativePath(a.filePath, workspaceRoot).localeCompare(relativePath(b.filePath, workspaceRoot)) || + (a.lineRange.start - b.lineRange.start) || + (a.lineRange.end - b.lineRange.end) || + a.id.localeCompare(b.id); +} + +/** + * Build the AGENTS.md digest content. `storageDir` should match the + * workspace's configured `codeContextNotes.storageDirectory`. + */ +export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = new Date(), storageDir: string = '.code-notes'): string { + const notes = rawNotes.map(applyDefaults).filter(n => !isExpired(n, now)); + const tb = tiebreak(workspaceRoot); + + const out: string[] = []; + out.push('# Code Notes Digest'); + // No INDEX.json source pointer here — exports.indexJson can be disabled + // independently, which would make that claim stale. + out.push('*Auto-generated by Code Context Notes. Do not edit.*'); + out.push(''); + + const critical = notes + .filter(n => n.type === 'instruction' || n.type === 'warning') + .sort((a, b) => ((PRIORITY_RANK[a.priority!] ?? 99) - (PRIORITY_RANK[b.priority!] ?? 99)) || tb(a, b)); + if (critical.length > 0) { + out.push('## Critical instructions and warnings'); + for (const n of critical) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\` — ${n.type} (${n.priority}):** ${preview(n.content)}`); + } + out.push(''); + } + + const handoffs = notes.filter(n => n.type === 'handoff').sort(tb); + if (handoffs.length > 0) { + out.push('## Open handoffs'); + for (const n of handoffs) { + const range = n.lineRange.start === n.lineRange.end ? `${n.lineRange.start + 1}` : `${n.lineRange.start + 1}-${n.lineRange.end + 1}`; + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${range}\`** — handoff from ${n.author}: ${preview(n.content)}`); + } + out.push(''); + } + + const decisions = notes.filter(n => n.type === 'decision').sort(tb); + if (decisions.length > 0) { + out.push('## Decisions worth knowing'); + for (const n of decisions) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\`** — decision: ${preview(n.content)}`); + } + out.push(''); + } + + // All notes by file, sorted + out.push('## All notes by file'); + const byFile = new Map(); + for (const n of notes) { + const key = relativePath(n.filePath, workspaceRoot); + const arr = byFile.get(key) ?? []; + arr.push(n); + byFile.set(key, arr); + } + const fileKeys = Array.from(byFile.keys()).sort(); + for (const file of fileKeys) { + out.push(`### \`${file}\``); + const fileNotes = byFile.get(file)!.sort((a, b) => (a.lineRange.start - b.lineRange.start) || (a.lineRange.end - b.lineRange.end) || a.id.localeCompare(b.id)); + for (const n of fileNotes) { + const tags = n.tags!.length > 0 ? ` [${n.tags!.join(', ')}]` : ''; + out.push(`- L${n.lineRange.start + 1} — ${n.type} (${n.priority})${tags}: ${preview(n.content)}`); + } + out.push(''); + } + + return out.join('\n'); +} diff --git a/src/exportWriter.ts b/src/exportWriter.ts new file mode 100644 index 0000000..0725c9c --- /dev/null +++ b/src/exportWriter.ts @@ -0,0 +1,110 @@ +/** + * Debounced atomic writer for INDEX.json and AGENTS.md. + * + * Owns the debounce timer and the temp-file-and-rename logic. Failures + * are surfaced via the onError callback — never thrown — because export + * regeneration is best-effort and must never block a note write. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { Note } from './types.js'; +import { buildIndex, buildDigest } from './exportGenerator.js'; + +export interface ExportWriterOptions { + debounceMs?: number; + getConfig?: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; +} + +export class ExportWriter { + private workspaceRoot: string; + private storageDir: string; + private debounceMs: number; + private pendingTimer: NodeJS.Timeout | undefined; + private pendingGetNotes: (() => Promise) | undefined; + private getConfig: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; + private tmpSeq = 0; + private writeQueue: Promise = Promise.resolve(); + onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); + + constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { + this.workspaceRoot = workspaceRoot; + this.storageDir = storageDir; + this.debounceMs = opts.debounceMs ?? 200; + this.getConfig = opts.getConfig ?? (() => ({ enabled: true, indexJson: true, agentsMarkdown: true })); + } + + /** + * Schedule a regeneration. Subsequent calls within the debounce window + * coalesce — only the latest getNotes is used. + */ + scheduleRegenerate(getNotes: () => Promise): void { + this.pendingGetNotes = getNotes; + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + } + this.pendingTimer = setTimeout(() => { + const fn = this.pendingGetNotes; + this.pendingTimer = undefined; + this.pendingGetNotes = undefined; + if (fn) { + fn().then(notes => this.regenerate(notes)).catch(e => this.onError(e)); + } + }, this.debounceMs); + } + + /** + * Force an immediate (non-debounced) regeneration. Used at startup, + * by the manual command, and by tests. Returns what happened so callers + * (e.g. the manual command) can report honestly; errors still route + * through onError rather than throwing. + * + * Calls are serialized on `writeQueue` so the debounced timer and a + * manual regenerate can't interleave their INDEX.json/AGENTS.md writes. + */ + regenerate(notes: Note[]): Promise<'written' | 'disabled' | 'failed'> { + const run = this.writeQueue.then(() => this.doRegenerate(notes)); + this.writeQueue = run.catch(() => {}); + return run; + } + + private async doRegenerate(notes: Note[]): Promise<'written' | 'disabled' | 'failed'> { + try { + const cfg = this.getConfig(); + if (!cfg.enabled) return 'disabled'; + + const dir = path.join(this.workspaceRoot, this.storageDir); + await fs.mkdir(dir, { recursive: true }); + if (cfg.indexJson) { + const idx = buildIndex(notes, this.workspaceRoot, new Date(), this.storageDir); + await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); + } + if (cfg.agentsMarkdown) { + const digest = buildDigest(notes, this.workspaceRoot, new Date(), this.storageDir); + await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + } + return 'written'; + } catch (e: unknown) { + this.onError(e instanceof Error ? e : new Error(String(e))); + return 'failed'; + } + } + + private async atomicWrite(targetPath: string, content: string): Promise { + // Unique temp name so overlapping regenerations can't clobber each + // other's temp file; the final rename stays atomic. + const tmpPath = `${targetPath}.${process.pid}.${++this.tmpSeq}.tmp`; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, targetPath); + } + + /** + * Cancel any pending debounced regeneration. Call from extension deactivate. + */ + dispose(): void { + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} diff --git a/src/extension.ts b/src/extension.ts index f44ec44..5c17b19 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ */ import * as vscode from 'vscode'; +import * as path from 'path'; import { StorageManager } from './storageManager.js'; import { ContentHashTracker } from './contentHashTracker.js'; import { GitIntegration } from './gitIntegration.js'; @@ -11,8 +12,10 @@ import { CommentController } from './commentController.js'; import { CodeNotesLensProvider } from './codeLensProvider.js'; import { NotesSidebarProvider } from './notesSidebarProvider.js'; import { SearchManager } from './searchManager.js'; +import { ExportWriter } from './exportWriter.js'; let noteManager: NoteManager; +let exportWriter: ExportWriter; let searchManager: SearchManager; let commentController: CommentController; let codeLensProvider: CodeNotesLensProvider; @@ -27,7 +30,7 @@ const DEBOUNCE_DELAY = 500; // ms */ export async function activate(context: vscode.ExtensionContext) { console.log('Code Context Notes extension is activating...'); - console.log('Code Context Notes: Extension version 0.1.3'); + console.log(`Code Context Notes: Extension version ${context.extension.packageJSON.version}`); // Get workspace folder const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; @@ -89,6 +92,34 @@ export async function activate(context: vscode.ExtensionContext) { await searchManager.buildIndex(allNotes); console.log(`Code Context Notes: Search index built with ${allNotes.length} notes`); + // Initialize export writer and hook into note changes + exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { + debounceMs: 200, + getConfig: () => { + const cfg = vscode.workspace.getConfiguration('codeContextNotes.exports'); + return { + enabled: cfg.get('enabled', true), + indexJson: cfg.get('indexJson', true), + agentsMarkdown: cfg.get('agentsMarkdown', true), + }; + }, + }); + context.subscriptions.push({ dispose: () => exportWriter.dispose() }); + + // Hooks are always attached; ExportWriter.regenerate reads the + // exports.enabled setting on every run, so toggling it takes effect + // in both directions without a reload. + // Initial export on activation (covers fresh installs, manual deletes) + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + // Notes changed through the extension + noteManager.on('noteChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); + // Note files changed externally (git pull, manual edits) + noteManager.on('noteFileChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); + // Initialize comment controller commentController = new CommentController(noteManager, context); @@ -932,6 +963,121 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Regenerate Exports + const regenerateExportsCommand = vscode.commands.registerCommand( + 'codeContextNotes.regenerateExports', + async () => { + if (!noteManager || !exportWriter) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + const exportsCfg = vscode.workspace.getConfiguration('codeContextNotes.exports'); + if (!exportsCfg.get('enabled', true)) { + vscode.window.showWarningMessage('Code Notes: exports are disabled (codeContextNotes.exports.enabled is false). Nothing was written.'); + return; + } + + // regenerate() never throws — failures go through onError — so wrap + // it here to give this manual command an accurate success/failure message. + const previousOnError = exportWriter.onError; + let failure: Error | undefined; + exportWriter.onError = (e) => { failure = e; previousOnError(e); }; + + try { + const notes = await noteManager.getAllNotes(); + const outcome = await exportWriter.regenerate(notes); + if (outcome === 'written') { + vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + } else if (outcome === 'disabled') { + vscode.window.showWarningMessage('Code Notes: exports are disabled (codeContextNotes.exports.enabled). Nothing was written.'); + } else { + vscode.window.showErrorMessage(`Failed to regenerate exports: ${failure ? failure.message : 'see the developer console for details'}`); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to regenerate exports: ${error}`); + } finally { + exportWriter.onError = previousOnError; + } + } + ); + + // Filter Notes by Type + const filterByTypeCommand = vscode.commands.registerCommand( + 'codeContextNotes.filterByType', + async () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + const choices = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; + const picked = await vscode.window.showQuickPick(choices, { + canPickMany: true, + title: 'Show only notes of these types (cancel to clear filter)', + }); + sidebarProvider.setTypeFilter(picked && picked.length > 0 ? new Set(picked) : null); + } + ); + + // Toggle Expired Notes + const toggleExpiredCommand = vscode.commands.registerCommand( + 'codeContextNotes.toggleExpired', + () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + sidebarProvider.toggleHideExpired(); + } + ); + + // Set Note Metadata (type, priority, tags, expiry) + const setNoteMetadataCommand = vscode.commands.registerCommand( + 'codeContextNotes.setNoteMetadata', + async (noteIdArg?: string) => { + if (!noteManager) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + let noteId = noteIdArg; + if (!noteId) { + const all = await noteManager.getAllNotes(); + const pick = await vscode.window.showQuickPick( + all.map(n => ({ label: n.id, description: n.content.slice(0, 60) })), + { title: 'Pick a note to update' }, + ); + if (!pick) return; + noteId = pick.label; + } + + const type = await vscode.window.showQuickPick( + ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale'], + { title: 'Type' }, + ); + if (!type) return; + + const priority = await vscode.window.showQuickPick( + ['low', 'normal', 'high', 'critical'], + { title: 'Priority' }, + ); + if (!priority) return; + + const tagsRaw = await vscode.window.showInputBox({ title: 'Tags (comma-separated, optional)' }); + const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; + + const expiresRaw = await vscode.window.showInputBox({ title: 'Expires at (ISO 8601, optional)' }); + const expiresAt = expiresRaw && expiresRaw.trim().length > 0 ? expiresRaw.trim() : undefined; + + try { + await noteManager.updateNoteMetadata(noteId, { type: type as any, priority: priority as any, tags, expiresAt }); + vscode.window.showInformationMessage(`Code Notes: updated metadata for ${noteId}.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to update note metadata: ${error}`); + } + } + ); + // Register all commands context.subscriptions.push( addNoteCommand, @@ -963,7 +1109,11 @@ function registerAllCommands(context: vscode.ExtensionContext) { editNoteFromSidebarCommand, deleteNoteFromSidebarCommand, viewNoteHistoryFromSidebarCommand, - openFileFromSidebarCommand + openFileFromSidebarCommand, + regenerateExportsCommand, + filterByTypeCommand, + toggleExpiredCommand, + setNoteMetadataCommand ); } @@ -1063,8 +1213,15 @@ function setupEventListeners(context: vscode.ExtensionContext) { ); const fileWatcher = vscode.workspace.createFileSystemWatcher(fileWatcherPattern); + // AGENTS.md is a generated export living in the same directory; treating + // it as a note would make export regeneration re-trigger itself forever. + const isGeneratedExport = (uri: vscode.Uri) => path.basename(uri.fsPath) === 'AGENTS.md'; + // When a note file is created fileWatcher.onDidCreate((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file created: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); @@ -1073,6 +1230,9 @@ function setupEventListeners(context: vscode.ExtensionContext) { // When a note file is changed fileWatcher.onDidChange((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file changed: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); @@ -1081,6 +1241,9 @@ function setupEventListeners(context: vscode.ExtensionContext) { // When a note file is deleted fileWatcher.onDidDelete((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file deleted: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); diff --git a/src/noteDefaults.ts b/src/noteDefaults.ts new file mode 100644 index 0000000..a58eb52 --- /dev/null +++ b/src/noteDefaults.ts @@ -0,0 +1,40 @@ +/** + * Apply schema defaults to a Note loaded from disk. + * Legacy notes from v0.2.x have no structured fields; this fills them + * in memory only. The on-disk file is not modified. + */ + +import { Note, NoteType, NoteScope, AuthorType, NotePriority } from './types.js'; + +export const NOTE_DEFAULTS = { + type: 'context' as NoteType, + scope: 'line' as NoteScope, + tags: [] as string[], + references: [] as never[], // typed as never — replaced via applyDefaults + priority: 'normal' as NotePriority, + authorType: 'human' as AuthorType, +}; + +/** + * Returns a new Note with all optional fields populated. + * Does not mutate the input. + */ +export function applyDefaults(note: Note): Note { + return { + ...note, + type: note.type ?? NOTE_DEFAULTS.type, + scope: note.scope ?? NOTE_DEFAULTS.scope, + tags: note.tags ?? [], + references: note.references ?? [], + priority: note.priority ?? NOTE_DEFAULTS.priority, + authorType: note.authorType ?? NOTE_DEFAULTS.authorType, + }; +} + +/** + * True if `expiresAt` is set and is at or before `now`. + */ +export function isExpired(note: Note, now: Date = new Date()): boolean { + if (!note.expiresAt) return false; + return new Date(note.expiresAt).getTime() <= now.getTime(); +} diff --git a/src/noteManager.ts b/src/noteManager.ts index 0c8bc8f..4be1079 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -7,11 +7,12 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { EventEmitter } from 'events'; -import { Note, CreateNoteParams, UpdateNoteParams, LineRange } from './types.js'; +import { Note, CreateNoteParams, UpdateNoteParams, LineRange, NoteType, NotePriority, NoteScope } from './types.js'; import { StorageManager } from './storageManager.js'; import { ContentHashTracker } from './contentHashTracker.js'; import { GitIntegration } from './gitIntegration.js'; import { SearchManager } from './searchManager.js'; +import { applyDefaults } from './noteDefaults.js'; /** * NoteManager coordinates all note operations @@ -95,23 +96,28 @@ export class NoteManager extends EventEmitter { isDeleted: false }; + // Normalize before save/cache so this note matches the shape every other + // load path guarantees (applyDefaults is a no-op for on-disk serialization + // since storageManager omits fields already equal to their default). + const normalized = applyDefaults(note); + // Save to storage - await this.storage.saveNote(note); + await this.storage.saveNote(normalized); // Update cache - this.addNoteToCache(note); + this.addNoteToCache(normalized); // Update search index if (this.searchManager) { - await this.searchManager.updateIndex(note); + await this.searchManager.updateIndex(normalized); } // Clear workspace cache and emit events this.clearWorkspaceCache(); - this.emit('noteCreated', note); - this.emit('noteChanged', { type: 'created', note }); + this.emit('noteCreated', normalized); + this.emit('noteChanged', { type: 'created', note: normalized }); - return note; + return normalized; } /** @@ -170,6 +176,41 @@ export class NoteManager extends EventEmitter { return note; } + /** + * Update only the metadata fields of an existing note (type, priority, tags, expiresAt, scope). + * Does not touch content, lineRange, contentHash, or history. + */ + async updateNoteMetadata( + noteId: string, + fields: { type?: NoteType; priority?: NotePriority; tags?: string[]; expiresAt?: string; scope?: NoteScope }, + ): Promise { + const existing = await this.storage.loadNoteById(noteId); + if (!existing) throw new Error(`Note ${noteId} not found`); + if (existing.isDeleted) throw new Error(`Cannot update deleted note ${noteId}`); + + // Merge: only overwrite fields explicitly provided, then normalize + // defaults so cache/consumers see the same shape as every other load path. + const updated: Note = applyDefaults({ + ...existing, + ...fields, + updatedAt: new Date().toISOString(), + }); + await this.storage.saveNote(updated); + + // Mirror the cache-invalidation pattern used in updateNote + this.updateNoteInCache(updated); + this.clearWorkspaceCache(); + + // Keep search index in sync (updatedAt / metadata affect search results) + if (this.searchManager) { + await this.searchManager.updateIndex(updated); + } + + this.emit('noteUpdated', updated); + this.emit('noteChanged', { type: 'updated', note: updated }); + return updated; + } + /** * Delete a note (soft delete) */ @@ -200,8 +241,10 @@ export class NoteManager extends EventEmitter { // Save to storage await this.storage.saveNote(note); - // Remove from cache - this.removeNoteFromCache(noteId, filePath); + // Keep the soft-deleted note in the cache (the cache holds ALL notes; + // getNotesForFile filters deleted ones at return). Removing it here + // would make "already deleted" lookups report "not found" instead. + this.updateNoteInCache(note); // Remove from search index if (this.searchManager) { @@ -223,8 +266,11 @@ export class NoteManager extends EventEmitter { return this.noteCache.get(filePath)!.filter(n => !n.isDeleted); } - // Load from storage - const notes = await this.storage.loadNotes(filePath); + // Load from storage and apply defaults at the boundary. The cache always + // holds ALL notes (including soft-deleted) — getAllNotesForFile shares + // this cache, so caching a pre-filtered list here would make deleted + // notes invisible to it. + const notes = (await this.storage.loadAllNotes(filePath)).map(applyDefaults); // Update cache this.noteCache.set(filePath, notes); @@ -242,8 +288,8 @@ export class NoteManager extends EventEmitter { return this.noteCache.get(filePath)!; } - // Load from storage (including deleted notes) - const notes = await this.storage.loadAllNotes(filePath); + // Load from storage (including deleted notes) and apply defaults at the boundary + const notes = (await this.storage.loadAllNotes(filePath)).map(applyDefaults); // Update cache this.noteCache.set(filePath, notes); @@ -256,7 +302,9 @@ export class NoteManager extends EventEmitter { */ async getNoteById(noteId: string, filePath: string): Promise { const notes = await this.getAllNotesForFile(filePath); - return notes.find(n => n.id === noteId); + // Soft-deleted notes are not retrievable through the by-id lookup — + // consumers (comment threads, edit mode) must treat them as gone + return notes.find(n => n.id === noteId && !n.isDeleted); } /** @@ -322,38 +370,29 @@ export class NoteManager extends EventEmitter { } /** - * Add a note to the cache + * Add a note to the cache. Defaults are applied here so cached notes + * always honor the NoteManager boundary guarantee. */ private addNoteToCache(note: Note): void { const notes = this.noteCache.get(note.filePath) || []; - notes.push(note); + notes.push(applyDefaults(note)); this.noteCache.set(note.filePath, notes); } /** - * Update a note in the cache + * Update a note in the cache. Defaults are applied here so cached notes + * always honor the NoteManager boundary guarantee. */ private updateNoteInCache(updatedNote: Note): void { const notes = this.noteCache.get(updatedNote.filePath); if (notes) { const index = notes.findIndex(n => n.id === updatedNote.id); if (index !== -1) { - notes[index] = updatedNote; + notes[index] = applyDefaults(updatedNote); } } } - /** - * Remove a note from the cache - */ - private removeNoteFromCache(noteId: string, filePath: string): void { - const notes = this.noteCache.get(filePath); - if (notes) { - const filtered = notes.filter(n => n.id !== noteId); - this.noteCache.set(filePath, filtered); - } - } - /** * Clear the cache for a specific file */ @@ -453,7 +492,9 @@ export class NoteManager extends EventEmitter { for (const noteFilePath of allNoteFiles) { try { const noteId = this.extractNoteIdFromFilePath(noteFilePath); - const note = await this.storage.loadNoteById(noteId); + const rawNote = await this.storage.loadNoteById(noteId); + // Apply defaults at the boundary before cache/consumer use + const note = rawNote ? applyDefaults(rawNote) : null; // Include only non-deleted notes if (note && !note.isDeleted) { diff --git a/src/noteTreeItem.ts b/src/noteTreeItem.ts index 19c8e57..93edcdd 100644 --- a/src/noteTreeItem.ts +++ b/src/noteTreeItem.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Note } from './types.js'; +import { applyDefaults } from './noteDefaults.js'; /** * Base class for all tree items @@ -79,8 +80,12 @@ export class NoteTreeItem extends BaseTreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = note.author; // Shows right-aligned + const filled = applyDefaults(note); + if (filled.type && filled.type !== 'context') { + this.description = `${this.description ?? ''} · ${filled.type}`.trim(); + } this.contextValue = 'noteNode'; - this.tooltip = this.createTooltip(); + this.tooltip = this.createTooltip(filled.priority); this.iconPath = new vscode.ThemeIcon('note'); // Command to navigate to note when clicked @@ -94,7 +99,7 @@ export class NoteTreeItem extends BaseTreeItem { /** * Create rich tooltip with full note content */ - private createTooltip(): vscode.MarkdownString { + private createTooltip(priority?: string): vscode.MarkdownString { const tooltip = new vscode.MarkdownString(); tooltip.isTrusted = true; tooltip.supportHtml = true; @@ -103,6 +108,9 @@ export class NoteTreeItem extends BaseTreeItem { const created = new Date(this.note.createdAt).toLocaleString(); const updated = new Date(this.note.updatedAt).toLocaleString(); + if (priority === 'high' || priority === 'critical') { + tooltip.appendMarkdown(`**[${priority}]**\n\n`); + } tooltip.appendMarkdown(`**${lineRange}**\n\n`); tooltip.appendMarkdown(`**Author:** ${this.note.author}\n\n`); tooltip.appendMarkdown(`**Created:** ${created}\n\n`); @@ -130,10 +138,11 @@ export class NoteTreeItem extends BaseTreeItem { .replace(/_([^_]+)_/g, '$1') // Remove strikethrough .replace(/~~([^~]+)~~/g, '$1') + // Remove images (before links — the link pattern would otherwise + // match the [alt](url) part and leave a stray '!') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // Remove links but keep text .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - // Remove images - .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // Remove headings .replace(/^#+\s+/gm, '') // Remove list markers @@ -155,16 +164,17 @@ export class NoteTreeItem extends BaseTreeItem { maxLength = 0; } - // For very small maxLength (<=3), just return substring without ellipsis - if (maxLength <= 3) { - return text.substring(0, maxLength); - } - // If text fits, return unchanged if (text.length <= maxLength) { return text; } + // For very small maxLength (<=3) there is no room for content plus + // ellipsis — the ellipsis itself is the truncation marker + if (maxLength <= 3) { + return '.'.repeat(maxLength); + } + // Otherwise, truncate and add ellipsis return text.substring(0, maxLength - 3) + '...'; } diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index f8ddac4..3d9a60d 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { NoteManager } from './noteManager.js'; import { Note } from './types.js'; import { RootTreeItem, FileTreeItem, NoteTreeItem, BaseTreeItem } from './noteTreeItem.js'; +import { applyDefaults, isExpired } from './noteDefaults.js'; /** * Notes Sidebar Provider @@ -22,6 +23,9 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider | null = null; // null = no filter + private hideExpired: boolean = true; + constructor( private readonly noteManager: NoteManager, private readonly workspaceRoot: string, @@ -73,6 +77,33 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider | null): void { + this.typeFilter = types; + this._onDidChangeTreeData.fire(); + } + + /** + * Toggle visibility of expired notes. + */ + toggleHideExpired(): void { + this.hideExpired = !this.hideExpired; + this._onDidChangeTreeData.fire(); + } + + /** + * Apply the active type/expiry filters to a list of notes. + * Single filtering point shared by the root count, file nodes, and leaves. + */ + private applyNoteFilters(notes: Note[]): Note[] { + return notes + .map(applyDefaults) + .filter(n => !this.hideExpired || !isExpired(n)) + .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); + } + /** * Get tree item for a node (required by TreeDataProvider) */ @@ -87,15 +118,16 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider { // Root level: return RootTreeItem or empty state if (!element) { - const noteCount = await this.noteManager.getNoteCount(); + const fileNodes = await this.getFileNodes(); - // Empty state - no notes - if (noteCount === 0) { + // Empty state - no visible notes (all filtered out counts too) + if (fileNodes.length === 0) { return []; } - // Return root node with count - return [new RootTreeItem(noteCount)]; + // Return root node with count of notes visible under active filters + const visibleNoteCount = fileNodes.reduce((sum, node) => sum + node.notes.length, 0); + return [new RootTreeItem(visibleNoteCount)]; } // Root node: return file nodes @@ -120,10 +152,12 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider 0) { - fileNodes.push(new FileTreeItem(filePath, notes, this.workspaceRoot)); + const visible = this.applyNoteFilters(notes); + if (visible.length > 0) { + fileNodes.push(new FileTreeItem(filePath, visible, this.workspaceRoot)); } } @@ -161,18 +195,16 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider a.lineRange.start - b.lineRange.start) + .map(note => new NoteTreeItem(note, previewLength)); } /** diff --git a/src/storageManager.ts b/src/storageManager.ts index 5c1cc18..09abc48 100644 --- a/src/storageManager.ts +++ b/src/storageManager.ts @@ -6,7 +6,16 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; -import { Note, NoteStorage, NoteMetadata } from './types.js'; +import { Note, NoteStorage, NoteMetadata, NoteType, NoteScope, NotePriority, AuthorType, NoteReference } from './types.js'; +import { NOTE_DEFAULTS } from './noteDefaults.js'; + +// Allowed values for structured fields parsed from note markdown. +// Must stay in sync with the union types in types.ts. +const VALID_TYPES: NoteType[] = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; +const VALID_SCOPES: NoteScope[] = ['line', 'function', 'class', 'file', 'directory']; +const VALID_PRIORITIES: NotePriority[] = ['low', 'normal', 'high', 'critical']; +const VALID_AUTHOR_TYPES: AuthorType[] = ['human', 'agent']; +const VALID_REFERENCE_KINDS = ['note', 'pr', 'issue', 'commit', 'test', 'url']; /** * StorageManager implements the NoteStorage interface @@ -210,6 +219,30 @@ export class StorageManager implements NoteStorage { lines.push(`**Author:** ${note.author}`); lines.push(`**Created:** ${note.createdAt}`); lines.push(`**Updated:** ${note.updatedAt}`); + + // Structured fields — omitted if equal to default for compactness + if (note.type && note.type !== NOTE_DEFAULTS.type) { + lines.push(`**Type:** ${note.type}`); + } + if (note.scope && note.scope !== NOTE_DEFAULTS.scope) { + lines.push(`**Scope:** ${note.scope}`); + } + if (note.priority && note.priority !== NOTE_DEFAULTS.priority) { + lines.push(`**Priority:** ${note.priority}`); + } + if (note.tags && note.tags.length > 0) { + lines.push(`**Tags:** ${note.tags.join(', ')}`); + } + if (note.authorType && note.authorType !== NOTE_DEFAULTS.authorType) { + lines.push(`**AuthorType:** ${note.authorType}`); + } + if (note.expiresAt) { + lines.push(`**ExpiresAt:** ${note.expiresAt}`); + } + if (note.references && note.references.length > 0) { + lines.push(`**References:** ${JSON.stringify(note.references)}`); + } + if (note.isDeleted) { lines.push(`**Status:** DELETED`); } @@ -293,6 +326,67 @@ export class StorageManager implements NoteStorage { else if (line.startsWith('**Status:** DELETED')) { note.isDeleted = true; } + // Parse new structured fields — invalid values are dropped (the note + // then gets the schema default at the NoteManager boundary) + else if (line.startsWith('**Type:**')) { + const v = line.substring(9).trim(); + if ((VALID_TYPES as string[]).includes(v)) { + note.type = v as NoteType; + } else { + console.warn(`[code-notes] Ignoring invalid Type for note: ${v}`); + } + } + else if (line.startsWith('**Scope:**')) { + const v = line.substring(10).trim(); + if ((VALID_SCOPES as string[]).includes(v)) { + note.scope = v as NoteScope; + } else { + console.warn(`[code-notes] Ignoring invalid Scope for note: ${v}`); + } + } + else if (line.startsWith('**Priority:**')) { + const v = line.substring(13).trim(); + if ((VALID_PRIORITIES as string[]).includes(v)) { + note.priority = v as NotePriority; + } else { + console.warn(`[code-notes] Ignoring invalid Priority for note: ${v}`); + } + } + else if (line.startsWith('**Tags:**')) { + const raw = line.substring(9).trim(); + note.tags = raw ? raw.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; + } + else if (line.startsWith('**AuthorType:**')) { + const v = line.substring(15).trim(); + if ((VALID_AUTHOR_TYPES as string[]).includes(v)) { + note.authorType = v as AuthorType; + } else { + console.warn(`[code-notes] Ignoring invalid AuthorType for note: ${v}`); + } + } + else if (line.startsWith('**ExpiresAt:**')) { + note.expiresAt = line.substring(14).trim(); + } + else if (line.startsWith('**References:**')) { + const raw = line.substring(15).trim(); + if (raw) { + try { + const parsed = JSON.parse(raw); + // Salvage valid entries; drop malformed ones + note.references = Array.isArray(parsed) + ? parsed.filter( + (r: unknown): r is NoteReference => + !!r && typeof r === 'object' && + typeof (r as NoteReference).value === 'string' && + VALID_REFERENCE_KINDS.includes((r as NoteReference).kind) + ) + : []; + } catch { + console.warn(`[code-notes] Failed to parse References for note: ${raw}`); + note.references = []; + } + } + } // Parse current content section else if (line === '## Current Content') { inContent = true; diff --git a/src/test/runUnitTests.ts b/src/test/runUnitTests.ts index 900c0b7..27a6da7 100644 --- a/src/test/runUnitTests.ts +++ b/src/test/runUnitTests.ts @@ -35,7 +35,7 @@ async function main() { const unitTestFiles = files.filter(f => { const basename = path.basename(f); // Only include tests that don't require vscode - return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js'; + return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js' || basename === 'exportGenerator.test.js' || basename === 'exportWriter.test.js'; }); console.log(`Running ${unitTestFiles.length} unit tests (excluding integration tests)`); diff --git a/src/test/suite/commentController.test.ts b/src/test/suite/commentController.test.ts index 7f85b3e..e6b42e3 100644 --- a/src/test/suite/commentController.test.ts +++ b/src/test/suite/commentController.test.ts @@ -204,8 +204,8 @@ suite('CommentController Test Suite', () => { content: 'Updated content' }, document); - // Update thread - commentController.updateCommentThread(updatedNote, document); + // Update thread (async — refreshes the comment from storage) + await commentController.updateCommentThread(updatedNote, document); const updatedComment = thread.comments[0]; assert.ok((updatedComment.body as vscode.MarkdownString).value.includes('Updated content')); @@ -315,7 +315,7 @@ suite('CommentController Test Suite', () => { await commentController.handleDeleteNote(note.id, testFile); const deletedNote = await noteManager.getNoteById(note.id, testFile); - assert.strictEqual(deletedNote, null, 'Note should be deleted'); + assert.ok(!deletedNote, 'Note should be deleted'); }); }); @@ -324,6 +324,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -343,6 +345,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -367,6 +371,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -389,6 +395,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); // Create and update a note to generate history const note = await noteManager.createNote({ @@ -424,6 +432,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, diff --git a/src/test/suite/exportGenerator.test.ts b/src/test/suite/exportGenerator.test.ts new file mode 100644 index 0000000..6caf511 --- /dev/null +++ b/src/test/suite/exportGenerator.test.ts @@ -0,0 +1,90 @@ +import * as assert from 'assert'; +import { buildIndex, buildDigest } from '../../exportGenerator.js'; +import { Note } from '../../types.js'; + +const n = (overrides: Partial): Note => ({ + id: 'id', + content: 'content', + author: 'alice', + filePath: '/ws/src/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', + scope: 'line', + tags: [], + references: [], + priority: 'normal', + authorType: 'human', + ...overrides, +}); + +suite('exportGenerator', () => { + test('buildIndex contentPath honors a custom storage directory', () => { + const idx = buildIndex([n({ id: 'a' })], '/ws', new Date('2026-05-01T00:00:00Z'), '.my-notes'); + assert.strictEqual(idx.notes[0].contentPath, '.my-notes/a.md'); + // default stays .code-notes + const idxDefault = buildIndex([n({ id: 'a' })], '/ws', new Date('2026-05-01T00:00:00Z')); + assert.strictEqual(idxDefault.notes[0].contentPath, '.code-notes/a.md'); + }); + + test('buildIndex includes notes, byFile, byType, byTag', () => { + const notes = [ + n({ id: 'a', filePath: '/ws/src/foo.ts', type: 'instruction', tags: ['security'] }), + n({ id: 'b', filePath: '/ws/src/bar.ts', type: 'context' }), + ]; + const idx = buildIndex(notes, '/ws'); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.workspaceRoot, '/ws'); + assert.strictEqual(idx.notes.length, 2); + assert.deepStrictEqual(idx.byFile['src/foo.ts'], ['a']); + assert.deepStrictEqual(idx.byFile['src/bar.ts'], ['b']); + assert.deepStrictEqual(idx.byType['instruction'], ['a']); + assert.deepStrictEqual(idx.byType['context'], ['b']); + assert.deepStrictEqual(idx.byTag['security'], ['a']); + }); + + test('buildIndex marks expired notes', () => { + const past = new Date('2025-01-01T00:00:00Z').toISOString(); + const idx = buildIndex( + [n({ id: 'a', expiresAt: past })], + '/ws', + new Date('2026-05-01T00:00:00Z'), + ); + assert.strictEqual(idx.notes[0].isExpired, true); + }); + + test('buildIndex output is deterministic for same input', () => { + const notes = [ + n({ id: 'b', filePath: '/ws/b.ts' }), + n({ id: 'a', filePath: '/ws/a.ts' }), + ]; + const idx1 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + const idx2 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + assert.strictEqual(JSON.stringify(idx1), JSON.stringify(idx2)); + }); + + test('buildDigest hoists instructions and warnings', () => { + const notes = [ + n({ id: 'i', type: 'instruction', priority: 'high', content: 'do not bypass auth', filePath: '/ws/auth.ts', lineRange: { start: 41, end: 41 } }), + n({ id: 'c', type: 'context', content: 'just background', filePath: '/ws/auth.ts' }), + ]; + const md = buildDigest(notes, '/ws'); + const instructionPos = md.indexOf('do not bypass auth'); + const contextPos = md.indexOf('just background'); + assert.ok(instructionPos !== -1); + assert.ok(contextPos !== -1); + assert.ok(instructionPos < contextPos, 'instructions must appear before context'); + }); + + test('buildDigest hoists open handoffs to a dedicated section', () => { + const md = buildDigest( + [n({ id: 'h', type: 'handoff', content: 'pick up here', author: 'claude-code', authorType: 'agent' })], + '/ws', + ); + assert.ok(md.includes('## Open handoffs')); + assert.ok(md.includes('pick up here')); + }); +}); diff --git a/src/test/suite/exportWriter.test.ts b/src/test/suite/exportWriter.test.ts new file mode 100644 index 0000000..1a89643 --- /dev/null +++ b/src/test/suite/exportWriter.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { ExportWriter } from '../../exportWriter.js'; +import { Note } from '../../types.js'; + +async function tmpdir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'cn-export-')); +} + +const sampleNote = (id: string, ws: string): Note => ({ + id, + content: `note ${id}`, + author: 'alice', + filePath: path.join(ws, 'src/foo.ts'), + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}); + +suite('ExportWriter', () => { + test('writes INDEX.json and AGENTS.md atomically', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + await writer.regenerate([sampleNote('a', ws)]); + const idxRaw = await fs.readFile(path.join(ws, '.code-notes', 'INDEX.json'), 'utf-8'); + const idx = JSON.parse(idxRaw); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.notes[0].id, 'a'); + const digest = await fs.readFile(path.join(ws, '.code-notes', 'AGENTS.md'), 'utf-8'); + assert.ok(digest.startsWith('# Code Notes Digest')); + }); + + test('debounces rapid scheduleRegenerate calls', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 50 }); + let callCount = 0; + const getNotes = async () => { callCount++; return [sampleNote(`n${callCount}`, ws)]; }; + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + await new Promise(r => setTimeout(r, 100)); + assert.strictEqual(callCount, 1, 'getNotes should be called once after debounce'); + }); + + test('regeneration failure surfaces via onError without throwing', async () => { + const ws = '/this/path/does/not/exist/anywhere'; + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + let captured: Error | undefined; + writer.onError = (e) => { captured = e; }; + await writer.regenerate([]); + assert.ok(captured, 'onError should have been called'); + }); +}); diff --git a/src/test/suite/noteDefaults.test.ts b/src/test/suite/noteDefaults.test.ts new file mode 100644 index 0000000..ce1137e --- /dev/null +++ b/src/test/suite/noteDefaults.test.ts @@ -0,0 +1,60 @@ +/** + * Unit tests for noteDefaults + * Tests schema default application and expiry checking + */ + +import * as assert from 'assert'; +import { applyDefaults, isExpired } from '../../noteDefaults.js'; +import { Note } from '../../types.js'; + +const baseNote: Note = { + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}; + +suite('noteDefaults', () => { + test('applyDefaults fills all optional fields on a legacy note', () => { + const filled = applyDefaults({ ...baseNote }); + assert.strictEqual(filled.type, 'context'); + assert.strictEqual(filled.scope, 'line'); + assert.deepStrictEqual(filled.tags, []); + assert.deepStrictEqual(filled.references, []); + assert.strictEqual(filled.priority, 'normal'); + assert.strictEqual(filled.authorType, 'human'); + assert.strictEqual(filled.expiresAt, undefined); + }); + + test('applyDefaults preserves explicitly set fields', () => { + const filled = applyDefaults({ + ...baseNote, + type: 'instruction', + priority: 'high', + tags: ['security'], + }); + assert.strictEqual(filled.type, 'instruction'); + assert.strictEqual(filled.priority, 'high'); + assert.deepStrictEqual(filled.tags, ['security']); + assert.strictEqual(filled.scope, 'line'); // still defaulted + }); + + test('isExpired returns false when expiresAt is undefined', () => { + assert.strictEqual(isExpired(baseNote, new Date('2099-01-01')), false); + }); + + test('isExpired returns true when expiresAt is in the past', () => { + const n = { ...baseNote, expiresAt: '2026-04-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), true); + }); + + test('isExpired returns false when expiresAt is in the future', () => { + const n = { ...baseNote, expiresAt: '2026-06-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), false); + }); +}); diff --git a/src/test/suite/noteManager.test.ts b/src/test/suite/noteManager.test.ts index d742eb1..72b699b 100644 --- a/src/test/suite/noteManager.test.ts +++ b/src/test/suite/noteManager.test.ts @@ -502,14 +502,101 @@ suite('NoteManager Test Suite', () => { assert.ok(true); }); }); + + suite('Schema Defaults', () => { + test('getNotesForFile applies defaults to legacy notes (no structured fields)', async () => { + // Write a legacy-shaped note directly to storage, bypassing NoteManager. + // The writer omits fields that equal defaults, so a note without type/scope/tags + // written this way will be read back with those fields as undefined. + await (storage as any).saveNote({ + id: 'legacy-1', + content: 'old', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:legacy', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + }); + + // Confirm the storage parser does NOT auto-fill defaults (raw read returns undefined) + const rawNotes = await (storage as any).loadNotes('/abs/x.ts'); + const rawLegacy = rawNotes.find((n: any) => n.id === 'legacy-1'); + assert.ok(rawLegacy, 'legacy note should be present in storage'); + assert.strictEqual(rawLegacy.type, undefined, 'storage should NOT fill type default'); + + // Now read via NoteManager — defaults must be applied + const notes = await noteManager.getNotesForFile('/abs/x.ts'); + const legacy = notes.find((n: any) => n.id === 'legacy-1'); + assert.ok(legacy, 'legacy note should be returned by getNotesForFile'); + assert.strictEqual(legacy!.type, 'context'); + assert.strictEqual(legacy!.scope, 'line'); + assert.deepStrictEqual(legacy!.tags, []); + }); + }); + + suite('Metadata Updates', () => { + test('updateNoteMetadata rejects soft-deleted notes', async () => { + await (storage as any).saveNote({ + id: 'deleted-1', + content: 'gone', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + isDeleted: true, + }); + + await assert.rejects( + () => noteManager.updateNoteMetadata('deleted-1', { type: 'instruction' }), + /deleted/i + ); + }); + + test('updateNoteMetadata persists fields and returns a defaults-applied note', async () => { + await (storage as any).saveNote({ + id: 'meta-1', + content: 'hi', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + }); + + const updated = await noteManager.updateNoteMetadata('meta-1', { + type: 'instruction', + priority: 'high', + }); + assert.strictEqual(updated.type, 'instruction'); + assert.strictEqual(updated.priority, 'high'); + // Untouched fields come back defaulted, not undefined + assert.strictEqual(updated.scope, 'line'); + assert.deepStrictEqual(updated.tags, []); + + // Cache must serve the defaults-applied note too + const notes = await noteManager.getNotesForFile('/abs/x.ts'); + const cached = notes.find(n => n.id === 'meta-1'); + assert.strictEqual(cached!.type, 'instruction'); + assert.strictEqual(cached!.scope, 'line'); + }); + }); }); /** * Helper function to create a mock VSCode document */ +let mockDocumentSeq = 0; + async function createMockDocument(content: string): Promise { const lines = content.split('\n'); - const filePath = `/test/file-${Date.now()}.ts`; + const filePath = `/test/file-${Date.now()}-${++mockDocumentSeq}.ts`; return { lineCount: lines.length, diff --git a/src/test/suite/storageManager.test.ts b/src/test/suite/storageManager.test.ts index 91ccb0f..1a9c0fc 100644 --- a/src/test/suite/storageManager.test.ts +++ b/src/test/suite/storageManager.test.ts @@ -319,4 +319,148 @@ suite('StorageManager Test Suite', () => { assert.ok(content.includes('**Status:** DELETED')); }); + + test('markdownToNote drops invalid structured field values', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z +**Type:** banana +**Scope:** galaxy +**Priority:** urgent +**AuthorType:** robot +**References:** [{"kind":"pr","value":"#42"},{"kind":"nope","value":"x"},"junk",{"kind":"url"}] + +## Current Content + +Hi. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, undefined, 'invalid type must be dropped'); + assert.strictEqual(note!.scope, undefined, 'invalid scope must be dropped'); + assert.strictEqual(note!.priority, undefined, 'invalid priority must be dropped'); + assert.strictEqual(note!.authorType, undefined, 'invalid authorType must be dropped'); + assert.deepStrictEqual(note!.references, [{ kind: 'pr', value: '#42' }], 'only valid references survive'); + }); + + test('markdownToNote parses new structured fields when present', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z +**Type:** instruction +**Scope:** function +**Priority:** high +**Tags:** security, legacy +**AuthorType:** human +**ExpiresAt:** 2026-12-01T00:00:00Z +**References:** [{"kind":"pr","value":"#42"}] + +## Current Content + +Do not bypass. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, 'instruction'); + assert.strictEqual(note!.scope, 'function'); + assert.strictEqual(note!.priority, 'high'); + assert.deepStrictEqual(note!.tags, ['security', 'legacy']); + assert.strictEqual(note!.authorType, 'human'); + assert.strictEqual(note!.expiresAt, '2026-12-01T00:00:00Z'); + assert.deepStrictEqual(note!.references, [{ kind: 'pr', value: '#42' }]); + }); + + test('markdownToNote leaves new fields undefined for legacy notes', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z + +## Current Content + +Hi. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, undefined); + assert.strictEqual(note!.scope, undefined); + assert.strictEqual(note!.tags, undefined); + }); + + test('noteToMarkdown emits structured fields when set', async () => { + const sm: any = new StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'do not refactor', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'instruction', + scope: 'function', + priority: 'high', + tags: ['security'], + authorType: 'agent', + expiresAt: '2026-12-01T00:00:00Z', + references: [{ kind: 'pr', value: '#42' }], + }); + assert.ok(md.includes('**Type:** instruction')); + assert.ok(md.includes('**Scope:** function')); + assert.ok(md.includes('**Priority:** high')); + assert.ok(md.includes('**Tags:** security')); + assert.ok(md.includes('**AuthorType:** agent')); + assert.ok(md.includes('**ExpiresAt:** 2026-12-01T00:00:00Z')); + assert.ok(md.includes('**References:** [{"kind":"pr","value":"#42"}]')); + }); + + test('noteToMarkdown omits fields equal to defaults', async () => { + const sm: any = new StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', // default + scope: 'line', // default + priority: 'normal', // default + tags: [], // default + authorType: 'human', // default + }); + assert.ok(!md.includes('**Type:**')); + assert.ok(!md.includes('**Scope:**')); + assert.ok(!md.includes('**Priority:**')); + assert.ok(!md.includes('**Tags:**')); + assert.ok(!md.includes('**AuthorType:**')); + }); }); diff --git a/src/types.ts b/src/types.ts index 90ff026..409522d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,49 @@ export interface LineRange { */ export type NoteAction = 'created' | 'edited' | 'deleted'; +/** + * Type of note — drives prioritization in agent-facing exports + */ +export type NoteType = + | 'context' // default: explanatory background + | 'instruction' // directive: agent/human MUST follow + | 'warning' // hazard: don't do X + | 'decision' // architectural decision + rationale + | 'todo' // outstanding work + | 'handoff' // "next session pick up here" (often agent-authored) + | 'rationale'; // why this code exists (links to PRs/commits) + +/** + * Scope of applicability for a note + */ +export type NoteScope = + | 'line' // default — current behavior + | 'function' + | 'class' + | 'file' + | 'directory'; + +/** + * Author kind — explicit agent attribution + */ +export type AuthorType = 'human' | 'agent'; + +/** + * Priority used for digest ordering — `critical` sorts first. Expired + * notes are still excluded by expiry filtering regardless of priority; + * `critical` always-include behavior ships with the v0.4 MCP tools. + */ +export type NotePriority = 'low' | 'normal' | 'high' | 'critical'; + +/** + * Reference to an external artifact (PR, commit, test, etc.) + */ +export interface NoteReference { + kind: 'note' | 'pr' | 'issue' | 'commit' | 'test' | 'url'; + value: string; + label?: string; +} + /** * Represents a single history entry for a note */ @@ -55,6 +98,14 @@ export interface Note { history: NoteHistoryEntry[]; /** Whether this note has been deleted (soft delete) */ isDeleted?: boolean; + // NEW (all optional for back-compat — see noteDefaults.applyDefaults) + type?: NoteType; + tags?: string[]; + scope?: NoteScope; + references?: NoteReference[]; + expiresAt?: string; // ISO 8601 + authorType?: AuthorType; + priority?: NotePriority; } /** diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 6d4e4ad..d1d66fc 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Scope -This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `/Users/nahian/Projects/code-notes/src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. +This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `../src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. ## Commands diff --git a/web/src/pages/ChangelogPage.tsx b/web/src/pages/ChangelogPage.tsx index baa4922..772bc98 100644 --- a/web/src/pages/ChangelogPage.tsx +++ b/web/src/pages/ChangelogPage.tsx @@ -36,8 +36,8 @@ export function ChangelogPage() { {/* Timeline Items */}
- {/* Version 0.2.1 */} -
+ {/* Version 0.3.0 */} +
{/* Timeline Node */}
@@ -47,10 +47,116 @@ export function ChangelogPage() {

- Version 0.2.1 + Version 0.3.0 Latest

+
+ + May 16, 2026 +
+

+ Agent integration foundation: structured note schema, auto-generated workspace exports for coding agents, and sidebar enhancements. +

+
+ + {/* Right Column - Changes */} +
+ + + {/* Added */} +
+

+ + Added +

+
+
+ +
+
Structured Note Schema for Agent Integration
+

+ Optional fields turn free-form notes into machine-readable workspace context for coding agents. +

+
    +
  • Types: context, instruction, warning, decision, todo, handoff, rationale
  • +
  • Priority: low, normal, high, critical
  • +
  • Scope: line, function, class, file, directory
  • +
  • • Tags, expiry (ISO 8601), authorType (human/agent), references (PR/issue/commit/test/url)
  • +
  • • New command Set Note Type / Tags / Priority… to enrich any note
  • +
+
+
+
+ +
+
Auto-Generated Workspace Exports
+

+ Two files regenerate atomically on every note change so any coding agent can ingest workspace notes as context. +

+
    +
  • .code-notes/INDEX.json — machine-readable index with byFile/byType/byTag lookups
  • +
  • .code-notes/AGENTS.md — human-readable digest hoisting instructions, warnings, handoffs, decisions
  • +
  • • Debounced (200ms) writes, atomic temp-then-rename, deterministic output
  • +
  • • Manual Regenerate Exports command for recovery
  • +
+
+
+
+ +
+
Sidebar Enhancements
+

+ Type badges, priority indicators, and new title-bar filters. +

+
    +
  • • Note type shown as · instruction description suffix
  • +
  • • High/critical priority shown as tooltip badge
  • +
  • Filter Notes by Type… (multi-select Quick Pick) and Toggle Expired Notes
  • +
+
+
+
+
+ + {/* Technical */} +
+

+ + Technical +

+
+

• New modules: noteDefaults.ts, exportGenerator.ts, exportWriter.ts

+

NoteManager wraps storage reads with applyDefaults — single boundary for lazy migration of v0.2.x notes

+

• Storage format extended with optional bold-label fields; fields equal to default are omitted so untouched legacy notes remain byte-identical on disk

+

• 58+ unit tests covering schema defaults, storage round-trip, export determinism, and debounced atomic writes

+

• New settings: codeContextNotes.exports.{`{enabled,indexJson,agentsMarkdown}`}

+
+
+ + {/* Coming Next */} +
+

+ Coming next: v0.4 ships a standalone MCP server backed by INDEX.json for direct agent integration (Cursor, Claude Code, and other MCP clients). +

+
+
+
+
+
+ + {/* Version 0.2.1 */} +
+ {/* Timeline Node */} +
+
+
+ + {/* Left Column - Version Info */} +
+
+

Version 0.2.1

+
November 12, 2025 @@ -62,7 +168,7 @@ export function ChangelogPage() { {/* Right Column - Changes */}
- + {/* Added */}
@@ -647,7 +753,7 @@ export function ChangelogPage() { {/* Left Column - Version Info */}
-

Coming in v0.3.0

+

Coming Next

Future Release @@ -665,19 +771,13 @@ export function ChangelogPage() {
- Search and Filter Notes - Full-text search across all note content with filters by author, date range, and file path - -
-
- - - Regex Pattern Matching - Advanced search capabilities for power users + MCP Server (v0.4) - Standalone @jnahian/code-notes-mcp server so any MCP-capable agent can read and write notes
- Background Indexing - Instant search results with automatic index updates + Search and Filter Notes - Full-text search across all note content with filters by author, date range, and file path