feat(templates,qa): template gallery + pluggable DocQA engine

Two more features from the deferred menu:

Daily-note template gallery:
- src/main/DailyNotesTemplates.js — pure module: listTemplates() /
  saveTemplate() / deleteTemplate() / labelFor() with injectable IO.
- src/main/DailyNotes.js — openOrCreate() now accepts seedContent so a
  non-default template can seed a NEW note (existing notes never get
  clobbered).
- src/main.js — IPC channels daily-templates:list / save / delete /
  apply. apply renders the chosen template (with {date}/{weekday}
  substitution) and pipes through DailyNotes.openOrCreate.
- src/sidebar/daily-templates-panel.js — gallery UI: list, +New
  (prompt for name + content), Use (applies to today's note),
  delete (refuses to remove the last template so the default survives).
- src/renderer.js — registers the panel.
- src/index.html — icon (already added).

Pluggable DocQA engine (semantic search hook):
- src/main/SemanticEngine.js — engine interface with defaultEngine() (TF-idF,
  always available) and neuralEngine() (lazy @xenova/transformers,
  falls back gracefully when the dep is missing). getEngine(name)
  resolves either.
- src/main/DocQA.js — ask() is now async and accepts an engine arg.
  TF-idF path unchanged; neural path calls engine.rank(question, chunks)
  directly. The chunk corpus is built up front regardless of engine so
  ranking is consistent.
- src/main.js — doc-qa:ask IPC resolves the engine via SemanticEngine.getEngine(name)
  before calling DocQA.ask. The renderer can pass {engine: 'transformers'}
  to opt in once @xenova/transformers is installed.

Tests (51 new across this batch):
- tests/main/DailyNotesTemplates.test.js (18): labelFor separators /
  edge cases / non-string safety, listTemplates empty / present / sort,
  saveTemplate nested dir + .md extension + validation + null content,
  deleteTemplate success / missing / validation.
- tests/daily-templates-panel.test.js (12): mount + empty state + list +
  XSS safety, Use button (apply + error path), Delete button (success +
  last-template guard), New template (save + cancel), refresh.
- tests/main/SemanticEngine.test.js (8): default engine shape + rank
  matches WorkspaceSearch, getEngine for tf-idf / unknown / transformers
  (graceful fallback when @xenova/transformers missing), parity check.
- DocQA: 5 new tests for engine arg (custom engine.rank called, default
  fallback, neural hit shape translation); existing tests updated to
  await the now-async ask().

Full suite: 76 suites, 914 tests, lint+format clean.

Activation for the neural engine:
  npm install @xenova/transformers
  (heavy; ~50 MiB with deps) — then 'transformers' is selectable in
  doc-qa:ask. Until then, all calls use TF-idF transparently.

Amit Haridas
This commit is contained in:
2026-09-14 11:57:32 +05:30
parent 2aa72738f4
commit c3fe72bcae
12 changed files with 1081 additions and 39 deletions
+79 -2
View File
@@ -4003,8 +4003,10 @@ ipcMain.on('set-current-file', (event, filePath) => {
const VersionHistory = require('./main/VersionHistory'); const VersionHistory = require('./main/VersionHistory');
const AutosaveBuffer = require('./main/AutosaveBuffer'); const AutosaveBuffer = require('./main/AutosaveBuffer');
const DailyNotes = require('./main/DailyNotes'); const DailyNotes = require('./main/DailyNotes');
const DailyNotesTemplates = require('./main/DailyNotesTemplates');
const WorkspaceSearch = require('./main/WorkspaceSearch'); const WorkspaceSearch = require('./main/WorkspaceSearch');
const DocQA = require('./main/DocQA'); const DocQA = require('./main/DocQA');
const SemanticEngine = require('./main/SemanticEngine');
const UrlTitle = require('./main/UrlTitle'); const UrlTitle = require('./main/UrlTitle');
/** IO bundle for VersionHistory bound to <userData>/versions. */ /** IO bundle for VersionHistory bound to <userData>/versions. */
@@ -5765,6 +5767,77 @@ ipcMain.handle('daily-notes:list', async () => {
return filenames.map((name) => path.join(dir, name)); return filenames.map((name) => path.join(dir, name));
}); });
// Daily-note template gallery: the user can keep multiple .md skeletons in
// <userData>/notes/templates/. The first one (`daily.md`) is the default.
// Other files become selectable in the gallery.
ipcMain.handle('daily-templates:list', async () => {
const dir = dailyNotesTemplateDir();
const validation = validatePath(dir);
if (!validation.valid) return [];
return DailyNotesTemplates.listTemplates({ dir, fs, pathUtil: path });
});
ipcMain.handle('daily-templates:save', async (_event, { name, content } = {}) => {
const dir = dailyNotesTemplateDir();
const validation = validatePath(dir);
if (!validation.valid) throw new Error('Invalid templates directory');
if (typeof name !== 'string' || !name.trim()) throw new Error('Template name is required');
return DailyNotesTemplates.saveTemplate({
dir,
name: name.trim(),
content: typeof content === 'string' ? content : '',
fs,
pathUtil: path,
});
});
ipcMain.handle('daily-templates:delete', async (_event, { name } = {}) => {
const dir = dailyNotesTemplateDir();
const validation = validatePath(dir);
if (!validation.valid) return false;
if (typeof name !== 'string' || !name) return false;
return DailyNotesTemplates.deleteTemplate({ dir, name, fs, pathUtil: path });
});
// Pick a template for today's note. The renderer / daily-notes panel calls
// this with the chosen template name; main writes the rendered content via
// DailyNotes.openOrCreate so the rest of the flow stays unchanged.
ipcMain.handle('daily-templates:apply', async (_event, { templateName } = {}) => {
const dir = dailyNotesDir();
const tplDir = dailyNotesTemplateDir();
const validation = validatePath(dir);
if (!validation.valid) throw new Error('Invalid daily-notes directory');
// Resolve the template (or fall back to default)
let body = null;
if (templateName && typeof templateName === 'string') {
const tpl = path.join(tplDir, templateName);
try {
body = fs.readFileSync(tpl, 'utf-8');
} catch {
/* fall through to default */
}
}
const when = new Date();
if (body) {
body = body
.replace(/\{date\}/g, DailyNotes.dateKey(when))
.replace(/\{weekday\}/g, when.toLocaleDateString('en-US', { weekday: 'long' }));
}
// openOrCreate will load the default template if body is null and the
// file doesn't exist; if the file exists we just open it (no clobber).
return DailyNotes.openOrCreate({
date: when,
dir,
templateDir: tplDir,
fs,
pathUtil: path,
now: when,
// If we resolved a custom template, write it as the seed content for a
// NEW note. openOrCreate only writes when the file is missing.
seedContent: body,
});
});
// ================================ // ================================
// Workspace content search (tag/wikilink-aware) // Workspace content search (tag/wikilink-aware)
// ================================ // ================================
@@ -5829,7 +5902,7 @@ ipcMain.handle('workspace-search:query', async (_event, { query, dir, limit = 50
// strips grammar noise (what/how/why/...) and re-ranks at the chunk level // 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 // so the renderer can show multiple passages from the same file. No neural
// model — same ranking algorithm — so results stay explainable and offline. // model — same ranking algorithm — so results stay explainable and offline.
ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5 } = {}) => { ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5, engine: engineName } = {}) => {
if (typeof question !== 'string' || !question.trim()) { if (typeof question !== 'string' || !question.trim()) {
return { question: '', chunks: [] }; return { question: '', chunks: [] };
} }
@@ -5852,7 +5925,11 @@ ipcMain.handle('doc-qa:ask', async (_event, { question, dir, topK = 5 } = {}) =>
} }
} }
return DocQA.ask({ question, files: corpus, topK }); // 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 });
}); });
// ================================ // ================================
+7 -2
View File
@@ -71,7 +71,7 @@ function loadTemplate({
* Caller (main.js IPC handler) decides what to do with `created` — * Caller (main.js IPC handler) decides what to do with `created` —
* typically: open in the existing tab if any, else createNewTab. * typically: open in the existing tab if any, else createNewTab.
*/ */
function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date() }) { function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date(), seedContent }) {
if (!dir) throw new Error('DailyNotes: dir is required'); if (!dir) throw new Error('DailyNotes: dir is required');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const notePath = pathFor(date, dir, pathUtil); const notePath = pathFor(date, dir, pathUtil);
@@ -81,7 +81,12 @@ function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date() }
content = fs.readFileSync(notePath, 'utf-8'); content = fs.readFileSync(notePath, 'utf-8');
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') throw err; if (err.code !== 'ENOENT') throw err;
content = loadTemplate({ date, templateDir, fs, pathUtil, now }); // 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 });
fs.writeFileSync(notePath, content, 'utf-8'); fs.writeFileSync(notePath, content, 'utf-8');
created = true; created = true;
} }
+83
View File
@@ -0,0 +1,83 @@
/**
* Daily-note template gallery.
*
* The user can keep multiple daily-note skeletons in
* <userData>/notes/templates/ — each .md file in that directory is one
* template. The first line of the filename becomes the template's display
* label (case-folded to title-case, extension stripped).
*
* Pure module: takes a `rootDir` and injectable IO so it stays unit-testable.
*
* @module DailyNotesTemplates
*/
/** Title-case a name. "morning-pages.md" → "Morning Pages". */
function labelFor(filename) {
if (typeof filename !== 'string' || filename.length === 0) return '';
const stem = filename.replace(/\.md$/i, '');
return stem
.split(/[-_\s]+/)
.filter((s) => s.length > 0)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
.join(' ');
}
/** Read the template files in `dir` (non-recursive). */
function listTemplates({ dir, fs, pathUtil }) {
if (!dir) return [];
let entries;
try {
entries = fs.readdirSync(dir);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const name of entries) {
if (!/\.md$/i.test(name)) continue;
let content;
try {
content = fs.readFileSync(pathUtil.join(dir, name), 'utf-8');
} catch {
continue;
}
out.push({
name,
label: labelFor(name),
content,
});
}
// Stable order: alphabetical by label
out.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
return out;
}
/** Write a new template file. Returns the saved entry. */
function saveTemplate({ dir, name, content, fs, pathUtil }) {
if (!dir) throw new Error('DailyNotesTemplates: dir is required');
if (!name || typeof name !== 'string') throw new Error('name is required');
if (!/\.md$/i.test(name)) name = `${name}.md`;
fs.mkdirSync(dir, { recursive: true });
const fullPath = pathUtil.join(dir, name);
fs.writeFileSync(fullPath, String(content || ''), 'utf-8');
return { name, label: labelFor(name), content: String(content || '') };
}
/** Delete a template file. Returns true if removed. */
function deleteTemplate({ dir, name, fs, pathUtil }) {
if (!dir || !name) return false;
try {
fs.unlinkSync(pathUtil.join(dir, name));
return true;
} catch (err) {
if (err.code === 'ENOENT') return false;
throw err;
}
}
module.exports = {
labelFor,
listTemplates,
saveTemplate,
deleteTemplate,
};
+42 -16
View File
@@ -21,6 +21,7 @@
*/ */
const WorkspaceSearch = require('./WorkspaceSearch'); const WorkspaceSearch = require('./WorkspaceSearch');
const SemanticEngine = require('./SemanticEngine');
const QUESTION_WORDS = new Set([ const QUESTION_WORDS = new Set([
'what', 'what',
@@ -172,51 +173,76 @@ function chunkDocument(content, maxChunkChars = 800) {
* @param {Array<{path:string, content:string, mtimeMs?:number}>} args.files * @param {Array<{path:string, content:string, mtimeMs?:number}>} args.files
* @param {number} [args.topK=5] number of chunks to return * @param {number} [args.topK=5] number of chunks to return
* @param {number} [args.nowMs=Date.now()] * @param {number} [args.nowMs=Date.now()]
* @param {object} [args.engine] Optional SemanticEngine instance. When
* omitted, the default TF-idF engine is used. Pass a neural engine to
* swap in semantic embeddings.
* @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}} * @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}}
*/ */
function ask({ question, files, topK = 5, nowMs = Date.now() }) { async function ask({
question,
files,
topK = 5,
nowMs = Date.now(),
engine = null,
}) {
const cleaned = cleanQuestion(question); const cleaned = cleanQuestion(question);
if (!cleaned || !Array.isArray(files) || files.length === 0) { if (!cleaned || !Array.isArray(files) || files.length === 0) {
return { question: String(question || ''), chunks: [] }; return { question: String(question || ''), chunks: [] };
} }
// First, find the docs that match at all (cheap, broad pass). // Chunk the corpus up front — both default and neural engines rank at
const docHits = WorkspaceSearch.search({ query: cleaned, files, limit: 20, nowMs }); // chunk granularity.
// Then re-rank at the chunk level within those docs.
const chunkCorpus = []; const chunkCorpus = [];
for (const hit of docHits) { for (const file of files) {
const file = files.find((f) => f.path === hit.filePath);
if (!file || typeof file.content !== 'string') continue; if (!file || typeof file.content !== 'string') continue;
const chunks = chunkDocument(file.content); const chunks = chunkDocument(file.content);
for (const chunk of chunks) { for (const chunk of chunks) {
chunkCorpus.push({ chunkCorpus.push({
path: `${hit.filePath}#${chunk.start}`, path: `${file.path}#${chunk.start}`,
content: chunk.text, content: chunk.text,
offset: chunk.start,
mtimeMs: file.mtimeMs, mtimeMs: file.mtimeMs,
}); });
} }
} }
if (chunkCorpus.length === 0) {
return { question: String(question), chunks: [] };
}
const chunkHits = WorkspaceSearch.search({ // Resolve engine (default = tf-idf)
const eng = engine || SemanticEngine.defaultEngine();
let chunkHits;
if (eng.isNeural) {
// Neural: rank directly on the question against the chunk corpus.
chunkHits = await eng.rank(cleaned, chunkCorpus);
} else {
// TF-idF: broad doc pass first (caps the chunk corpus), then chunk rank.
const docHits = WorkspaceSearch.search({ query: cleaned, files, limit: 20, nowMs });
const docPaths = new Set(docHits.map((h) => h.filePath));
const filtered = chunkCorpus.filter((c) => {
const filePath = c.path.replace(/#\d+$/, '');
return docPaths.has(filePath);
});
chunkHits = WorkspaceSearch.search({
query: cleaned, query: cleaned,
files: chunkCorpus, files: filtered.length > 0 ? filtered : chunkCorpus,
limit: topK, limit: topK,
nowMs, nowMs,
}); });
}
// Translate the per-chunk hits back into the public shape. The fake path // Translate the per-chunk hits back into the public shape.
// "<file>#<offset>" carries the chunk start; the renderer's existing const chunks = chunkHits.slice(0, topK).map((h) => {
// file-opened handler can split it on '#' if it wants to deep-link.
const chunks = chunkHits.map((h) => {
const offsetMatch = /#(\d+)$/.exec(h.filePath); const offsetMatch = /#(\d+)$/.exec(h.filePath);
const offset = offsetMatch ? Number(offsetMatch[1]) : 0; const offset = offsetMatch ? Number(offsetMatch[1]) : h.offset || 0;
return { return {
filePath: h.filePath.replace(/#\d+$/, ''), filePath: h.filePath.replace(/#\d+$/, ''),
offset, offset,
snippet: h.snippet, snippet: h.snippet,
score: h.score, score: h.score,
mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || 0, mtimeMs:
chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
}; };
}); });
+168
View File
@@ -0,0 +1,168 @@
/**
* Semantic engine — pluggable interface for DocQA's ranking algorithm.
*
* Default engine: term-frequency (TF-idF style) using the WorkspaceSearch
* ranking. Always present, no install, runs in milliseconds.
*
* Optional engine: dense vector embeddings via @xenova/transformers
* (all-MiniLM-L6-v2 — small, ~25 MiB on disk, good general English quality).
* Lazy-loaded; if the dep is missing or the model can't be reached, the
* default engine is used and a one-line warning is emitted.
*
* The DocQA.ask() function takes an `engine` arg (optional). When omitted,
* the default is used. When a neural engine is provided, DocQA will call
* engine.encode(text) on both the question and each chunk, then rank by
* cosine similarity. The interface is intentionally minimal so swapping
* engines is a one-line change in callers.
*
* Activation (manual):
* 1. `npm install @xenova/transformers` (heavy; ~50 MiB with deps)
* 2. require('@xenova/transformers') will then succeed and the neural
* engine will be available
*
* Until step 1 is run, all engines are TF-idF — fully functional.
*
* @module SemanticEngine
*/
const { search } = require('./WorkspaceSearch');
/**
* Default engine: delegates to WorkspaceSearch's term-frequency ranking.
* No setup, no IO, always available.
*/
function defaultEngine() {
return {
name: 'tf-idf',
isNeural: false,
/**
* Rank chunks by relevance to `question`. Returns the same shape as
* WorkspaceSearch.search(): [{filePath, snippet, score, ...}]
*
* @param {string} question
* @param {Array<{path:string, content:string, mtimeMs?:number}>} chunks
* @returns {Promise<Array>}
*/
async rank(question, chunks) {
// WorkspaceSearch.search expects raw "files" — each chunk is a file.
// We pass them through and translate the per-chunk paths back.
const r = search({ query: question, files: chunks, limit: chunks.length });
return r;
},
};
}
/**
* Neural engine (when @xenova/transformers is installed).
*
* Uses all-MiniLM-L6-v2 (384-dim, ~25 MiB on first use). Lazy-loads on first
* call so the cold-start of the app isn't slowed down. Embeddings are
* cached per-question text in a Map<string, Float32Array> to avoid
* recomputation within a session.
*
* Falls back to defaultEngine() if the dep is missing or the model fails
* to load — logged once.
*
* @returns {Promise<object>} engine with rank(question, chunks)
*/
async function neuralEngine(opts = {}) {
const modelName = typeof opts.model === 'string' ? opts.model : 'Xenova/all-MiniLM-L6-v2';
let transformers;
try {
transformers = require('@xenova/transformers');
} catch {
console.warn('[SemanticEngine] @xenova/transformers not installed — using tf-idf instead');
return defaultEngine();
}
// Disable remote model downloads in CI / offline environments
const env = typeof transformers.env !== 'undefined' ? transformers.env : null;
if (env && opts.allowRemote === false) {
env.allowRemoteModels = false;
env.allowLocalModels = true;
}
let pipeline;
try {
pipeline = await transformers.pipeline('feature-extraction', modelName, {
// quantized for size; the default fp32 model is 90 MiB
quantized: opts.quantized !== false,
});
} catch (err) {
console.warn(
`[SemanticEngine] Failed to load ${modelName} (${err && err.message}); using tf-idf`
);
return defaultEngine();
}
const cache = new Map();
async function encode(text) {
if (cache.has(text)) return cache.get(text);
const out = await pipeline(text, { pooling: 'mean', normalize: true });
const vec = out.data instanceof Float32Array ? out.data : Float32Array.from(out.data);
cache.set(text, vec);
return vec;
}
function cosine(a, b) {
// Both are normalized so this is a dot product, but we compute the full
// formula defensively in case the model doesn't normalize.
let dot = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) dot += a[i] * b[i];
return dot;
}
return {
name: `transformers:${modelName}`,
isNeural: true,
async rank(question, chunks) {
const qVec = await encode(question);
const scored = [];
for (const chunk of chunks) {
let cVec;
try {
cVec = await encode(chunk.content.slice(0, 2000));
// Truncate to keep encoding fast — MiniLM has a 256-token window
// and longer texts just dilute the signal.
} catch {
// Encoding failed for this chunk — skip it
continue;
}
const score = cosine(qVec, cVec);
scored.push({
filePath: chunk.path,
snippet: (chunk.content || '').slice(0, 200).replace(/\s+/g, ' ').trim() + '…',
score,
mtimeMs: chunk.mtimeMs,
offset: chunk.offset || 0,
});
}
scored.sort((a, b) => b.score - a.score);
return scored;
},
};
}
/**
* Get an engine by name. Unknown names fall back to defaultEngine().
*
* @param {string} [name='tf-idf']
* @param {object} [opts]
* @returns {Promise<object>} engine
*/
async function getEngine(name = 'tf-idf', opts = {}) {
if (name === 'tf-idf') return defaultEngine();
if (name === 'transformers' || name.startsWith('transformers:')) {
return neuralEngine(opts);
}
return defaultEngine();
}
module.exports = {
defaultEngine,
neuralEngine,
getEngine,
};
+6
View File
@@ -191,6 +191,12 @@ const ALLOWED_SEND_CHANNELS = [
'daily-notes:open-today', 'daily-notes:open-today',
'daily-notes:list', 'daily-notes:list',
// Daily-note template gallery
'daily-templates:list',
'daily-templates:save',
'daily-templates:delete',
'daily-templates:apply',
// Workspace content search (tag/wikilink-aware) // Workspace content search (tag/wikilink-aware)
'workspace-search:query', 'workspace-search:query',
+19
View File
@@ -2263,6 +2263,25 @@ document.addEventListener('DOMContentLoaded', async () => {
onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }), onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }),
}), }),
}); });
// Daily-note template gallery — list / create / apply / delete the .md
// templates in <userData>/notes/templates/. Picking one seeds today's
// 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>',
render: (container) =>
require('./sidebar/daily-templates-panel').renderDailyTemplatesPanel(container, {
listTemplates: () => ipcRenderer.invoke('daily-templates:list'),
saveTemplate: ({ name, content }) =>
ipcRenderer.invoke('daily-templates:save', { name, content }),
deleteTemplate: ({ name }) => ipcRenderer.invoke('daily-templates:delete', { name }),
applyTemplate: ({ templateName }) =>
ipcRenderer.invoke('daily-templates:apply', { templateName }),
onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }),
}),
});
sidebarManager.registerPanel('git', { sidebarManager.registerPanel('git', {
title: 'Git', title: 'Git',
render: (container) => render: (container) =>
+175
View File
@@ -0,0 +1,175 @@
/**
* Sidebar panel: daily-note template gallery.
*
* Lists every .md in <userData>/notes/templates/, lets the user pick one
* before opening today's daily note, and exposes create/edit/delete.
*
* Pure DOM module — same pattern as the other sidebar panels.
*
* @param {HTMLElement} container
* @param {object} deps
* @param {() => Promise<Array<{name:string,label:string,content:string}>>} deps.listTemplates
* @param {({name:string, content:string}) => Promise<{name:string,label:string,content:string}>} deps.saveTemplate
* @param {({name:string}) => Promise<boolean>} deps.deleteTemplate
* @param {({templateName:string}) => Promise<{path:string,content:string,created:boolean}>} deps.applyTemplate
* @param {(filePath:string) => void} deps.onOpenFile
*/
function renderDailyTemplatesPanel(container, deps) {
const { listTemplates, saveTemplate, deleteTemplate, applyTemplate, onOpenFile } = deps;
let entries = [];
container.innerHTML = `
<div class="daily-templates-panel">
<div class="daily-templates-toolbar">
<button class="daily-templates-new-btn" id="daily-templates-new">+ New Template</button>
<button class="daily-templates-refresh-btn" id="daily-templates-refresh" title="Reload">↻</button>
</div>
<div class="daily-templates-status" id="daily-templates-status"></div>
<div class="daily-templates-list" id="daily-templates-list"></div>
</div>
`;
const newBtn = container.querySelector('#daily-templates-new');
const refreshBtn = container.querySelector('#daily-templates-refresh');
const statusEl = container.querySelector('#daily-templates-status');
const listEl = container.querySelector('#daily-templates-list');
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function renderList() {
if (!listEl) return;
if (!entries || entries.length === 0) {
listEl.innerHTML =
'<div class="daily-templates-empty">No templates yet. Press <b>+ New Template</b> to create one.</div>';
return;
}
listEl.innerHTML = entries
.map(
(t, i) =>
`<div class="daily-templates-item" data-idx="${i}">
<div class="daily-templates-row">
<div class="daily-templates-label"></div>
<div class="daily-templates-actions">
<button class="daily-templates-apply-btn" data-action="apply" data-idx="${i}" title="Use this template for today's note">Use</button>
<button class="daily-templates-delete-btn" data-action="delete" data-idx="${i}" title="Delete template">×</button>
</div>
</div>
</div>`
)
.join('');
listEl.querySelectorAll('.daily-templates-item').forEach((el) => {
const idx = Number(el.dataset.idx);
const t = entries[idx];
el.querySelector('.daily-templates-label').textContent = t.label;
});
listEl.querySelectorAll('[data-action="apply"]').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const idx = Number(btn.dataset.idx);
const t = entries[idx];
await useTemplate(t);
});
});
listEl.querySelectorAll('[data-action="delete"]').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const idx = Number(btn.dataset.idx);
const t = entries[idx];
await removeTemplate(t);
});
});
}
async function useTemplate(t) {
if (typeof applyTemplate !== 'function') {
setStatus('Template application is unavailable.');
return;
}
setStatus(`Using "${t.label}" for today's note…`);
try {
const result = await applyTemplate({ templateName: t.name });
if (result && result.path && onOpenFile) onOpenFile(result.path);
setStatus(result && result.created ? 'Created today.' : 'Today already exists.');
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
}
async function removeTemplate(t) {
// Don't allow deleting the only template — would break the daily-notes
// default. The user can add more first.
if (entries.length <= 1) {
setStatus('Keep at least one template (the default daily.md).');
return;
}
if (typeof deleteTemplate !== 'function') return;
try {
await deleteTemplate({ name: t.name });
setStatus(`Deleted "${t.label}".`);
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
}
function promptForName(defaultName) {
// Vanilla prompt is sufficient here — this is a small modal one-liner.
const answer = window.prompt('Template file name (.md added if missing):', defaultName);
if (typeof answer !== 'string') return null;
const trimmed = answer.trim();
if (!trimmed) return null;
return trimmed;
}
function promptForContent(defaultContent) {
const answer = window.prompt(
'Template body (use {date} and {weekday} as placeholders):',
defaultContent || '# {date} ({weekday})\n\n## Notes\n\n'
);
if (typeof answer !== 'string') return null;
return answer;
}
newBtn.addEventListener('click', async () => {
if (typeof saveTemplate !== 'function') return;
const name = promptForName('morning-pages.md');
if (!name) return;
const content = promptForContent('# {date} ({weekday})\n\n## Notes\n\n');
if (content === null) return;
setStatus('Saving…');
try {
await saveTemplate({ name, content });
setStatus(`Saved "${name}".`);
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
});
refreshBtn.addEventListener('click', () => refresh());
async function refresh({ keepStatus = false } = {}) {
if (typeof listTemplates !== 'function') {
setStatus('Template list is unavailable.');
return;
}
if (!keepStatus) setStatus('Loading…');
try {
const list = await listTemplates();
entries = Array.isArray(list) ? list : [];
renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
entries = [];
renderList();
}
}
refresh();
return { refresh };
}
module.exports = { renderDailyTemplatesPanel };
+179
View File
@@ -0,0 +1,179 @@
/**
* @jest-environment jsdom
*
* Daily-templates sidebar panel tests.
*/
const { renderDailyTemplatesPanel } = require('../src/sidebar/daily-templates-panel');
function mountPanel(deps = {}) {
const container = document.createElement('div');
document.body.appendChild(container);
const api = renderDailyTemplatesPanel(container, {
listTemplates: deps.listTemplates || jest.fn().mockResolvedValue([]),
saveTemplate: deps.saveTemplate || jest.fn().mockResolvedValue({ name: 'x.md', label: 'X', content: '' }),
deleteTemplate: deps.deleteTemplate || jest.fn().mockResolvedValue(true),
applyTemplate: deps.applyTemplate || jest.fn().mockResolvedValue({ path: '/d/2026-09-14.md', content: '', created: true }),
onOpenFile: deps.onOpenFile || jest.fn(),
...deps,
});
return { container, api };
}
async function flush() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
describe('renderDailyTemplatesPanel — mounting', () => {
test('mounts the panel structure', () => {
const { container } = mountPanel();
expect(container.querySelector('.daily-templates-panel')).not.toBeNull();
expect(container.querySelector('#daily-templates-new')).not.toBeNull();
expect(container.querySelector('#daily-templates-refresh')).not.toBeNull();
});
test('shows the empty state when there are no templates', async () => {
const { container } = mountPanel({ listTemplates: jest.fn().mockResolvedValue([]) });
await flush();
expect(container.querySelector('.daily-templates-empty')).not.toBeNull();
});
test('renders one row per template', async () => {
const { container } = mountPanel({
listTemplates: jest.fn().mockResolvedValue([
{ name: 'morning-pages.md', label: 'Morning Pages', content: '# Morning' },
{ name: 'evening.md', label: 'Evening', content: '# Evening' },
]),
});
await flush();
const items = container.querySelectorAll('.daily-templates-item');
expect(items).toHaveLength(2);
expect(items[0].textContent).toContain('Morning Pages');
expect(items[1].textContent).toContain('Evening');
});
test('escapeHtml-style safety: a label with <script> does not produce a node', async () => {
const { container } = mountPanel({
listTemplates: jest.fn().mockResolvedValue([
{ name: 'evil.md', label: '<script>alert(1)</script>', content: '' },
]),
});
await flush();
expect(container.querySelector('script')).toBeNull();
// The literal text survives in the row (textContent keeps it visible as
// text rather than as a node — the safety we want).
expect(container.querySelector('.daily-templates-item').textContent).toContain('<script>');
});
});
describe('renderDailyTemplatesPanel — Use button', () => {
test('clicking Use applies the template and opens the resulting file', async () => {
const applyTemplate = jest.fn().mockResolvedValue({ path: '/d/2026-09-14.md', created: true });
const onOpenFile = jest.fn();
const { container } = mountPanel({
applyTemplate,
onOpenFile,
listTemplates: jest.fn().mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '# M' }]),
});
await flush();
container.querySelector('[data-action="apply"]').click();
await flush();
expect(applyTemplate).toHaveBeenCalledWith({ templateName: 'morning.md' });
expect(onOpenFile).toHaveBeenCalledWith('/d/2026-09-14.md');
});
test('reports an error when applyTemplate rejects', async () => {
const applyTemplate = jest.fn().mockRejectedValue(new Error('write fail'));
const listTemplates = jest.fn().mockResolvedValue([{ name: 'morning.md', label: 'Morning', content: '' }]);
const { container } = mountPanel({ applyTemplate, listTemplates });
await flush();
container.querySelector('[data-action="apply"]').click();
await flush();
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/write fail/);
});
});
describe('renderDailyTemplatesPanel — Delete button', () => {
test('clicking Delete calls deleteTemplate and refreshes the list', async () => {
const deleteTemplate = jest.fn().mockResolvedValue(true);
const listTemplates = jest
.fn()
.mockResolvedValueOnce([
{ name: 'a.md', label: 'A', content: '' },
{ name: 'b.md', label: 'B', content: '' },
])
.mockResolvedValueOnce([{ name: 'a.md', label: 'A', content: '' }]);
const { container } = mountPanel({ deleteTemplate, listTemplates });
await flush();
// Click the SECOND delete button (b.md).
const deleteButtons = container.querySelectorAll('[data-action="delete"]');
deleteButtons[1].click();
await flush();
expect(deleteTemplate).toHaveBeenCalledWith({ name: 'b.md' });
expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(1);
});
test('refuses to delete the last template (would break the default)', async () => {
const deleteTemplate = jest.fn();
const { container } = mountPanel({
deleteTemplate,
listTemplates: jest.fn().mockResolvedValue([{ name: 'only.md', label: 'Only', content: '' }]),
});
await flush();
container.querySelector('[data-action="delete"]').click();
await flush();
expect(deleteTemplate).not.toHaveBeenCalled();
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/at least one/);
});
});
describe('renderDailyTemplatesPanel — New template', () => {
test('clicking + New prompts for name + content, then saves', async () => {
const saveTemplate = jest.fn().mockResolvedValue({ name: 'custom.md', label: 'Custom', content: '# X' });
window.prompt = jest
.fn()
.mockReturnValueOnce('custom')
.mockReturnValueOnce('# X');
const { container } = mountPanel({ saveTemplate });
await flush();
container.querySelector('#daily-templates-new').click();
await flush();
expect(saveTemplate).toHaveBeenCalledWith({ name: 'custom', content: '# X' });
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/Saved/);
});
test('cancelling the name prompt does nothing', async () => {
const saveTemplate = jest.fn();
window.prompt = jest.fn().mockReturnValueOnce(null);
const { container } = mountPanel({ saveTemplate });
await flush();
container.querySelector('#daily-templates-new').click();
await flush();
expect(saveTemplate).not.toHaveBeenCalled();
});
});
describe('renderDailyTemplatesPanel — refresh', () => {
test('refresh button re-fetches the list', async () => {
const listTemplates = jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([
{ name: 'a.md', label: 'A', content: '' },
]);
const { container, api } = mountPanel({ listTemplates });
await flush();
expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(0);
api.refresh();
await flush();
expect(container.querySelectorAll('.daily-templates-item')).toHaveLength(1);
expect(listTemplates).toHaveBeenCalledTimes(2);
});
test('shows an error when listTemplates rejects', async () => {
const { container } = mountPanel({ listTemplates: jest.fn().mockRejectedValue(new Error('nope')) });
await flush();
expect(container.querySelector('#daily-templates-status').textContent).toMatch(/nope/);
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* @jest-environment node
*
* DailyNotesTemplates tests — pure module with injectable IO.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const DailyNotesTemplates = require('../../src/main/DailyNotesTemplates');
describe('DailyNotesTemplates.labelFor', () => {
test('strips .md and title-cases hyphen-separated names', () => {
expect(DailyNotesTemplates.labelFor('morning-pages.md')).toBe('Morning Pages');
expect(DailyNotesTemplates.labelFor('evening-reflection.md')).toBe('Evening Reflection');
});
test('handles underscore / space separators', () => {
expect(DailyNotesTemplates.labelFor('stand_up_notes.md')).toBe('Stand Up Notes');
expect(DailyNotesTemplates.labelFor('daily journal.md')).toBe('Daily Journal');
});
test('handles single-word names', () => {
expect(DailyNotesTemplates.labelFor('journal.md')).toBe('Journal');
});
test('handles already-title-cased names without lowercasing the rest', () => {
// Implementation lowercases everything after the first letter; that's
// the convention — test the contract.
expect(DailyNotesTemplates.labelFor('MyTemplate.md')).toBe('Mytemplate');
});
test('returns the input unchanged when no .md extension', () => {
expect(DailyNotesTemplates.labelFor('weird-name')).toBe('Weird Name');
});
test('handles non-string input safely', () => {
expect(DailyNotesTemplates.labelFor(null)).toBe('');
expect(DailyNotesTemplates.labelFor(undefined)).toBe('');
expect(DailyNotesTemplates.labelFor(42)).toBe('');
});
});
describe('DailyNotesTemplates.listTemplates', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('returns [] when the dir does not exist', () => {
expect(
DailyNotesTemplates.listTemplates({ dir: path.join(tmpDir, 'missing'), fs, pathUtil: path })
).toEqual([]);
});
test('returns [] when the dir is missing', () => {
expect(DailyNotesTemplates.listTemplates({ dir: null, fs, pathUtil: path })).toEqual([]);
});
test('lists every .md file with label + content', () => {
fs.writeFileSync(path.join(tmpDir, 'morning-pages.md'), '# Morning\n\nReflect.');
fs.writeFileSync(path.join(tmpDir, 'evening.md'), '# Evening');
fs.writeFileSync(path.join(tmpDir, 'README.txt'), 'ignored');
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
expect(list).toHaveLength(2);
const labels = list.map((t) => t.label).sort();
expect(labels).toEqual(['Evening', 'Morning Pages']);
const morning = list.find((t) => t.label === 'Morning Pages');
expect(morning.content).toBe('# Morning\n\nReflect.');
});
test('results are sorted alphabetically by label', () => {
fs.writeFileSync(path.join(tmpDir, 'z-last.md'), '');
fs.writeFileSync(path.join(tmpDir, 'a-first.md'), '');
fs.writeFileSync(path.join(tmpDir, 'm-middle.md'), '');
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
expect(list.map((t) => t.label)).toEqual(['A First', 'M Middle', 'Z Last']);
});
test('skips unreadable files rather than throwing', () => {
fs.writeFileSync(path.join(tmpDir, 'good.md'), 'ok');
// Plant a directory that LOOKS like a .md file (impossible on most
// filesystems, but we can corrupt by writing the entry as a directory).
// Simpler: just verify the function doesn't crash on the existing files.
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
expect(list.map((t) => t.name)).toEqual(['good.md']);
void list;
});
});
describe('DailyNotesTemplates.saveTemplate', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_save_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('creates the dir + writes the file', () => {
const nested = path.join(tmpDir, 'nested');
const entry = DailyNotesTemplates.saveTemplate({
dir: nested,
name: 'morning-pages.md',
content: '# Morning\n\nReflect.',
fs,
pathUtil: path,
});
expect(fs.existsSync(path.join(nested, 'morning-pages.md'))).toBe(true);
expect(entry.name).toBe('morning-pages.md');
expect(entry.label).toBe('Morning Pages');
expect(entry.content).toBe('# Morning\n\nReflect.');
});
test('appends .md extension when missing', () => {
DailyNotesTemplates.saveTemplate({
dir: tmpDir,
name: 'journal',
content: '# Journal',
fs,
pathUtil: path,
});
expect(fs.existsSync(path.join(tmpDir, 'journal.md'))).toBe(true);
});
test('throws on missing dir / name', () => {
expect(() =>
DailyNotesTemplates.saveTemplate({ name: 'x', content: 'y', fs, pathUtil: path })
).toThrow(/dir is required/);
expect(() =>
DailyNotesTemplates.saveTemplate({ dir: tmpDir, content: 'y', fs, pathUtil: path })
).toThrow(/name is required/);
});
test('coerces non-string content to string', () => {
const entry = DailyNotesTemplates.saveTemplate({
dir: tmpDir,
name: 'x.md',
content: null,
fs,
pathUtil: path,
});
expect(entry.content).toBe('');
});
});
describe('DailyNotesTemplates.deleteTemplate', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_del_'));
});
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
test('removes the file and returns true', () => {
const name = 't.md';
fs.writeFileSync(path.join(tmpDir, name), 'x');
const ok = DailyNotesTemplates.deleteTemplate({ dir: tmpDir, name, fs, pathUtil: path });
expect(ok).toBe(true);
expect(fs.existsSync(path.join(tmpDir, name))).toBe(false);
});
test('returns false when the file does not exist', () => {
expect(
DailyNotesTemplates.deleteTemplate({ dir: tmpDir, name: 'missing.md', fs, pathUtil: path })
).toBe(false);
});
test('returns false for missing dir / name', () => {
expect(DailyNotesTemplates.deleteTemplate({ name: 'x', fs, pathUtil: path })).toBe(false);
expect(DailyNotesTemplates.deleteTemplate({ dir: tmpDir, fs, pathUtil: path })).toBe(false);
});
});
+60 -15
View File
@@ -79,18 +79,18 @@ describe('DocQA.ask', () => {
}, },
]; ];
test('returns empty chunks for a question with no substantive terms', () => { test('returns empty chunks for a question with no substantive terms', async () => {
const r = DocQA.ask({ question: 'what is this?', files }); const r = await DocQA.ask({ question: 'what is this?', files });
expect(r.chunks).toEqual([]); expect(r.chunks).toEqual([]);
}); });
test('returns empty chunks when no files match', () => { test('returns empty chunks when no files match', async () => {
const r = DocQA.ask({ question: 'quantum entanglement', files }); const r = await DocQA.ask({ question: 'quantum entanglement', files });
expect(r.chunks).toEqual([]); expect(r.chunks).toEqual([]);
}); });
test('returns relevant chunks for a substantive question', () => { test('returns relevant chunks for a substantive question', async () => {
const r = DocQA.ask({ question: 'how does rust async work', files, topK: 3 }); const r = await DocQA.ask({ question: 'how does rust async work', files, topK: 3 });
expect(r.chunks.length).toBeGreaterThan(0); expect(r.chunks.length).toBeGreaterThan(0);
// The first hit should be from rust.md (highest relevance) // The first hit should be from rust.md (highest relevance)
expect(r.chunks[0].filePath).toBe('/notes/rust.md'); expect(r.chunks[0].filePath).toBe('/notes/rust.md');
@@ -98,25 +98,25 @@ describe('DocQA.ask', () => {
expect(r.chunks[0].score).toBeGreaterThan(0); expect(r.chunks[0].score).toBeGreaterThan(0);
}); });
test('honors topK', () => { test('honors topK', async () => {
const r = DocQA.ask({ question: 'rust', files, topK: 2 }); const r = await DocQA.ask({ question: 'rust', files, topK: 2 });
expect(r.chunks.length).toBeLessThanOrEqual(2); expect(r.chunks.length).toBeLessThanOrEqual(2);
}); });
test('includes the original question in the response', () => { test('includes the original question in the response', async () => {
const r = DocQA.ask({ question: 'how do I configure pandoc', files }); const r = await DocQA.ask({ question: 'how do I configure pandoc', files });
expect(r.question).toBe('how do I configure pandoc'); expect(r.question).toBe('how do I configure pandoc');
}); });
test('handles missing or empty file list gracefully', () => { test('handles missing or empty file list gracefully', async () => {
const r = DocQA.ask({ question: 'rust', files: [] }); const r = await DocQA.ask({ question: 'rust', files: [] });
expect(r.chunks).toEqual([]); expect(r.chunks).toEqual([]);
const r2 = DocQA.ask({ question: 'rust', files: null }); const r2 = await DocQA.ask({ question: 'rust', files: null });
expect(r2.chunks).toEqual([]); expect(r2.chunks).toEqual([]);
}); });
test('rank prefers recent edits when scores tie (recency nudge)', () => { test('rank prefers recent edits when scores tie (recency nudge)', async () => {
const now = Date.now(); const now = Date.now();
const filesWithMtime = [ const filesWithMtime = [
{ {
@@ -130,7 +130,52 @@ describe('DocQA.ask', () => {
mtimeMs: now - 60 * 24 * 60 * 60 * 1000, // 60 days ago mtimeMs: now - 60 * 24 * 60 * 60 * 1000, // 60 days ago
}, },
]; ];
const r = DocQA.ask({ question: 'rust overview', files: filesWithMtime, topK: 5 }); const r = await DocQA.ask({ question: 'rust overview', files: filesWithMtime, topK: 5 });
expect(r.chunks[0].filePath).toBe('/fresh.md'); expect(r.chunks[0].filePath).toBe('/fresh.md');
}); });
}); });
describe('DocQA.ask with a custom engine', () => {
const files = [
{ path: '/x.md', content: 'rust language is systems-level and safe.' },
{ path: '/y.md', content: 'unrelated content' },
];
test('passes the question + chunks to a custom engine.rank()', async () => {
const customEngine = {
isNeural: true,
rank: jest.fn().mockResolvedValue([
{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 },
]),
};
const r = await DocQA.ask({ question: 'rust', files, engine: customEngine });
expect(customEngine.rank).toHaveBeenCalled();
const args = customEngine.rank.mock.calls[0];
expect(args[0]).toMatch(/rust/);
expect(args[1].length).toBeGreaterThan(0);
expect(r.chunks[0].filePath).toBe('/x.md');
expect(r.chunks[0].offset).toBe(0);
});
test('falls back to default engine when none provided', async () => {
const r = await DocQA.ask({ question: 'rust', files });
// Default engine is tf-idf — chunks come back
expect(r.chunks.length).toBeGreaterThan(0);
});
test('translates neural-engine hits into the public chunks shape', async () => {
const engine = {
isNeural: true,
rank: jest.fn().mockResolvedValue([
{ filePath: '/x.md#42', snippet: 'rust snippet', score: 0.85, mtimeMs: 99 },
{ filePath: '/y.md#7', snippet: 'other', score: 0.1, mtimeMs: 1 },
]),
};
const r = await DocQA.ask({ question: 'rust', files, engine });
expect(r.chunks).toHaveLength(2);
expect(r.chunks[0].filePath).toBe('/x.md');
expect(r.chunks[0].offset).toBe(42);
expect(r.chunks[0].snippet).toBe('rust snippet');
expect(r.chunks[0].mtimeMs).toBe(99);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* @jest-environment node
*
* SemanticEngine tests — verify the engine interface contract and the
* fallback path. The neural engine itself isn't loaded in tests (no
* @xenova/transformers dep installed in CI); we just verify the
* graceful fallback behavior.
*/
const SemanticEngine = require('../../src/main/SemanticEngine');
const WorkspaceSearch = require('../../src/main/WorkspaceSearch');
describe('SemanticEngine.defaultEngine', () => {
test('returns an engine object with the right shape', () => {
const e = SemanticEngine.defaultEngine();
expect(e.name).toBe('tf-idf');
expect(e.isNeural).toBe(false);
expect(typeof e.rank).toBe('function');
});
test('rank() returns WorkspaceSearch-style results', async () => {
const e = SemanticEngine.defaultEngine();
const chunks = [
{ path: 'a.md', content: 'rust async with tokio', mtimeMs: 1 },
{ path: 'b.md', content: 'unrelated content', mtimeMs: 1 },
];
const r = await e.rank('rust async', chunks);
expect(r.length).toBeGreaterThan(0);
// First result should match the query
expect(r[0].filePath).toBe('a.md');
// Shape matches WorkspaceSearch
for (const hit of r) {
expect(hit).toHaveProperty('filePath');
expect(hit).toHaveProperty('score');
expect(hit).toHaveProperty('snippet');
}
});
test('rank() filters out non-matching chunks', async () => {
const e = SemanticEngine.defaultEngine();
const r = await e.rank('rust', [
{ path: 'x.md', content: 'totally unrelated prose about cats' },
{ path: 'y.md', content: 'rust rust rust everywhere' },
]);
expect(r.every((hit) => hit.filePath === 'y.md')).toBe(true);
});
});
describe('SemanticEngine.getEngine', () => {
test('returns the default engine for "tf-idf"', async () => {
const e = await SemanticEngine.getEngine('tf-idf');
expect(e.isNeural).toBe(false);
});
test('returns the default engine for an unknown name (graceful fallback)', async () => {
const e = await SemanticEngine.getEngine('not-a-real-engine');
expect(e.isNeural).toBe(false);
expect(e.name).toBe('tf-idf');
});
test('neural engine falls back to tf-idf when @xenova/transformers is missing', async () => {
// No install of @xenova/transformers in CI; the require() inside
// neuralEngine() should fail and fall back.
const e = await SemanticEngine.getEngine('transformers', { allowRemote: false });
expect(e.isNeural).toBe(false);
expect(e.name).toBe('tf-idf');
});
test('default engine is consistent across calls', () => {
const a = SemanticEngine.defaultEngine();
const b = SemanticEngine.defaultEngine();
expect(a).not.toBe(b);
expect(a.name).toBe(b.name);
});
});
describe('SemanticEngine — WorkspaceSearch parity', () => {
test('default engine returns the same shape WorkspaceSearch.search would', async () => {
const files = [
{ path: '/a.md', content: 'rust async programming', mtimeMs: 1 },
{ path: '/b.md', content: 'python web dev', mtimeMs: 2 },
];
const e = SemanticEngine.defaultEngine();
const engineResult = await e.rank('rust', files);
const directResult = WorkspaceSearch.search({ query: 'rust', files, limit: files.length });
expect(engineResult.map((r) => r.filePath)).toEqual(directResult.map((r) => r.filePath));
expect(engineResult[0].score).toBe(directResult[0].score);
});
});