feat(pkm): daily notes + workspace search + doc-aware Q&A

Three more features from the brainstorm menu, built on a shared search
algorithm so the codebase stays small.

- src/main/DailyNotes.js — Zettelkasten-style helper. One YYYY-MM-DD.md
  per local date under <userData>/notes/daily/; loads skeleton from
  <userData>/notes/templates/daily.md when present (built-in default
  otherwise). openOrCreate never clobbers existing content.
- src/main/WorkspaceSearch.js — tag/wikilink-aware content search.
  Pure module, injectable-IO tested. Query grammar: bare words,
  #tag, @wikilink, "quoted phrases". Facets weight +3 each; prose
  terms +1/occurrence capped at 5; edits within 7 days get a recency
  nudge. Returns ranked results with snippets.
- src/main/DocQA.js — chunk-level Q&A wrapper over WorkspaceSearch.
  cleanQuestion strips question words (what/how/why/...) and verb
  noise (write/read/show/tell/...) so they don't drown the ranking.
  Returns top-K passages instead of whole-file hits — multiple chunks
  from the same file can appear in the answer.
- src/main.js — IPC: daily-notes:open-today, daily-notes:list,
  workspace-search:query, doc-qa:ask. Path validation through the
  existing validatePath gate; a global Ctrl+Alt+D shortcut creates
  today's daily note from anywhere.
- src/preload.js — all four channels added to ALLOWED_SEND_CHANNELS.

Tests (56 new across the three modules):
- tests/main/DailyNotes.test.js (15): dateKey formatting, pathFor,
  template load + {date}/{weekday} substitution, openOrCreate +
  no-clobber, nested-dir creation, listExisting filtering,
  isValidDir rejects NUL/non-string.
- tests/main/WorkspaceSearch.test.js (25): parseQuery grammar,
  hasTag/hasWikilink word boundaries, scoreDocument scoring,
  per-term spam cap, recency nudge, search ranking + limit +
  empty-query short-circuit, bad-input safety.
- tests/main/DocQA.test.js (16): cleanQuestion stripping + facet
  preservation, chunkDocument paragraph + hard-split, ask()
  top-K, recency tiebreaker, missing-files fallback.

Full suite: 748 tests pass, 66 suites, lint+format clean.

