chore(release): bump v4.7.1 → v4.8.0; Prettier pass on overnight-session files

12 commits on master since v4.7.1 baseline, taking tests 524 → 914 across
27 new suites. This commit:
- Bumps package.json + README version to v4.8.0 (additive features → semver minor)
- Applies Prettier formatting to all files touched during the overnight
  session so release build is reproducible from a clean repo

New in v4.8.0:
- PDF encryption close-out (D1)
- KaTeX rendering test coverage
- Autosave buffer + crash recovery banner
- Daily notes + workspace search + doc-aware Q&A
- Search sidebar panel + Ask mode
- Status bar: word count, reading time, Flesch-Kincaid grade
- Footnote hover preview
- Smart paste (URL → link, CSV/TSV → table)
- Daily notes menu item + sidebar panel + Q&A deep-link
- Daily templates gallery
- Pluggable DocQA engine (TF-idF default + lazy neural embeddings)

Amit Haridas
This commit is contained in:
2026-09-14 14:56:05 +05:30
parent c3fe72bcae
commit b1b72f9a60
29 changed files with 186 additions and 136 deletions
+1 -1
View File
@@ -80,4 +80,4 @@ function smartPaste(deps) {
});
}
module.exports = { smartPaste, URL_ONLY_RE };
module.exports = { smartPaste, URL_ONLY_RE };
+17 -3
View File
@@ -2559,7 +2559,11 @@
</svg>
</button>
<!-- Search + Q&A: workspace content search / doc-aware Q&A -->
<button class="sidebar-icon" data-panel="search" title="Search &amp; Ask (Ctrl+Shift+F)">
<button
class="sidebar-icon"
data-panel="search"
title="Search &amp; Ask (Ctrl+Shift+F)"
>
<svg
width="20"
height="20"
@@ -2865,9 +2869,19 @@
<span class="status-separator">|</span>
<span class="status-item" id="char-count">Chars: 0</span>
<span class="status-separator">|</span>
<span class="status-item" id="reading-time" title="Estimated reading time at 220 words/minute">~0 min read</span>
<span
class="status-item"
id="reading-time"
title="Estimated reading time at 220 words/minute"
>~0 min read</span
>
<span class="status-separator">|</span>
<span class="status-item" id="grade-level" title="Flesch-Kincaid grade level (lower = easier)">Grade —</span>
<span
class="status-item"
id="grade-level"
title="Flesch-Kincaid grade level (lower = easier)"
>Grade —</span
>
<span class="status-separator">|</span>
<span class="status-item" id="line-col">Ln 1, Col 1</span>
<span class="status-separator">|</span>
+30 -27
View File
@@ -5902,35 +5902,38 @@ ipcMain.handle('workspace-search:query', async (_event, { query, dir, limit = 50
// strips grammar noise (what/how/why/...) and re-ranks at the chunk level
// so the renderer can show multiple passages from the same file. No neural
// model — same ranking algorithm — so results stay explainable and offline.
ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
if (typeof question !== 'string' || !question.trim()) {
return { question: '', chunks: [] };
}
const validation = typeof dir === 'string' ? validatePath(dir) : { valid: false };
if (!validation.valid) return { question: String(question), chunks: [] };
const files = collectMarkdownFiles(dir, WORKSPACE_SEARCH_MAX_FILES);
const corpus = [];
for (const filePath of files) {
try {
const stat = fs.statSync(filePath);
if (stat.size > WORKSPACE_SEARCH_MAX_BYTES) continue;
corpus.push({
path: filePath,
content: fs.readFileSync(filePath, 'utf-8'),
mtimeMs: stat.mtimeMs,
});
} catch {
/* unreadable — skip */
ipcMain.handle(
'doc-qa:ask',
async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
if (typeof question !== 'string' || !question.trim()) {
return { question: '', chunks: [] };
}
}
const validation = typeof dir === 'string' ? validatePath(dir) : { valid: false };
if (!validation.valid) return { question: String(question), chunks: [] };
// Resolve the engine up-front. If the user asked for neural and the dep
// is missing, SemanticEngine.getEngine() falls back to tf-idf with a
// single console warning — callers always get a usable engine.
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf');
return DocQA.ask({ question, files: corpus, topK, engine });
});
const files = collectMarkdownFiles(dir, WORKSPACE_SEARCH_MAX_FILES);
const corpus = [];
for (const filePath of files) {
try {
const stat = fs.statSync(filePath);
if (stat.size > WORKSPACE_SEARCH_MAX_BYTES) continue;
corpus.push({
path: filePath,
content: fs.readFileSync(filePath, 'utf-8'),
mtimeMs: stat.mtimeMs,
});
} catch {
/* unreadable — skip */
}
}
// Resolve the engine up-front. If the user asked for neural and the dep
// is missing, SemanticEngine.getEngine() falls back to tf-idf with a
// single console warning — callers always get a usable engine.
const engine = await SemanticEngine.getEngine(engineName || 'tf-idf');
return DocQA.ask({ question, files: corpus, topK, engine });
}
);
// ================================
// Smart-paste: URL → page title
+4 -3
View File
@@ -84,9 +84,10 @@ function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date(),
// Use the explicit seedContent when provided (e.g. a non-default
// template chosen via the gallery); otherwise fall back to the
// template-loader (which honors templateDir + the built-in default).
content = typeof seedContent === 'string' && seedContent.length > 0
? seedContent
: loadTemplate({ date, templateDir, fs, pathUtil, now });
content =
typeof seedContent === 'string' && seedContent.length > 0
? seedContent
: loadTemplate({ date, templateDir, fs, pathUtil, now });
fs.writeFileSync(notePath, content, 'utf-8');
created = true;
}
+1 -1
View File
@@ -80,4 +80,4 @@ module.exports = {
listTemplates,
saveTemplate,
deleteTemplate,
};
};
+2 -9
View File
@@ -178,13 +178,7 @@ function chunkDocument(content, maxChunkChars = 800) {
* swap in semantic embeddings.
* @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}}
*/
async function ask({
question,
files,
topK = 5,
nowMs = Date.now(),
engine = null,
}) {
async function ask({ question, files, topK = 5, nowMs = Date.now(), engine = null }) {
const cleaned = cleanQuestion(question);
if (!cleaned || !Array.isArray(files) || files.length === 0) {
return { question: String(question || ''), chunks: [] };
@@ -241,8 +235,7 @@ async function ask({
offset,
snippet: h.snippet,
score: h.score,
mtimeMs:
chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
};
});
+1 -1
View File
@@ -165,4 +165,4 @@ module.exports = {
defaultEngine,
neuralEngine,
getEngine,
};
};
+9 -3
View File
@@ -98,7 +98,12 @@ function isHttpUrl(value) {
* @param {typeof fetch} [args.fetch] injectable for tests
* @returns {Promise<{url:string, title:string} | null>}
*/
async function fetchTitle({ url, timeoutMs = 5000, maxBytes = 2 * 1024 * 1024, fetch = globalThis.fetch }) {
async function fetchTitle({
url,
timeoutMs = 5000,
maxBytes = 2 * 1024 * 1024,
fetch = globalThis.fetch,
}) {
if (!isHttpUrl(url) || typeof fetch !== 'function') return null;
const controller = new AbortController();
@@ -114,7 +119,8 @@ async function fetchTitle({ url, timeoutMs = 5000, maxBytes = 2 * 1024 * 1024, f
if (!ctype.toLowerCase().includes('text/html')) return null;
// Read up to maxBytes + 1 so we can detect overflow
const reader = res.body && typeof res.body.getReader === 'function' ? res.body.getReader() : null;
const reader =
res.body && typeof res.body.getReader === 'function' ? res.body.getReader() : null;
let body = '';
if (reader) {
while (true) {
@@ -152,4 +158,4 @@ module.exports = {
decodeTitle,
isHttpUrl,
fetchTitle,
};
};
+4 -8
View File
@@ -2228,14 +2228,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// the user can override the input.
sidebarManager.registerPanel('search', {
title: 'Search',
icon:
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 2a8 8 0 1 1-5.3 14L1 19.7 2.3 21l3.7-3.7A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12A6 6 0 0 0 10 4z"/></svg>',
icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 2a8 8 0 1 1-5.3 14L1 19.7 2.3 21l3.7-3.7A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12A6 6 0 0 0 10 4z"/></svg>',
render: (container) =>
require('./sidebar/search-panel').renderSearchPanel(container, {
search: ({ query, dir, limit }) =>
ipcRenderer.invoke('workspace-search:query', { query, dir, limit }),
ask: ({ question, dir, topK }) =>
ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
ask: ({ question, dir, topK }) => ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
getCurrentDir: () => {
// Re-read the explorer's path input each time the user opens the
// search panel; survives the explorer being remounted.
@@ -2254,8 +2252,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// file-opened IPC path, which already handles any daily-notes path).
sidebarManager.registerPanel('daily-notes', {
title: 'Daily Notes',
icon:
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
render: (container) =>
require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, {
openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }),
@@ -2269,8 +2266,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// note via daily-templates:apply.
sidebarManager.registerPanel('daily-templates', {
title: 'Daily Templates',
icon:
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h11l-2 2H5v12h14V9l2-2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm5 0h2v2H8V5zm0 4h2v2H8V9zm0 4h2v2H8v-2z"/></svg>',
icon: '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h11l-2 2H5v12h14V9l2-2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm5 0h2v2H8V5zm0 4h2v2H8V9zm0 4h2v2H8v-2z"/></svg>',
render: (container) =>
require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, {
listTemplates: () => ipcRenderer.invoke('daily-templates:list'),
+3 -2
View File
@@ -43,7 +43,8 @@ function mountFootnotePreview(previewRoot, opts = {}) {
// CSS.escape() is in modern browsers but not in every test env; fallback
// strips characters that would break the selector without escaping the
// whole id (footnote ids are integers, so this is safe).
const safeId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '');
const safeId =
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '');
const target = previewRoot.querySelector(`#${safeId}`);
return target || null;
}
@@ -121,4 +122,4 @@ function mountFootnotePreview(previewRoot, opts = {}) {
};
}
module.exports = { mountFootnotePreview };
module.exports = { mountFootnotePreview };
+6 -5
View File
@@ -101,11 +101,12 @@ function renderDailyNotesPanel(container, deps) {
// listExisting returns absolute paths (newest first). Pair each path
// with its basename for display.
const paths = Array.isArray(list) ? list : [];
entries = paths
.filter((p) => /\.md$/i.test(p))
.map((p) => ({ path: p, name: basename(p) }));
entries = paths.filter((p) => /\.md$/i.test(p)).map((p) => ({ path: p, name: basename(p) }));
renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`);
if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`
);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
renderList();
@@ -138,4 +139,4 @@ function renderDailyNotesPanel(container, deps) {
};
}
module.exports = { renderDailyNotesPanel };
module.exports = { renderDailyNotesPanel };
+5 -2
View File
@@ -160,7 +160,10 @@ function renderDailyTemplatesPanel(container, deps) {
const list = await listTemplates();
entries = Array.isArray(list) ? list : [];
renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`);
if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`
);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
entries = [];
@@ -172,4 +175,4 @@ function renderDailyTemplatesPanel(container, deps) {
return { refresh };
}
module.exports = { renderDailyTemplatesPanel };
module.exports = { renderDailyTemplatesPanel };
+7 -4
View File
@@ -92,8 +92,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
@@ -166,7 +167,9 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
if (mode === 'ask') {
const r = await ask({ question: q, dir, topK: 5 });
lastResults = (r && r.chunks) || [];
setStatus(`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`);
setStatus(
`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`
);
} else {
const r = await search({ query: q, dir, limit: 50 });
lastResults = Array.isArray(r) ? r : [];
@@ -220,4 +223,4 @@ function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }
};
}
module.exports = { renderSearchPanel };
module.exports = { renderSearchPanel };
+4 -2
View File
@@ -171,7 +171,9 @@ function looksLikeCsv(text) {
// Every non-empty line must have the same number of delimiters as the
// first, allowing a ±1 slack for trailing empties.
const delim = detectDelimiter(text);
const counts = lines.slice(0, 5).map((l) => (l.match(new RegExp(escapeRegex(delim), 'g')) || []).length);
const counts = lines
.slice(0, 5)
.map((l) => (l.match(new RegExp(escapeRegex(delim), 'g')) || []).length);
const first = counts[0];
if (first < 1) return false;
return counts.every((c) => Math.abs(c - first) <= 1);
@@ -187,4 +189,4 @@ module.exports = {
escapeCell,
csvToTable,
looksLikeCsv,
};
};
+6 -7
View File
@@ -38,7 +38,7 @@ function stripMarkdown(content) {
// Links: keep the text, drop the URL part
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Wikilinks: keep the visible label
s = s.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => (c || a));
s = s.replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a);
// Headings at line start
s = s.replace(/^\s{0,3}#{1,6}\s+/gm, '');
// Blockquote markers
@@ -62,9 +62,7 @@ function splitSentences(prose) {
if (!prose) return [];
// Split on . ! ? followed by whitespace or end. Keep abbreviations rough.
const parts = prose.split(/(?<=[.!?])\s+/);
return parts
.map((s) => s.trim())
.filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2);
return parts.map((s) => s.trim()).filter((s) => /[A-Za-z0-9À-ɏ]/.test(s) && s.length >= 2);
}
/**
@@ -85,7 +83,7 @@ function countSyllablesInWord(word) {
}
function countSyllables(prose) {
const words = (prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || []);
const words = prose.match(/\b[A-Za-z'À-ɏ]+\b/g) || [];
let total = 0;
for (const w of words) total += countSyllablesInWord(w);
return total;
@@ -117,7 +115,8 @@ function computeStats(content, opts = {}) {
const words = countWords(prose);
const sentences = splitSentences(prose).length;
const syllables = countSyllables(prose);
const wpm = typeof opts.wordsPerMinute === 'number' && opts.wordsPerMinute > 0 ? opts.wordsPerMinute : 220;
const wpm =
typeof opts.wordsPerMinute === 'number' && opts.wordsPerMinute > 0 ? opts.wordsPerMinute : 220;
const readingTimeMinutes = words > 0 ? words / wpm : 0;
let grade = null;
@@ -144,4 +143,4 @@ module.exports = {
countSyllables,
countWords,
computeStats,
};
};