Amit Haridas
This commit is contained in:
2026-09-14 00:02:58 +05:30
parent cd0050d988
commit 3f856bc857
9 changed files with 1275 additions and 2 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* @jest-environment node
*
* DailyNotes tests — pure module, injectable IO.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const DailyNotes = require('../../src/main/DailyNotes');
describe('DailyNotes.dateKey', () => {
test('formats a Date as YYYY-MM-DD in local time', () => {
expect(DailyNotes.dateKey(new Date(2026, 8, 13))).toBe('2026-09-13');
});
test('zero-pads single-digit month and day', () => {
expect(DailyNotes.dateKey(new Date(2026, 0, 5))).toBe('2026-01-05');
});
test('defaults to today when called with no args', () => {
const now = new Date();
expect(DailyNotes.dateKey()).toBe(DailyNotes.dateKey(now));
});
});
describe('DailyNotes.pathFor', () => {
test('joins dir + YYYY-MM-DD.md', () => {
const d = new Date(2026, 8, 13);
expect(DailyNotes.pathFor(d, '/tmp/notes', path)).toBe('/tmp/notes/2026-09-13.md');
});
});
describe('DailyNotes.loadTemplate', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily_template_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('returns built-in default when no template dir given', () => {
const d = new Date(2026, 8, 13);
const body = DailyNotes.loadTemplate({ date: d, fs, pathUtil: path });
expect(body).toContain('# 2026-09-13');
expect(body).toContain('## Notes');
});
test('returns built-in default when template file is missing', () => {
const d = new Date(2026, 8, 13);
const body = DailyNotes.loadTemplate({
date: d,
templateDir: tmpDir,
templateName: 'nope.md',
fs,
pathUtil: path,
});
expect(body).toContain('# 2026-09-13');
});
test('substitutes {date} and {weekday} in a custom template', () => {
const tplPath = path.join(tmpDir, 'daily.md');
fs.writeFileSync(tplPath, '# {date} ({weekday})\n\nReflecting.', 'utf-8');
const d = new Date(2026, 8, 13); // a Sunday
const body = DailyNotes.loadTemplate({
date: d,
templateDir: tmpDir,
fs,
pathUtil: path,
now: d,
});
expect(body).toContain('# 2026-09-13');
expect(body).toContain('Sunday');
});
});
describe('DailyNotes.openOrCreate', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily_notes_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('creates a new note from the default template', () => {
const d = new Date(2026, 8, 13);
const result = DailyNotes.openOrCreate({
date: d,
dir: tmpDir,
fs,
pathUtil: path,
now: d,
});
expect(result.created).toBe(true);
expect(result.path).toBe(path.join(tmpDir, '2026-09-13.md'));
expect(result.content).toContain('# 2026-09-13');
expect(fs.existsSync(result.path)).toBe(true);
});
test('returns existing content when the note already exists (no clobber)', () => {
const notePath = path.join(tmpDir, '2026-09-13.md');
fs.writeFileSync(notePath, '# Pre-existing content\n\nPreserve me.', 'utf-8');
const d = new Date(2026, 8, 13);
const result = DailyNotes.openOrCreate({
date: d,
dir: tmpDir,
fs,
pathUtil: path,
now: d,
});
expect(result.created).toBe(false);
expect(result.content).toBe('# Pre-existing content\n\nPreserve me.');
// The file was not modified — existing content survives.
expect(fs.readFileSync(notePath, 'utf-8')).toBe('# Pre-existing content\n\nPreserve me.');
});
test('creates the dir if it does not exist', () => {
const nestedDir = path.join(tmpDir, 'daily', 'nested');
expect(fs.existsSync(nestedDir)).toBe(false);
const d = new Date(2026, 8, 13);
DailyNotes.openOrCreate({
date: d,
dir: nestedDir,
fs,
pathUtil: path,
now: d,
});
expect(fs.existsSync(nestedDir)).toBe(true);
expect(fs.existsSync(path.join(nestedDir, '2026-09-13.md'))).toBe(true);
});
test('rejects when dir is missing', () => {
expect(() =>
DailyNotes.openOrCreate({
date: new Date(2026, 8, 13),
dir: null,
fs,
pathUtil: path,
})
).toThrow(/dir is required/);
});
});
describe('DailyNotes.listExisting', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily_list_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('returns [] when the dir does not exist', () => {
expect(
DailyNotes.listExisting({ dir: path.join(tmpDir, 'missing'), fs, pathUtil: path })
).toEqual([]);
});
test('lists only YYYY-MM-DD.md entries, newest first', () => {
fs.writeFileSync(path.join(tmpDir, '2026-09-10.md'), 'a');
fs.writeFileSync(path.join(tmpDir, '2026-09-13.md'), 'b');
fs.writeFileSync(path.join(tmpDir, '2026-09-12.md'), 'c');
fs.writeFileSync(path.join(tmpDir, 'readme.md'), 'ignore');
fs.writeFileSync(path.join(tmpDir, '2026-13-09.md'), 'ignore (bad month)');
const list = DailyNotes.listExisting({ dir: tmpDir, fs, pathUtil: path });
expect(list).toEqual(['2026-09-13.md', '2026-09-12.md', '2026-09-10.md']);
});
});
describe('DailyNotes.isValidDir', () => {
test('accepts non-empty strings without NUL bytes', () => {
expect(DailyNotes.isValidDir('/home/me/notes')).toBe(true);
expect(DailyNotes.isValidDir('C:\\Users\\me\\notes')).toBe(true);
});
test('rejects empty / null / non-string / NUL-containing input', () => {
expect(DailyNotes.isValidDir('')).toBe(false);
expect(DailyNotes.isValidDir(null)).toBe(false);
expect(DailyNotes.isValidDir(undefined)).toBe(false);
expect(DailyNotes.isValidDir(42)).toBe(false);
expect(DailyNotes.isValidDir('/etc/\0passwd')).toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* @jest-environment node
*
* DocQA tests — pure module, no IO. Verifies the question→chunks pipeline.
*/
const DocQA = require('../../src/main/DocQA');
describe('DocQA.cleanQuestion', () => {
test('strips question words but keeps substantive terms', () => {
expect(DocQA.cleanQuestion('what did I write about rust async')).toBe('rust async');
expect(DocQA.cleanQuestion('how do I configure pandoc')).toBe('configure pandoc');
});
test('keeps #tags and @wikilinks intact', () => {
expect(DocQA.cleanQuestion('what is #rust about?')).toContain('#rust');
expect(DocQA.cleanQuestion('tell me about @project-x')).toContain('@project-x');
});
test('keeps "quoted phrases" intact', () => {
const out = DocQA.cleanQuestion('what does "rust async" mean?');
expect(out).toContain('"rust async"');
});
test('returns empty string for non-string input', () => {
expect(DocQA.cleanQuestion(null)).toBe('');
expect(DocQA.cleanQuestion(undefined)).toBe('');
expect(DocQA.cleanQuestion(42)).toBe('');
});
test('returns empty string when only question words remain', () => {
expect(DocQA.cleanQuestion('what is this?')).toBe('');
});
});
describe('DocQA.chunkDocument', () => {
test('returns [] for empty / non-string input', () => {
expect(DocQA.chunkDocument('')).toEqual([]);
expect(DocQA.chunkDocument(null)).toEqual([]);
});
test('returns one chunk for a short document', () => {
const chunks = DocQA.chunkDocument('hello world');
expect(chunks).toHaveLength(1);
expect(chunks[0].text).toBe('hello world');
});
test('respects paragraph breaks', () => {
const content = 'para one.\n\npara two.\n\npara three.';
const chunks = DocQA.chunkDocument(content);
expect(chunks.length).toBeGreaterThanOrEqual(1);
expect(chunks[0].text).toContain('para one');
});
test('splits long content into multiple chunks', () => {
const big = 'x'.repeat(2500);
const chunks = DocQA.chunkDocument(big, 800);
expect(chunks.length).toBeGreaterThan(1);
// Each chunk should be ≤ 800 chars (except possibly the last)
for (let i = 0; i < chunks.length - 1; i++) {
expect(chunks[i].text.length).toBeLessThanOrEqual(800);
}
});
});
describe('DocQA.ask', () => {
const files = [
{
path: '/notes/rust.md',
content:
'Rust is a systems language.\n\nAsync in Rust uses tokio for runtime.\n\nBorrow checker enforces memory safety.',
},
{
path: '/notes/pandoc.md',
content: 'Pandoc is a document converter.\n\nConfiguration uses YAML metadata blocks.',
},
{
path: '/notes/old.md',
content: 'Old notes from years ago.',
},
];
test('returns empty chunks for a question with no substantive terms', () => {
const r = DocQA.ask({ question: 'what is this?', files });
expect(r.chunks).toEqual([]);
});
test('returns empty chunks when no files match', () => {
const r = DocQA.ask({ question: 'quantum entanglement', files });
expect(r.chunks).toEqual([]);
});
test('returns relevant chunks for a substantive question', () => {
const r = DocQA.ask({ question: 'how does rust async work', files, topK: 3 });
expect(r.chunks.length).toBeGreaterThan(0);
// The first hit should be from rust.md (highest relevance)
expect(r.chunks[0].filePath).toBe('/notes/rust.md');
expect(r.chunks[0].snippet).toMatch(/rust|async|tokio/i);
expect(r.chunks[0].score).toBeGreaterThan(0);
});
test('honors topK', () => {
const r = DocQA.ask({ question: 'rust', files, topK: 2 });
expect(r.chunks.length).toBeLessThanOrEqual(2);
});
test('includes the original question in the response', () => {
const r = DocQA.ask({ question: 'how do I configure pandoc', files });
expect(r.question).toBe('how do I configure pandoc');
});
test('handles missing or empty file list gracefully', () => {
const r = DocQA.ask({ question: 'rust', files: [] });
expect(r.chunks).toEqual([]);
const r2 = DocQA.ask({ question: 'rust', files: null });
expect(r2.chunks).toEqual([]);
});
test('rank prefers recent edits when scores tie (recency nudge)', () => {
const now = Date.now();
const filesWithMtime = [
{
path: '/fresh.md',
content: 'rust language overview',
mtimeMs: now - 1 * 24 * 60 * 60 * 1000, // 1 day ago
},
{
path: '/stale.md',
content: 'rust language overview (same text)',
mtimeMs: now - 60 * 24 * 60 * 60 * 1000, // 60 days ago
},
];
const r = DocQA.ask({ question: 'rust overview', files: filesWithMtime, topK: 5 });
expect(r.chunks[0].filePath).toBe('/fresh.md');
});
});
+1 -2
View File
@@ -54,8 +54,7 @@ describe('PDFOperations - Task 15 new operations', () => {
expect(result.success).toBe(true);
expect(result.text).toContain('Hello Task 15 Page One');
expect(result.text).toContain('Second Page Content');
}, // First pdfjs-dist legacy import can exceed the 5s default on slower
// CI runners (observed on windows-latest)
}, // CI runners (observed on windows-latest) // First pdfjs-dist legacy import can exceed the 5s default on slower
30000);
it('returns failure for a nonexistent file', async () => {
+228
View File
@@ -0,0 +1,228 @@
/**
* @jest-environment node
*
* WorkspaceSearch tests — pure module, no IO.
*/
const WorkspaceSearch = require('../../src/main/WorkspaceSearch');
describe('WorkspaceSearch.parseQuery', () => {
test('extracts bare terms', () => {
const q = WorkspaceSearch.parseQuery('hello world');
expect(q.terms).toEqual(['hello', 'world']);
expect(q.phrases).toEqual([]);
expect(q.tags).toEqual([]);
expect(q.links).toEqual([]);
});
test('extracts #tag and @wikilink facets', () => {
const q = WorkspaceSearch.parseQuery('search query #rust @project-x');
expect(q.terms).toEqual(['search', 'query']);
expect(q.tags).toEqual(['rust']);
expect(q.links).toEqual(['project-x']);
});
test('extracts "quoted phrases" as a unit', () => {
const q = WorkspaceSearch.parseQuery('hello "world peace" again');
expect(q.terms).toEqual(['hello', 'again']);
expect(q.phrases).toEqual(['world peace']);
});
test('lowercases terms and facets for case-insensitive matching', () => {
const q = WorkspaceSearch.parseQuery('Foo #BAR @Baz');
expect(q.terms).toEqual(['foo']);
expect(q.tags).toEqual(['bar']);
expect(q.links).toEqual(['baz']);
});
test('drops single-character noise terms', () => {
const q = WorkspaceSearch.parseQuery('a big b');
expect(q.terms).toEqual(['big']);
});
test('handles non-string input safely', () => {
expect(WorkspaceSearch.parseQuery(null)).toEqual({
terms: [],
phrases: [],
tags: [],
links: [],
});
expect(WorkspaceSearch.parseQuery(undefined)).toEqual({
terms: [],
phrases: [],
tags: [],
links: [],
});
expect(WorkspaceSearch.parseQuery(42)).toEqual({ terms: [], phrases: [], tags: [], links: [] });
});
});
describe('WorkspaceSearch.hasTag', () => {
test('matches #foo anywhere with word boundary', () => {
expect(WorkspaceSearch.hasTag('#foo hello world', 'foo')).toBe(true);
expect(WorkspaceSearch.hasTag('paragraph #foo end', 'foo')).toBe(true);
});
test('does not match a substring of another tag', () => {
// #foobar should not match #foo
expect(WorkspaceSearch.hasTag('#foobar text', 'foo')).toBe(false);
});
test('is case-insensitive', () => {
expect(WorkspaceSearch.hasTag('#FOO bar', 'foo')).toBe(true);
});
});
describe('WorkspaceSearch.hasWikilink', () => {
test('matches [[foo]] and [[foo|alias]]', () => {
expect(WorkspaceSearch.hasWikilink('see [[foo]] for details', 'foo')).toBe(true);
expect(WorkspaceSearch.hasWikilink('see [[foo|the thing]] for details', 'foo')).toBe(true);
});
test('does not match partial wiki-link references', () => {
expect(WorkspaceSearch.hasWikilink('look at foobar', 'foo')).toBe(false);
});
});
describe('WorkspaceSearch.scoreDocument', () => {
test('returns null when nothing matches', () => {
const r = WorkspaceSearch.scoreDocument({
content: 'unrelated text',
parsed: WorkspaceSearch.parseQuery('zebra'),
});
expect(r).toBeNull();
});
test('scores a single term match', () => {
const r = WorkspaceSearch.scoreDocument({
content: 'the quick brown fox jumps',
parsed: WorkspaceSearch.parseQuery('fox'),
});
expect(r).not.toBeNull();
expect(r.score).toBeGreaterThan(0);
expect(r.matchedTerms).toContain('fox');
});
test('caps per-term repetition spam', () => {
const content = 'foo '.repeat(50) + 'unrelated';
const r = WorkspaceSearch.scoreDocument({
content,
parsed: WorkspaceSearch.parseQuery('foo'),
});
// 50 occurrences but capped at +5/term
expect(r.score).toBe(5);
});
test('tag hits weigh more than prose hits', () => {
const r = WorkspaceSearch.scoreDocument({
content: 'just a #rust mention',
parsed: WorkspaceSearch.parseQuery('#rust'),
});
// Tag facet = +3, no prose matching
expect(r.score).toBe(3);
expect(r.matchedTags).toEqual(['rust']);
});
test('wikilink hits are recorded separately', () => {
const r = WorkspaceSearch.scoreDocument({
content: 'see [[project-x]]',
parsed: WorkspaceSearch.parseQuery('@project-x'),
});
expect(r.score).toBe(3);
expect(r.matchedLinks).toEqual(['project-x']);
});
test('phrase hits weigh more than single terms', () => {
const r = WorkspaceSearch.scoreDocument({
content: 'world peace is lovely',
parsed: WorkspaceSearch.parseQuery('"world peace"'),
});
expect(r.score).toBe(3);
});
test('snippet is a windowed slice around the first match', () => {
const content = 'A'.repeat(100) + ' fox here ' + 'B'.repeat(100);
const r = WorkspaceSearch.scoreDocument({
content,
parsed: WorkspaceSearch.parseQuery('fox'),
});
expect(r.snippet).toContain('fox');
expect(r.snippet.length).toBeLessThan(200);
});
test('recency nudge adds a small boost for edits within 7 days', () => {
const now = Date.now();
const content = 'fox mention';
const recent = WorkspaceSearch.scoreDocument({
content,
parsed: WorkspaceSearch.parseQuery('fox'),
mtimeMs: now - 1 * 24 * 60 * 60 * 1000, // 1 day ago
nowMs: now,
});
const stale = WorkspaceSearch.scoreDocument({
content,
parsed: WorkspaceSearch.parseQuery('fox'),
mtimeMs: now - 30 * 24 * 60 * 60 * 1000, // 30 days ago
nowMs: now,
});
expect(recent.score).toBeGreaterThan(stale.score);
});
});
describe('WorkspaceSearch.search', () => {
const files = [
{ path: '/notes/a.md', content: 'rust language overview' },
{ path: '/notes/b.md', content: '#rust tag-only entry with #rust mentions' },
{ path: '/notes/c.md', content: 'unrelated content' },
{ path: '/notes/d.md', content: '[[project-x]] link' },
{ path: '/notes/e.md', content: 'rust rust rust rust rust rust rust' },
];
test('returns matches sorted by score descending', () => {
const results = WorkspaceSearch.search({ query: 'rust', files });
expect(results.length).toBeGreaterThan(0);
// b.md has #rust (tag + prose) and a.md has prose only
expect(results[0].filePath).toBe('/notes/e.md'); // highest prose count
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
}
});
test('filters out non-matching docs', () => {
const results = WorkspaceSearch.search({ query: 'rust', files });
expect(results.find((r) => r.filePath === '/notes/c.md')).toBeUndefined();
expect(results.find((r) => r.filePath === '/notes/d.md')).toBeUndefined();
});
test('honors a limit', () => {
const results = WorkspaceSearch.search({ query: 'rust', files, limit: 2 });
expect(results.length).toBe(2);
});
test('returns [] for an empty query', () => {
expect(WorkspaceSearch.search({ query: '', files })).toEqual([]);
expect(WorkspaceSearch.search({ query: ' ', files })).toEqual([]);
expect(WorkspaceSearch.search({ query: '#', files })).toEqual([]);
});
test('combines bare terms with facets in a single query', () => {
const files2 = [
{ path: '/x.md', content: 'rust #rust content' },
{ path: '/y.md', content: 'rust content without tag' },
];
const results = WorkspaceSearch.search({ query: 'rust #rust', files: files2 });
expect(results[0].filePath).toBe('/x.md');
expect(results[0].matchedTerms).toContain('rust');
expect(results[0].matchedTags).toEqual(['rust']);
});
test('skips files without content (no crash on bad input)', () => {
const files3 = [
null,
{ path: '/a.md' }, // missing content
{ path: '/b.md', content: 'fox' },
];
const results = WorkspaceSearch.search({ query: 'fox', files: files3 });
expect(results).toHaveLength(1);
expect(results[0].filePath).toBe('/b.md');
});
});