noulchoiceClassification
Qualitative query
Classify blocks of text relevant to a query
suffername 30 downloads
Find source passages with saved questions and Jev scoring, then connect them with native block embeds.
Ask your vault a question. Connect the passages that answer it.
Qualitative Query finds answers in your existing notes. It shows original passages, without generating an answer.
- Ask. Save a question. Local search finds possible matches, and Jev checks which passages answer it.
- Read. Open each source or show nearby text. Results update as your notes change.
- Connect. Select Save passages to create a note with source-linked passages. Backlinks connect the source notes to your selection.
Source (15 files)
import { defineConfig } from 'eslint/config';
import obsidianmd from 'eslint-plugin-obsidianmd';
export default defineConfig([
{ ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tests/**', '*.mjs'] },
...obsidianmd.configs.recommended,
{
languageOptions: { parserOptions: { projectService: true } },
rules: {
"obsidianmd/ui/sentence-case": ["warn", {
brands: ["Jev", "TypeSafe", "Qualitative Query", "Obsidian"],
// Preserve the credential prefix and the established UI spelling for block IDs.
ignoreRegex: ["^ts_$", "^.*\\bIDs\\b.*$"],
}],
},
},
]);
/** Bundle for Obsidian's eval command. Reads only this plugin and the requested notes. */
import { parseQuery, resolveContext } from "../src/query";
import { TFile, type App } from "obsidian";
import type { QueryResult, QuerySpec } from "../src/types";
declare const app: App;
declare const require: (name:string)=>any;
(globalThis as any).captureQualitativePipeline = async (options:{queryPath:string;bakedPath?:string;output:string}) => {
const plugin=(app as any).plugins.plugins['qualitative-query'];
if(!plugin)throw Error('Enable Qualitative Query first.');
const file=app.vault.getAbstractFileByPath(options.queryPath);
if(!(file instanceof TFile))throw Error('Query note missing.');
const queryText=await app.vault.read(file);
if((queryText.match(/```(?:qq|qualitative-query)\b/g)||[]).length!==1)throw Error('Capture requires one query block.');
const spec=parseQuery(file.path,queryText,plugin.settings.queryFolder);
spec.contextPaths=[...resolveContext(app,file,spec.contextPaths)];
await plugin.index.whenReady();
const startedAt=new Date().toISOString();
const candidates=await plugin.retrieve(spec.question,plugin.engine.getBlocks(),plugin.settings.candidateLimit);
// Scoring criteria stay unchanged. Capture every score, not just the display slice.
const result:QueryResult=await plugin.engine.run({...spec,limit:1000},plugin.settings.candidateLimit,1000,0);
if(result.selection!=='jev')throw Error('Jev scoring did not complete; no successful capture recorded.');
const threshold=spec.threshold??plugin.settings.threshold;
const selected=result.judgements.filter(j=>j.score>=threshold).slice(0,spec.limit??plugin.settings.resultLimit);
let baked:any=null;
if(options.bakedPath){
const bakedFile=app.vault.getAbstractFileByPath(options.bakedPath);
if(!(bakedFile instanceof TFile))throw Error('Baked note missing.');
const text=await app.vault.read(bakedFile);
const refs=[];
for(const match of text.matchAll(/!\[\[([^\]#]+)#\^([^\]|]+)(?:\|[^\]]+)?\]\]/g)){
const source=app.metadataCache.getFirstLinkpathDest(match[1],bakedFile.path);
const block=source&&app.metadataCache.getFileCache(source)?.blocks?.[match[2]];
const sourceText=source?await app.vault.read(source):'';
refs.push({link:match[0],path:source?.path,id:match[2],resolved:!!block,
position:block?.position,text:block?sourceText.slice(block.position.start.offset,block.position.end.offset):null,
surrounding:block?sourceText.split('\n').slice(Math.max(0,block.position.start.line-3),block.position.end.line+4).join('\n'):null});
}
baked={path:bakedFile.path,text,refs};
}
const data={startedAt,finishedAt:new Date().toISOString(),vault:app.vault.getName(),pluginVersion:plugin.manifest.version,
model:plugin.settings.model,queryPath:file.path,queryText,spec,candidateLimit:plugin.settings.candidateLimit,
corpusBlocks:plugin.engine.getBlocks().length,embeddingWarning:plugin.embeddingWarning,
candidates:candidates.map((c:any,i:number)=>({id:c.id,rank:i+1,path:c.path,text:c.text,retrievalScore:c.retrievalScore})),
evaluatedCandidates:result.candidates.length,warning:result.warning,judgements:result.judgements,selected,baked};
require('node:fs').writeFileSync(options.output,JSON.stringify(data,null,2));
return {output:options.output,scored:result.judgements.length,selected:selected.length};
};
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
class Element {
constructor() { this.children = []; this.text = ''; this.isConnected = true; this.classes = new Set(); }
empty() { this.children = []; this.text = ''; }
addClass(...names) { names.forEach(n => this.classes.add(n)); }
removeClass(...names) { names.forEach(n => this.classes.delete(n)); }
createEl(tag, options = {}) { const child = new Element(); child.tag = tag; child.text = options.text || ''; if(options.cls) child.addClass(options.cls); this.children.push(child); return child; }
createDiv(options = {}) { return this.createEl('div', typeof options === 'string' ? {cls:options} : options); }
createSpan(options) { return this.createEl('span', options); }
setText(text) { this.text = text; }
setAttribute() {}
addEventListener() {}
prepend(child) { this.children.unshift(child); }
set textContent(text) { this.text = text; }
appendChild(child) { this.children.push(child); return child; }
remove() { this.isConnected = false; }
closest() { return null; }
querySelector() { return null; }
querySelectorAll() { return []; }
get textContent() { return this.text + this.children.map(c => c.textContent).join(' '); }
}
class Component {
constructor() { this.children = []; this.cleanups = []; }
addChild(child) { this.children.push(child); child.onload?.(); return child; }
removeChild(child) { child.unload(); this.children = this.children.filter(c=>c!==child); }
register(fn) { this.cleanups.push(fn); }
registerDomEvent() {}
load() { this.onload?.(); }
unload() { this.onunload?.(); this.children.forEach(c=>c.unload()); this.cleanups.forEach(f=>f()); }
}
class MarkdownRenderChild extends Component { constructor(el) { super(); this.containerEl=el; } }
class TFile { constructor(path) { this.path=path; this.extension='md'; this.basename=path.split('/').pop().replace(/\.md$/,''); this.stat={mtime:1}; } }
const source = new TFile('Sources/Conflict.md');
const query = new TFile('Queries/Conflict.md');
const contents = new Map([[source.path,'# Conflict\n\nA conflict is a clash of incompatible goals.'],[query.path,'# Conflict\n\n```qualitative-query\nWhat defines a conflict?\n```']]);
const callbacks = new Map();
const app = {
secretStorage:{getSecret(){return '';},setSecret(){}},
vault:{configDir:".custom-config",getMarkdownFiles(){return [source,query];},cachedRead:async f=>contents.get(f.path),read:async f=>contents.get(f.path),getAbstractFileByPath:p=>[source,query].find(f=>f.path===p),on:(event,fn)=>{callbacks.set(event,fn);return {};},offref(){}},
metadataCache:{getFirstLinkpathDest:()=>null,on:()=>({}),offref(){}},
workspace:{iterateAllLeaves(){},on:()=>({}),offref(){},onLayoutReady(fn){fn();},openLinkText:async()=>{},getLeavesOfType:()=>[]}
};
class Plugin extends Component {
constructor(){super();this.app=app;this.processors={};this.postprocessors=[];}
async loadData(){return {candidateLimit:1000,provider:'embedded',model:'kev-0.5b',endpoint:'http://127.0.0.1:8009',embeddedModelDir:'runtime/kev'};}
async saveData(){}
registerMarkdownCodeBlockProcessor(name,fn){this.processors[name]=fn;}
registerMarkdownPostProcessor(fn){this.postprocessors.push(fn);}
registerEvent(){}
registerInterval(id){this.register(()=>clearInterval(id));}
addSettingTab(){}
addCommand(){}
addRibbonIcon(){}
}
class MarkdownView {}
const requests=[];
let allowRequests=false;
const obsidian={Modal:class{},Plugin,Component,MarkdownRenderChild,TFile,MarkdownView,PluginSettingTab:class{},Setting:class{},Notice:class{},normalizePath:p=>p.replace(/\\/g,'/'),MarkdownRenderer:{render:async(app,text,el)=>{el.createDiv({text});}},requestUrl:async(options)=>{
assert.ok(allowRequests,'Unexpected network call without credentials');
requests.push(options);
const body=JSON.parse(options.body); const answers={};
for(const [id,q] of Object.entries(body.questions)) answers[id]=q.type==='choice'?{type:'choice',choice:'definition',probabilities:{definition:1,condition:0,distinction:0,other:0},confidence:1}:{type:'noul',noul:.9};
return {status:200,json:{model:body.model,answers,usage:{input_tokens:20,output_tokens:0}},headers:{}};
},parseYaml:()=>({})};
const moduleObject={exports:{}};
vm.runInNewContext(fs.readFileSync(process.argv[2],'utf8'),{module:moduleObject,exports:moduleObject.exports,require:(name)=>{assert.equal(name,'obsidian');return obsidian;},console,setTimeout,clearTimeout,setInterval,clearInterval,window:{setTimeout,clearTimeout,setInterval,clearInterval},createEl:()=>new Element(),createDiv:()=>new Element(),document:{createElement:()=>{throw new Error("Use Obsidian createEl helpers");}},URL,AbortController,TextEncoder,performance,crypto:require('node:crypto').webcrypto});
(async()=>{
const plugin=new moduleObject.exports.default();
await plugin.onload();
assert.equal(plugin.settings.candidateLimit,1000);
const el=new Element(); const children=[];
const ctx={sourcePath:query.path,docId:'smoke',addChild:c=>{children.push(c);c.load();},getSectionInfo:()=>({lineStart:0,lineEnd:0,text:contents.get(query.path)})};
await plugin.processors['qualitative-query']('What defines a conflict?',el,ctx);
await new Promise(r=>setTimeout(r,60));
assert.match(el.textContent,/key|provider|configure/i);
assert.equal(requests.length,0);
// A store install has no sibling worker file. Exercise the worker carried in main.js.
const workerSource = await plugin.retrieval.semantic.assets.readWorker();
assert.ok(workerSource.length > 1000);
const responses = [];
const workerContext = vm.createContext({ArrayBuffer,TextEncoder,TextDecoder,postMessage:message=>responses.push(message)});
workerContext.self = workerContext;
vm.runInContext(workerSource, workerContext);
const weights = fs.readFileSync('models/embeddings/model.safetensors');
workerContext.onmessage({data:{id:1,type:'load',weights:weights.buffer.slice(weights.byteOffset,weights.byteOffset+weights.byteLength),tokenizer:JSON.parse(fs.readFileSync('models/embeddings/tokenizer.json','utf8')),config:JSON.parse(fs.readFileSync('models/embeddings/tokenizer_config.json','utf8'))}});
workerContext.onmessage({data:{id:2,type:'update',rows:[{id:'conflict',text:'Mediation resolves disputes.'},{id:'cake',text:'A chocolate cake recipe.'}]}});
workerContext.onmessage({data:{id:3,type:'search',question:'resolving interpersonal conflict',limit:2}});
assert.ok(responses.every(message=>!message.error),JSON.stringify(responses));
assert.equal(responses.at(-1).result[0].id,'conflict');
assert.match(el.textContent,/incompatible goals/);
assert.match(el.textContent,/Local match/);
allowRequests=true;
plugin.settings.apiKey='synthetic-smoke-key';
plugin.rebuildClient();
const connectedEl=new Element();
await plugin.processors['qualitative-query']('What defines a conflict?',connectedEl,ctx);
await new Promise(r=>setTimeout(r,100));
assert.match(connectedEl.textContent,/incompatible goals/);
assert.ok(requests.length>0);
assert.equal(requests.at(-1).url,'https://api.typesafe.ai/v1/systemone');
contents.set(source.path,'# Conflict\n\nA conflict is a revised definition of incompatible goals.');
source.stat.mtime++;
callbacks.get('modify')(source);
await new Promise(r=>setTimeout(r,700));
assert.match(connectedEl.textContent,/revised definition/);
assert.equal(plugin.settings.provider,undefined);
assert.equal(plugin.settings.endpoint,undefined);
assert.equal(plugin.settings.embeddedModelDir,undefined);
assert.equal(plugin.settings.model,'jev-1.13.0');
assert.ok(requests.every(request=>request.url==='https://api.typesafe.ai/v1/systemone'));
// A modify timer must not reintroduce a source renamed to a non-Markdown file.
callbacks.get('modify')(source);
const previousPath = source.path;
source.path = 'Sources/Conflict.txt'; source.extension = 'txt';
callbacks.get('rename')(source, previousPath);
await new Promise(r=>setTimeout(r,700));
assert.equal(plugin.index.blocks.length,0);
assert.doesNotMatch(connectedEl.textContent,/revised definition/);
assert.equal(plugin.indexError,'');
plugin.unload(); children.forEach(c=>c.unload());
console.log('Bundled plugin smoke passed: startup, no-key guard, hosted rendering, live source refresh, legacy-settings migration, Jev-only transport, unload. Embedded worker executes from main.js with real model weights. All transport mocked.');
})().catch(error=>{console.error(error);process.exitCode=1;});
import { Modal, Notice, Setting, type App } from "obsidian";
import { createUniqueNote } from "./notes";
export const PRESETS: Record<string,{name:string;instructions:string;yes:string;no:string}> = {
relevant:{name:"Relevant passages",instructions:"Select passages that directly answer the question with substantive content.",yes:"The passage contributes an answer, explanation, or specific example.",no:"It only mentions the topic or is unrelated."},
definitions:{name:"Definitions",instructions:"Select passages that define the subject or explain its boundaries.",yes:"It states what the subject means or a condition that distinguishes it.",no:"It merely mentions the subject or gives an example without explaining its meaning."},
distinctions:{name:"Distinctions",instructions:"Select passages that explain the distinction asked about.",yes:"It directly contrasts the concepts or gives an example demonstrating the difference.",no:"It discusses only one concept without explaining the distinction."},
practices:{name:"Concrete practices",instructions:"Select passages describing an actionable practice relevant to the question.",yes:"It describes what someone does and why or what changes as a result.",no:"It only praises an ideal or names a practice without describing it."},
evidence:{name:"Evidence",instructions:"Select passages providing specific evidence relevant to the question.",yes:"It describes an observation, study, event, or concrete example supporting a claim.",no:"It only asserts a claim, uses a metaphor, or mentions evidence without describing it."},
};
export function queryMarkdown(question:string,preset:string,include:string,exclude:string):string {
const p=PRESETS[preset];
if(!p || !question.trim()) throw new Error("Enter a question and choose what to look for.");
const line=(s:string)=>s.replace(/[\r\n]+/g," ").replace(/`/g,"'").trim();
// Fixed templates; the builder does not generate prose or infer hidden context.
return `# ${line(question)}\n\n\`\`\`qualitative-query\nquestion: ${line(question)}\nmode: generic\ninstructions: ${p.instructions}\ntrue: ${line(include)||p.yes}\nfalse: ${line(exclude)||p.no}\nlimit: 6\nthreshold: 0.5\nadjacent: 0\n\`\`\`\n`;
}
export class QueryBuilder extends Modal {
constructor(app:App,private readonly folder:string){super(app);}
onOpen():void {
const el=this.contentEl;el.empty();el.createEl("h2",{text:"Ask your vault"});
el.createEl("p",{text:"Find passages that answer your question. Save them in a note to connect their sources."});
let question="",preset="relevant",include="",exclude="";
new Setting(el).setName("Question").addTextArea(t=>t.setPlaceholder("How does solitude differ from loneliness?").onChange(v=>question=v));
new Setting(el).setName("Look for").addDropdown(d=>{for(const [id,p] of Object.entries(PRESETS))d.addOption(id,p.name);d.onChange(v=>{preset=v;defaults.setText(`${PRESETS[v].yes} Excludes: ${PRESETS[v].no}`);});});
const defaults=el.createEl("p",{text:`${PRESETS.relevant.yes} Excludes: ${PRESETS.relevant.no}`});
const details=el.createEl("details");details.createEl("summary",{text:"Refine matches (optional)"});
new Setting(details).setName("Include when").setDesc("What must a passage explain or show? Leave blank to use the default.").addTextArea(t=>t.onChange(v=>include=v));
new Setting(details).setName("Exclude when").setDesc("What should be left out? Leave blank to use the default.").addTextArea(t=>t.onChange(v=>exclude=v));
el.createEl("p",{text:"Saves your question and finds up to 6 passages. Results update as your notes change. With a Jev key, scoring sends passages to TypeSafe."});
new Setting(el).addButton(b=>b.setButtonText("Save question and search").setCta().onClick(async()=>{
b.setDisabled(true);
try {
const text=queryMarkdown(question,preset,include,exclude);
const file=await createUniqueNote(this.app,this.folder,question,text);
this.close();await this.app.workspace.getLeaf(false).openFile(file,{state:{mode:"preview"}});
}catch(error){new Notice(error instanceof Error?error.message:String(error));b.setDisabled(false);}
}));
}
onClose():void{this.contentEl.empty();}
}
import { requestUrl, type DataAdapter } from "obsidian";
export const EMBEDDING_REVISION = "bf8b056651a2c21b8d2565580b8569da283cab23";
const FILES = {
"model.safetensors": {
"bytes": 30236760,
"sha256": "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2"
},
"tokenizer.json": {
"bytes": 683666,
"sha256": "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50"
},
"tokenizer_config.json": {
"bytes": 1431,
"sha256": "6725995e3ab3039857ff5bd99178a7cdf42863abb04449e7bb31feb1f55fe567"
}
} as const;
/** Explicit download only. Requests contain no vault text or Jev credentials. */
export async function downloadEmbeddings(adapter: DataAdapter, directory: string): Promise<void> {
if (!await adapter.exists(`${directory}/embeddings`)) await adapter.mkdir(`${directory}/embeddings`);
for (const [name, expected] of Object.entries(FILES)) {
const response = await requestUrl({ url: `https://huggingface.co/minishlab/potion-base-8M/resolve/${EMBEDDING_REVISION}/${name}`, method: "GET", throw: false });
if (response.status !== 200 || response.arrayBuffer.byteLength !== expected.bytes) throw new Error(`Embedding download failed: ${name}.`);
const digest = await crypto.subtle.digest("SHA-256", response.arrayBuffer);
const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
if (hash !== expected.sha256) throw new Error(`Embedding download checksum failed: ${name}.`);
const target = `${directory}/embeddings/${name}`;
await adapter.writeBinary(`${target}.download`, response.arrayBuffer);
await adapter.rename(`${target}.download`, target);
}
}
import { checkSignal, SharedWork } from "./work";
import type { RetrievalResult } from "./retrieval";
import type { ScoreCache } from "./score-cache";
import { MAX_CANDIDATES } from "./limits";
import { shortlist } from "./search";
import { JevClient } from "./jev";
import type { Block, Candidate, Contribution, Judgement, QueryResult, QuerySpec, QueryStats } from "./types";
const MAX_PASSAGE_CHARS = 24_000;
const MAX_CONTEXT_CHARS = 48_000;
const MAX_CACHE_ENTRIES = 512;
const MAX_CONCURRENCY = 16;
type ContextReader = (path: string) => Block[] | Promise<Block[]>;
type WarningResult = QueryResult & { warning?: string };
class QueryRunSuperseded extends Error {
constructor() { super("This query was superseded by a newer query."); }
}
interface QueueItem {
generation: number;
current: () => boolean;
resolve: (release: () => void) => void;
reject: (error: unknown) => void;
}
class ConcurrencyGate {
private running = 0;
private limit = MAX_CONCURRENCY;
private lastThrottle = -Infinity;
private successes = 0;
throttle(): void {
const now = Date.now();
if (now - this.lastThrottle >= 1000) this.limit = Math.max(1, Math.floor(this.limit / 2));
this.lastThrottle = now; this.successes = 0;
}
success(): void {
if (Date.now() - this.lastThrottle >= 5000 && ++this.successes >= 32) {
this.limit = Math.min(MAX_CONCURRENCY, this.limit + 1); this.successes = 0;
this.pump();
}
}
private queue: QueueItem[] = [];
acquire(generation: number, current: () => boolean): Promise<() => void> {
if (!current()) return Promise.reject(new QueryRunSuperseded());
return new Promise((resolve, reject) => {
this.queue.push({ generation, current, resolve, reject });
this.pump();
});
}
cancelStale(generation: number): void {
const retained: QueueItem[] = [];
for (const item of this.queue) {
if (item.generation < generation || !item.current()) item.reject(new QueryRunSuperseded());
else retained.push(item);
}
this.queue = retained;
}
private pump(): void {
while (this.running < this.limit && this.queue.length) {
const item = this.queue.shift()!;
if (!item.current()) { item.reject(new QueryRunSuperseded()); continue; }
this.running++;
let released = false;
item.resolve(() => {
if (released) return;
released = true;
this.running--;
this.pump();
});
}
}
}
function stable(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
return `{${Object.keys(value as Record<string, unknown>).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record<string, unknown>)[key])}`).join(",")}}`;
}
function span(block: Block): number { return Math.max(0, block.lineEnd - block.lineStart); }
/** Remove parent/child duplicates while retaining the most complete passage. */
function dedupeRanges<T extends Block>(blocks: T[]): T[] {
const selected: T[] = [];
const ordered = [...blocks].sort((a, b) => a.path.localeCompare(b.path) || a.lineStart - b.lineStart || a.lineEnd - b.lineEnd);
for (const block of ordered) {
// Source-order sorting means only the last selected range can overlap.
const existing = selected[selected.length - 1];
if (!existing || existing.path !== block.path || existing.lineEnd < block.lineStart) { selected.push(block); continue; }
if (span(block) > span(existing)) selected[selected.length - 1] = block;
}
return selected.sort((a, b) => a.path.localeCompare(b.path) || a.lineStart - b.lineStart || a.id.localeCompare(b.id));
}
function dedupeCandidates(candidates: Candidate[]): Candidate[] {
const selected: Candidate[] = [];
const ranges = new Map<string, Candidate[]>();
// Score first so a broad ancestor cannot hide a better focused child.
const ranked = [...candidates].sort((a, b) => (b.retrievalScore ?? b.lexicalScore) - (a.retrievalScore ?? a.lexicalScore) || span(a) - span(b) || a.id.localeCompare(b.id));
for (const candidate of ranked) {
const file = ranges.get(candidate.path) ?? [];
let low = 0, high = file.length;
while (low < high) { const mid = (low + high) >>> 1; if (file[mid].lineStart < candidate.lineStart) low = mid + 1; else high = mid; }
if ((low > 0 && file[low - 1].lineEnd >= candidate.lineStart) || (low < file.length && file[low].lineStart <= candidate.lineEnd)) continue;
file.splice(low, 0, candidate);
ranges.set(candidate.path, file);
selected.push(candidate);
}
return selected;
}
function displayText(block: Block): string { return block.renderText ?? block.text; }
function modelText(block: Block): string {
const heading = block.headingPath.length ? `[Headings: ${block.headingPath.join(" > ")}]\n` : "";
return `${heading}${displayText(block)}`;
}
export class QueryEngine {
private cache = new Map<string, Judgement>();
private inFlight = new Map<string, SharedWork<Judgement>>();
private runs = new Set<AbortController>();
private generation = 0;
private disposed = false;
private readonly gate = new ConcurrencyGate();
constructor(
private readonly client: Pick<JevClient, "rank"> | null,
private readonly getBlocks: () => Block[],
private readonly getSourceBlocks: ContextReader = () => [],
/** Include provider/model identity when an engine is reused across clients. */
private readonly cacheNamespace = "",
private readonly persistentCache?: Pick<ScoreCache, "get" | "set"> & Partial<Pick<ScoreCache, "getMany">>,
private readonly retrieve: (question: string, blocks: Block[], limit: number, signal?: AbortSignal) => RetrievalResult | Promise<RetrievalResult> = (question, blocks, limit) => ({ candidates: shortlist(question, blocks, limit), truncated: shortlist(question, blocks, limit + 1).length > limit }),
) {}
clearCache(): void { this.cache.clear(); }
invalidate(): void {
this.generation++;
for (const run of this.runs) run.abort();
this.gate.cancelStale(this.generation);
}
dispose(): void { this.disposed = true; this.invalidate(); this.cache.clear(); this.inFlight.clear(); }
async run(spec: QuerySpec, candidateLimit: number, limit: number, threshold: number, onProgress?: (completed: number, total: number) => void, onPartial?: (result: QueryResult) => void, signal?: AbortSignal): Promise<QueryResult> {
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
this.runs.add(controller);
try { return await this.runOwned(spec, candidateLimit, limit, threshold, onProgress, onPartial, controller.signal); }
finally { controller.abort(); this.runs.delete(controller); signal?.removeEventListener("abort", abort); }
}
private async runOwned(spec: QuerySpec, candidateLimit: number, limit: number, threshold: number, onProgress: ((completed: number, total: number) => void) | undefined, onPartial: ((result: QueryResult) => void) | undefined, signal: AbortSignal): Promise<QueryResult> {
checkSignal(signal);
if (this.disposed) return { status: "error", candidates: [], judgements: [], error: "The query engine is unavailable." };
const generation = this.generation;
const corpus = this.getBlocks();
let context: string;
try { context = await this.readContext(spec.contextPaths); }
catch (error) { return { status: "error", candidates: [], judgements: [], error: error instanceof Error ? error.message : String(error) }; }
this.assertCurrent(generation, signal);
if (context.length > MAX_CONTEXT_CHARS) return { status: "error", candidates: [], judgements: [], error: `Selected context is ${context.length.toLocaleString()} characters; the limit is ${MAX_CONTEXT_CHARS.toLocaleString()}. Choose specific blocks or sections, or remove context notes.` };
const retrieval = await this.retrieve(spec.question, corpus, Math.min(MAX_CANDIDATES, Math.max(1, candidateLimit)), signal);
const candidates = dedupeCandidates(retrieval.candidates);
const stats: QueryStats = { searchable: corpus.length, shortlisted: retrieval.candidates.length,
overlapRemoved: retrieval.candidates.length - candidates.length, windowLimit: Math.min(MAX_CANDIDATES, Math.max(1, candidateLimit)),
truncated: retrieval.truncated ?? false, checked: 0, cached: 0, shared: 0, requested: 0, retries: 0,
skipped: 0, passed: 0, contextChars: context.length, threshold };
const warning = (message?: string) => [retrieval.warning, message].filter(Boolean).join(" ") || undefined;
this.assertCurrent(generation, signal);
if (!candidates.length) return { status: "empty", candidates, judgements: [], stats, selection: this.client ? "jev" : "local", warning: warning() };
if (!this.client) {
const ranked = [...candidates].sort((a, b) => (b.retrievalScore ?? b.lexicalScore) - (a.retrievalScore ?? a.lexicalScore) || a.id.localeCompare(b.id));
return { status: "ready", selection: "local", candidates, stats,
warning: warning("Local matches. Add a Jev API key in settings to select and order passages by how well they answer your question."),
judgements: ranked.slice(0, spec.limit ?? limit).map(candidate => ({ candidate, score: candidate.retrievalScore ?? candidate.lexicalScore, contribution: "other", scores: {} })),
};
}
const judgements: Judgement[] = [];
const belowThreshold: Judgement[] = [];
const accept = (value: Judgement) => { stats.checked++; if (value.score >= threshold) { judgements.push(value); stats.passed++; } else belowThreshold.push(value); };
const rejected = () => [...belowThreshold].sort((a, b) => b.score - a.score || a.candidate.id.localeCompare(b.candidate.id));
const skipped = candidates.filter((candidate) => displayText(candidate).length > MAX_PASSAGE_CHARS);
stats.skipped = skipped.length;
const judgeable = candidates.filter((candidate) => displayText(candidate).length <= MAX_PASSAGE_CHARS);
const order: Record<Contribution, number> = { definition: 0, condition: 1, distinction: 2, other: 3 };
const selection = () => [...judgements].sort((a, b) => spec.criteria.mode === "default"
? order[a.contribution] - order[b.contribution] || b.score - a.score || a.candidate.id.localeCompare(b.candidate.id)
: b.score - a.score || a.candidate.id.localeCompare(b.candidate.id)).slice(0, spec.limit ?? limit);
let completed = 0;
onProgress?.(completed, judgeable.length);
const keys = judgeable.map(candidate => this.scoreKey(spec, candidate, context));
const persisted = await this.readScores(keys);
this.assertCurrent(generation, signal);
// Preserve existing location-based entries; promote them lazily without rescoring.
const missing = judgeable.filter((_, i) => !persisted.has(keys[i]) && !this.cache.has(keys[i]));
const legacy = await this.readScores(missing.map(candidate => this.scoreKey(spec, candidate, context, true)));
this.assertCurrent(generation, signal);
for (const candidate of missing) {
const value = legacy.get(this.scoreKey(spec, candidate, context, true));
if (value) {
const key = this.scoreKey(spec, candidate, context);
persisted.set(key, value);
await this.persistentCache?.set(key, value);
}
}
this.assertCurrent(generation, signal);
let lastPartial = -Infinity;
const publish = () => {
if (Date.now() - lastPartial >= 250 || completed === judgeable.length) {
lastPartial = Date.now();
onPartial?.({ status: "ready", selection: "jev", candidates, judgements: selection(), belowThreshold: rejected(), stats: { ...stats }, warning: warning("Still checking passages. Results may change.") });
}
};
const pending = judgeable.filter(candidate => {
const key = this.scoreKey(spec, candidate, context);
const value = this.cache.get(key) ?? persisted.get(key);
if (!value) return true;
stats.cached++; accept({ ...value, candidate });
completed++; return false;
});
if (completed) { onProgress?.(completed, judgeable.length); publish(); }
await Promise.all(pending.map(async (candidate) => {
try {
const value = await this.judge(spec, candidate, context, generation, stats, signal);
this.assertCurrent(generation, signal);
accept(value);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "INPUT_TOO_LARGE") { skipped.push(candidate); stats.skipped++; }
else throw error;
}
this.assertCurrent(generation, signal);
onProgress?.(++completed, judgeable.length);
publish();
}));
this.assertCurrent(generation, signal);
if (!judgements.length) {
const result: WarningResult = { status: "empty", candidates, selection: "jev", judgements: [], belowThreshold: rejected(), stats: { ...stats }, error: skipped.length ? "No checked passages met the minimum score. Broaden the question or lower its threshold." : "No passages met the minimum score. Broaden the question or lower its threshold." };
if (skipped.length) result.warning = `${skipped.length} passage${skipped.length === 1 ? " was" : "s were"} skipped because they exceed the model input limit.`;
result.warning = warning(result.warning);
return result;
}
const result: WarningResult = { status: "ready", selection: "jev", candidates, judgements: selection(), belowThreshold: rejected(), stats: { ...stats } };
if (skipped.length) result.warning = `${skipped.length} passage${skipped.length === 1 ? " was" : "s were"} skipped because they exceed the model input limit.`;
result.warning = warning(result.warning);
return result;
}
private async readContext(paths: string[]): Promise<string> {
const blocks: Block[] = [];
for (const path of paths) {
const pathBlocks = (await this.getSourceBlocks(path)).filter((block) => block.path === path.split("#")[0]);
for (const block of dedupeRanges(pathBlocks)) blocks.push(block);
}
return blocks.map(modelText).join("\n\n");
}
private scoreKey(spec: QuerySpec, candidate: Candidate, context: string, legacy = false): string {
const text = modelText(candidate);
return stable({ namespace: this.cacheNamespace, question: spec.question, criteria: spec.criteria, context,
...(legacy ? { candidate: { id: candidate.id, path: candidate.path, lineStart: candidate.lineStart, lineEnd: candidate.lineEnd, headingPath: candidate.headingPath, text } }
: { passage: text, keyVersion: 2 }) });
}
private async readScores(keys: string[]): Promise<Map<string, Omit<Judgement, "candidate">>> {
if (!this.persistentCache || !keys.length) return new Map();
if (this.persistentCache.getMany) return this.persistentCache.getMany(keys);
const values = new Map<string, Omit<Judgement, "candidate">>();
await Promise.all(keys.map(async key => { const value = await this.persistentCache!.get(key); if (value) values.set(key, value); }));
return values;
}
private async judge(spec: QuerySpec, candidate: Candidate, context: string, generation: number, stats: QueryStats, signal: AbortSignal): Promise<Judgement> {
this.assertCurrent(generation, signal);
const passage = modelText(candidate);
const key = this.scoreKey(spec, candidate, context);
const cached = this.cache.get(key);
if (cached) {
stats.cached++;
this.cache.delete(key); this.cache.set(key, cached);
return { ...cached, candidate };
}
const existing = this.inFlight.get(key);
if (existing) { stats.shared++; return { ...(await existing.join(signal)), candidate }; }
const request = new SharedWork<Judgement>(async sharedSignal => {
const prune = () => this.gate.cancelStale(this.generation);
sharedSignal.addEventListener("abort", prune, { once: true });
let release: (() => void) | undefined;
try {
release = await this.gate.acquire(generation, () => !this.disposed && generation === this.generation && !sharedSignal.aborted);
this.assertCurrent(generation, sharedSignal);
stats.requested++;
const response = await this.client!.rank(spec.question, passage, spec.criteria, context, () => this.gate.throttle(), () => { stats.retries++; }, sharedSignal);
this.gate.success();
release();
this.assertCurrent(generation, sharedSignal);
const judgement = this.validateResponse(response, spec, candidate);
this.cache.set(key, judgement);
if (this.persistentCache) {
const { candidate: _candidate, ...score } = judgement;
await this.persistentCache.set(key, score);
this.assertCurrent(generation, sharedSignal);
}
while (this.cache.size > MAX_CACHE_ENTRIES) this.cache.delete(this.cache.keys().next().value!);
return judgement;
} finally { release?.(); sharedSignal.removeEventListener("abort", prune); }
}, () => { if (this.inFlight.get(key) === request) this.inFlight.delete(key); });
this.inFlight.set(key, request);
return { ...(await request.join(signal)), candidate };
}
private validateResponse(response: unknown, spec: QuerySpec, candidate: Candidate): Judgement {
if (!response || typeof response !== "object" || !("answers" in response) || !response.answers || typeof response.answers !== "object") throw new Error("The decision model returned an invalid response: answers are missing.");
const answers = response.answers as Record<string, { noul?: unknown; choice?: unknown }>;
const scores: Record<string, number> = {};
const score = (id: string): number => {
const value = answers[id]?.noul;
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) throw new Error(`The decision model returned an invalid score for “${id}”.`);
scores[id] = value;
return value;
};
let resultScore: number;
let contribution: Contribution = "other";
if (spec.criteria.mode === "generic") resultScore = score("relevant");
else {
const values = [score("definition"), score("condition"), score("distinction")];
const choice = answers.contribution?.choice;
if (choice !== "definition" && choice !== "condition" && choice !== "distinction" && choice !== "other") throw new Error("The decision model returned an invalid contribution choice.");
contribution = choice;
resultScore = Math.max(...values);
}
return { candidate, score: resultScore, contribution, scores };
}
private assertCurrent(generation: number, signal?: AbortSignal): void {
checkSignal(signal);
if (this.disposed || generation !== this.generation) throw new QueryRunSuperseded();
}
}
import { checkSignal } from "./work";
import { requestUrl } from "obsidian";
import type { JevResponse, QueryCriteria } from "./types";
export interface JevTransport { post(url: string, headers: Record<string, string>, body: unknown, signal?: AbortSignal): Promise<{ status: number; json: unknown }> }
const defaultTransport: JevTransport = { post: async (url, headers, body) => { const response = await requestUrl({ url, method: "POST", headers, body: JSON.stringify(body), throw: false }); return { status: response.status, json: response.json }; } };
const ENDPOINT = "https://api.typesafe.ai/v1/systemone";
export class JevClient {
constructor(private readonly apiKey: string, private readonly model: string, private readonly transport: JevTransport = defaultTransport, private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms))) {}
async rank(question: string, candidate: string, criteria: QueryCriteria, context = "", onThrottle?: () => void, onRetry?: () => void, signal?: AbortSignal): Promise<JevResponse> {
checkSignal(signal);
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (this.apiKey.trim()) headers.Authorization = `Bearer ${this.apiKey}`;
else throw new Error("TypeSafe API key is not configured.");
const questions: Record<string, unknown> = {};
if (criteria.mode === "generic") {
questions.relevant = { type: "noul", instructions: criteria.instructions ?? `Does this passage answer the question: “${question}”?`, criteria: { true: criteria.true ?? "The passage directly addresses the question with substantive content.", false: criteria.false ?? "The passage only mentions the topic or is unrelated." } };
} else {
questions.definition = { type: "noul", instructions: `For the question “${question}”, does this passage directly define the subject?`, criteria: { true: "It states what the subject is or means, rather than merely mentioning it.", false: "It mentions, illustrates, or uses the subject without defining it." } };
questions.condition = { type: "noul", instructions: `For the question “${question}”, does this passage state a condition that makes the subject apply?`, criteria: { true: "It states a qualifying condition, test, boundary, or necessary feature.", false: "It does not give a condition or boundary for the subject." } };
questions.distinction = { type: "noul", instructions: `For the question “${question}”, does this passage distinguish the subject from a related concept?`, criteria: { true: "It contrasts, separates, or clarifies the subject against another concept.", false: "It does not distinguish the subject from a related concept." } };
questions.contribution = { type: "choice", instructions: `Which contribution does this passage make toward answering “${question}”?`, criteria: { definition: "Directly defines the subject.", condition: "States qualifying conditions, tests, or boundaries.", distinction: "Distinguishes the subject from related concepts.", other: "None of those; it is another kind of relevant contribution." } };
}
const body = { model: this.model, state: { question, candidate_passage: candidate, context_notes: context }, questions };
let attempt = 0;
while (true) {
checkSignal(signal);
const response = await this.transport.post(ENDPOINT, headers, body, signal);
checkSignal(signal);
if (response.status >= 200 && response.status < 300) return response.json as JevResponse;
if (response.status === 429 || response.status === 529) onThrottle?.();
if ((response.status === 429 || response.status === 529) && attempt < 3) { onRetry?.(); await this.sleep(250 * 2 ** attempt); attempt++; continue; }
if (response.status === 401 || response.status === 403) throw new Error(`Jev authentication failed (${response.status}). Check the TypeSafe API key.`);
if (response.status === 422) throw new Error("Jev rejected the request (422). Check the model and typed question payload.");
if (response.status === 429) throw new Error("Jev rate limit persisted after retries (429).");
if (response.status === 529) throw new Error("Jev service remained unavailable after retries (529).");
throw new Error(`Jev request failed (${response.status}).`);
}
}
}
import { applyInclusions, inclusionFor, updateInclusions } from "./manual-selection";
import { readContextBlocks } from "./context";
import { parseObject } from "./validation";
declare const EMBEDDING_WORKER_SOURCE: string;
import { QueryBuilder } from "./builder";
import { bakeNote, BAKED_FOLDER, PendingSave } from "./bake";
import { Notice, Component, MarkdownRenderChild, Plugin, TFile, type MarkdownPostProcessorContext, type TAbstractFile } from "obsidian";
import { EmbeddingIndex } from "./embeddings";
import { downloadEmbeddings } from "./embedding-assets";
import { LocalRetrieval } from "./retrieval";
import { ScoreCache } from "./score-cache";
import { VaultIndex } from "./indexer";
import { parseQuery, resolveContext } from "./query";
import { JevClient } from "./jev";
import { QueryEngine } from "./engine";
import { renderLoading, renderResult } from "./render";
import { expandBlock } from "./markdown";
import { DEFAULT_SETTINGS, TYPESAFE_ENDPOINT, SettingsTab, normalizeSettings, type Settings } from "./settings";
import type { Block, QueryResult, QuerySpec } from "./types";
const SCORING_VERSION = "jev-v1";
const QUERY_FENCE = /^ {0,3}(?:`{3,}|~{3,})(?:qualitative-query|qq)\s*$/m;
/** Each rendered query owns its subscriptions, async generation, and Markdown children. */
class QueryView extends MarkdownRenderChild {
private version = 0;
private run?: AbortController;
private stopped = false;
private saving = false;
private content?: Component;
constructor(
private readonly plugin: QualitativeQueryPlugin,
el: HTMLElement,
private readonly sourcePath: string,
private readonly blockSource?: string,
private readonly autoKey?: string,
) { super(el); }
onload(): void {
this.plugin.views.add(this);
void this.refresh();
}
invalidate(): void { if (!this.saving) { this.version++; this.run?.abort(); } }
onunload(): void {
this.stopped = true;
this.run?.abort();
this.invalidate();
this.plugin.views.delete(this);
if (this.autoKey && this.plugin.autoViews.get(this.autoKey) === this) this.plugin.autoViews.delete(this.autoKey);
this.containerEl.empty();
}
async refresh(): Promise<void> {
if (this.stopped || this.saving) return;
this.run?.abort();
const run = this.run = new AbortController();
const version = ++this.version;
const current = () => !this.stopped && version === this.version && !this.plugin.stopped;
if (this.content) { this.removeChild(this.content); this.content = undefined; }
renderLoading(this.containerEl);
let spec: QuerySpec = { question: "", folder: this.plugin.settings.queryFolder, contextPaths: [], criteria: { mode: "generic" } };
try {
const file = this.plugin.app.vault.getAbstractFileByPath(this.sourcePath);
if (!(file instanceof TFile)) throw new Error("The query note no longer exists. Open its new location or close this view.");
const text = await this.plugin.app.vault.cachedRead(file);
if (!current()) return;
// A note with explicit query blocks does not also get an automatic view.
if (this.blockSource === undefined && QUERY_FENCE.test(text)) { this.containerEl.empty(); return; }
spec = parseQuery(file.path, text, this.plugin.settings.queryFolder, this.blockSource);
spec.contextPaths = [...resolveContext(this.plugin.app, file, spec.contextPaths)];
await this.plugin.index.whenReady();
if (!current()) return;
if (this.plugin.indexError) throw new Error(this.plugin.indexError);
if (this.plugin.clientError) throw new Error(this.plugin.clientError);
let result: QueryResult;
{
let completed = 0;
let total = 0;
const started = Date.now();
let rendering = Promise.resolve();
let partialVersion = 0;
const showProgress = () => {
if (!current()) return;
let status = this.containerEl.querySelector<HTMLElement>(".qq-progress");
if (!status) {
status = createDiv(); status.className = "qq-status qq-progress";
this.containerEl.prepend(status);
}
status.textContent = this.plugin.client ? `Jev is checking passages: ${completed}/${total} · ${Math.floor((Date.now() - started) / 1000)}s` : "Finding passages locally…";
};
const timer = window.setInterval(showProgress, 1000);
try {
result = await this.plugin.engine.run(spec, this.plugin.settings.candidateLimit, this.plugin.settings.resultLimit, spec.threshold ?? this.plugin.settings.threshold,
(done, count) => { completed = done; total = count; showProgress(); },
partial => {
const revision = ++partialVersion;
rendering = rendering.then(async () => {
const valid = () => current() && revision === partialVersion;
if (!valid()) return;
await this.display(partial, spec, valid);
showProgress();
}).catch(error => console.warn("Qualitative Query: partial rendering failed", error));
}, run.signal);
} finally {
window.clearInterval(timer);
partialVersion++;
await rendering;
}
}
if (!current()) return;
await this.display(result, spec, current, true);
} catch (error) {
if (!current()) return;
await this.display({ status: "error", candidates: [], judgements: [], error: error instanceof Error ? error.message : String(error) }, spec, current);
}
}
private async display(result: QueryResult, spec: QuerySpec, current: () => boolean, allowBake = false): Promise<void> {
const baseResult = result;
const queryFile = this.plugin.app.vault.getAbstractFileByPath(this.sourcePath);
const manual = allowBake && queryFile instanceof TFile
? await applyInclusions(await this.plugin.app.vault.read(queryFile), spec, result, this.plugin.index.blocks)
: { result, missing: [] };
result = manual.result;
const content = new Component();
this.addChild(content);
const stage = createDiv();
stage.addClass("qq-view");
try {
await renderResult(this.plugin.app, stage, result, spec, content, (candidate, adjacent) =>
expandBlock(candidate, this.plugin.index.blocksForPath(candidate.path), adjacent),
allowBake && queryFile instanceof TFile ? async (judgement, include) => {
if (!current()) return;
const entry = await inclusionFor(judgement);
if (!current()) return;
this.saving = true;
try { await this.plugin.app.vault.process(queryFile, text => updateInclusions(text, spec, entry, include)); }
finally { this.saving = false; }
if (current()) await this.display(baseResult, spec, current, true);
} : undefined);
for (const missing of manual.missing) {
const warning = stage.createDiv({ cls: "qq-warning", text: missing.message });
const remove = warning.createEl("button", { text: "Remove inclusion" });
content.registerDomEvent(remove, "click", async () => {
if (!current() || !(queryFile instanceof TFile) || remove.disabled) return;
remove.disabled = true; this.saving = true;
try {
await this.plugin.app.vault.process(queryFile, text => updateInclusions(text, spec, missing.entry, false));
this.saving = false;
if (current()) await this.display(baseResult, spec, current, true);
} catch (error) { this.saving = false; remove.disabled = false; new Notice(error instanceof Error ? error.message : String(error)); }
});
}
if (allowBake && result.status === "ready" && result.judgements.length) {
const tools = createDiv();
tools.className = "qq-bake-actions";
const button = tools.createEl("button", { text: "Save passages" });
tools.createEl("small", { text: " Creates a note with these passages linked to their sources. Adds missing block IDs to source notes. Nearby context is not saved." });
const saveStatus = tools.createEl("small", { text: "" });
const controller = new AbortController();
content.register(() => controller.abort());
let save = () => bakeNote(this.plugin.app, result, spec, this.sourcePath, {
signal: controller.signal,
onWait: (pending, ms) => saveStatus.setText(` Waiting for ${pending} source links · timeout in ${Math.ceil(ms / 1000)}s.`),
});
content.registerDomEvent(button, "click", async () => {
if (!current() || controller.signal.aborted) return;
button.disabled = true;
this.saving = true;
try {
const file = await save();
this.saving = false;
saveStatus.setText(" Saved.");
new Notice("Passages saved. Open a source note’s backlinks to see the connection.");
await this.plugin.app.workspace.getLeaf(true).openFile(file, { state: { mode: "preview" } });
} catch (error) {
if (controller.signal.aborted) return;
if (error instanceof PendingSave) { save = error.retry; button.setText("Retry save"); }
else this.saving = false;
saveStatus.setText(error instanceof Error ? error.message : String(error)); button.disabled = false;
}
});
stage.prepend(tools);
}
if (!current()) { this.removeChild(content); return; }
if (this.content) this.removeChild(this.content);
this.content = content;
this.containerEl.empty();
this.containerEl.appendChild(stage);
} catch (error) { this.removeChild(content); throw error; }
}
}
export default class QualitativeQueryPlugin extends Plugin {
settings: Settings = { ...DEFAULT_SETTINGS };
index!: VaultIndex;
engine!: QueryEngine;
client: Pick<JevClient, "rank"> | null = null;
clientError = "";
indexError = "";
stopped = false;
readonly views = new Set<QueryView>();
readonly autoViews = new Map<string, QueryView>();
private refreshTimer?: number;
private fileTimers = new Map<string, number>();
private exclusionKey = "";
private scoreCache?: ScoreCache;
private retrieval!: LocalRetrieval;
private corpusSource?: Block[];
private corpus: Block[] = [];
async onload(): Promise<void> {
await this.loadSettings();
this.retrieval = new LocalRetrieval(() => new EmbeddingIndex({
read: name => this.app.vault.adapter.readBinary(`${this.pluginDirectory()}/embeddings/${name}`),
readWorker: async () => EMBEDDING_WORKER_SOURCE,
}), () => downloadEmbeddings(this.app.vault.adapter, this.pluginDirectory()));
this.addCommand({ id: "build-query", name: "Ask your vault", callback: () => new QueryBuilder(this.app, this.settings.queryFolder).open() });
this.addRibbonIcon("search", "Ask your vault", () => new QueryBuilder(this.app, this.settings.queryFolder).open());
const directory = this.manifest?.dir ?? `${this.app.vault.configDir}/plugins/qualitative-query`;
const cachePath = `${directory}/scores-v1.json`;
const adapter = this.app.vault.adapter;
if (adapter) {
let reported = false;
const onError = (error: unknown) => {
console.warn("Qualitative Query: score cache unavailable", error);
if (!reported) { reported = true; new Notice("Qualitative Query could not persist some scores. Check available disk space and restart Obsidian."); }
};
try {
const identityPath = `${directory}/score-cache-id.json`;
let identity: string;
if (await adapter.exists(identityPath)) {
const storedIdentity = parseObject(await adapter.read(identityPath)).id;
if (typeof storedIdentity !== "string" || !/^[a-f0-9-]{36}$/.test(storedIdentity)) throw new Error("Invalid score cache identity file.");
identity = storedIdentity;
} else {
identity = crypto.randomUUID();
await adapter.write(identityPath, JSON.stringify({ id: identity }));
}
this.scoreCache = new ScoreCache({ read: async () => await adapter.exists(cachePath) ? adapter.read(cachePath) : null },
{ databaseName: `qualitative-query-scores-v2-${identity}`, onError });
} catch (error) { onError(error); }
}
this.index = new VaultIndex(this.app.vault, () => this.exclusions());
this.exclusionKey = JSON.stringify(this.exclusions());
this.rebuildClient();
void this.scan();
for (const language of ["qualitative-query", "qq"]) {
this.registerMarkdownCodeBlockProcessor(language, (source, el, ctx) => {
if (el.closest(".qq-source-content")) { el.createEl("pre", { text: source }); return; }
this.mount(el, ctx, source);
});
}
this.registerMarkdownPostProcessor((el, ctx) => {
if (el.closest(".qq-view") || !this.inQueryFolder(ctx.sourcePath)) return;
const key = `${ctx.docId}:${ctx.sourcePath}`;
if (this.autoViews.has(key)) return;
if (el.querySelector(".qq-view, .block-language-qualitative-query, .block-language-qq")) return;
const view = this.mount(el.createDiv({ cls: "qq-auto-view" }), ctx, undefined, key);
this.autoViews.set(key, view);
});
this.registerEvent(this.app.vault.on("create", file => this.scheduleIndex(file)));
this.registerEvent(this.app.vault.on("modify", file => this.scheduleIndex(file)));
this.registerEvent(this.app.vault.on("delete", file => {
this.invalidateViews();
this.index.remove(file.path);
if (!(file instanceof TFile)) void this.scan();
else this.scheduleQueries();
}));
this.registerEvent(this.app.vault.on("rename", (file, oldPath) => {
window.clearTimeout(this.fileTimers.get(oldPath));
this.fileTimers.delete(oldPath);
this.index.remove(oldPath);
if (file instanceof TFile && file.extension !== "md") { this.invalidateViews(); this.scheduleQueries(); }
else this.scheduleIndex(file);
}));
this.addSettingTab(new SettingsTab(this.app, this));
this.addCommand({ id: "reindex-vault", name: "Reindex vault", callback: () => void this.scan() });
this.addCommand({ id: "refresh-queries", name: "Refresh open questions", callback: () => this.scheduleQueries() });
}
private mount(el: HTMLElement, ctx: MarkdownPostProcessorContext, source?: string, autoKey?: string): QueryView {
const view = new QueryView(this, el, ctx.sourcePath, source, autoKey);
ctx.addChild(view);
return view;
}
async loadSettings(): Promise<void> {
this.settings = normalizeSettings(await this.loadData());
this.settings.apiKey = this.app.secretStorage.getSecret("qualitative-query-api-key") ?? "";
}
async saveSettings(): Promise<void> {
const { apiKey, ...stored } = this.settings;
this.app.secretStorage.setSecret("qualitative-query-api-key", apiKey);
await this.saveData(stored);
const exclusions = JSON.stringify(this.exclusions());
if (exclusions !== this.exclusionKey) {
this.exclusionKey = exclusions;
await this.scan();
} else this.scheduleQueries();
}
rebuildClient(): void {
this.invalidateViews();
this.engine?.dispose();
this.clientError = "";
try {
this.client = this.settings.apiKey.trim() ? new JevClient(this.settings.apiKey, this.settings.model) : null;
} catch (error) { this.client = null; this.clientError = error instanceof Error ? error.message : String(error); }
if (this.index) this.engine = new QueryEngine(this.client, () => this.searchableBlocks(), path => readContextBlocks(this.app, path), `${SCORING_VERSION}:${TYPESAFE_ENDPOINT}:${this.settings.model}`, this.scoreCache, (question, blocks, limit, signal) => this.retrieval.search(question, blocks, limit, signal));
this.scheduleQueries();
}
private searchableBlocks(): Block[] {
const source = this.index.blocks;
if (source !== this.corpusSource) {
this.corpusSource = source;
this.corpus = source.filter(block => !QUERY_FENCE.test(block.text));
}
return this.corpus;
}
private pluginDirectory(): string { return this.manifest?.dir ?? `${this.app.vault.configDir}/plugins/qualitative-query`; }
embeddingStatus(): string { return this.retrieval.status; }
restartLocalSearch(): void {
this.retrieval.restart(); this.invalidateViews(); this.scheduleQueries();
}
async restoreEmbeddings(): Promise<void> {
await this.retrieval.restore();
if (this.stopped) return;
this.invalidateViews(); this.scheduleQueries();
}
private exclusions(): string[] {
return [...this.settings.excludedFolders.split(",").map(value => value.trim()).filter(Boolean), this.app.vault.configDir, this.settings.queryFolder, BAKED_FOLDER];
}
private inQueryFolder(path: string): boolean {
const folder = this.settings.queryFolder.replace(/^\/+|\/+$/g, "");
return Boolean(folder && path.startsWith(`${folder}/`));
}
private invalidateViews(): void {
this.engine?.invalidate();
for (const view of this.views) view.invalidate();
}
private async scan(): Promise<void> {
if (this.stopped) return;
this.invalidateViews();
this.indexError = "";
try { await this.index.initialScan(); }
catch { this.indexError = "Some vault files could not be indexed. Run Reindex vault after checking file access."; }
if (!this.stopped) this.scheduleQueries();
}
private scheduleIndex(file: TAbstractFile): void {
if (this.stopped) return;
if (file instanceof TFile && file.extension !== "md") return;
this.invalidateViews();
if (!(file instanceof TFile)) { void this.scan(); return; }
if (file.extension !== "md") { this.scheduleQueries(); return; }
const path = file.path;
window.clearTimeout(this.fileTimers.get(path));
const timer = window.setTimeout(() => {
this.fileTimers.delete(path);
void this.index.indexFile(file).catch(() => { this.indexError = `Could not index ${path}. Run Reindex vault.`; }).finally(() => this.scheduleQueries());
}, 250);
this.fileTimers.set(path, timer);
}
private scheduleQueries(): void {
if (this.stopped) return;
window.clearTimeout(this.refreshTimer);
this.refreshTimer = window.setTimeout(() => {
for (const view of this.views) void view.refresh();
}, 300);
}
onunload(): void {
this.stopped = true;
window.clearTimeout(this.refreshTimer);
for (const timer of this.fileTimers.values()) window.clearTimeout(timer);
this.fileTimers.clear();
this.engine?.dispose();
this.retrieval?.dispose();
this.index?.dispose();
this.corpusSource = undefined; this.corpus = [];
void this.scoreCache?.close();
for (const view of [...this.views]) view.unload();
this.autoViews.clear();
}
}
import { Component, MarkdownRenderer, TFile, type App } from "obsidian";
import type { Block, Candidate, Judgement, QueryResult, QuerySpec } from "./types";
type Expansion = (candidate: Candidate, adjacent: number) => Block;
/** Only the fence language changes; authored source content remains intact. */
export function inertQueryBlocks(text: string): string {
return text.replace(/^( {0,3})(`{3,}|~{3,})(?:qualitative-query|qq)[ \t]*$/gm, "$1$2text");
}
export async function renderResult(
app: App,
container: HTMLElement,
result: QueryResult,
spec: QuerySpec,
component: Component,
expand?: Expansion,
onInclude?: (judgement: Judgement, include: boolean) => Promise<void>,
belowThreshold = false,
): Promise<void> {
let active = true;
component.register(() => { active = false; });
container.empty();
container.addClass("qq-view");
if (result.stats) {
const s = result.stats;
container.createDiv({ cls: "qq-status", text: `Shortlisted ${s.shortlisted} passages from ${s.searchable} searchable blocks · ${s.overlapRemoved} overlaps removed${s.truncated ? ` · Window limit ${s.windowLimit} reached; more matches were available` : ""}.` });
container.createDiv({ cls: "qq-status", text: result.selection === "local"
? "Local matches · Jev minimum score does not apply."
: `${s.checked} checked · ${s.cached} cached · ${s.shared} shared · ${s.requested} new requests · ${s.retries} retries · ${s.skipped} skipped · ${s.passed} meet minimum score ${s.threshold}.` });
if (s.contextChars) container.createDiv({ cls: "qq-status", text: `Explicit context: ${s.contextChars.toLocaleString()} / 48,000 characters.` });
}
if (result.belowThreshold?.length) {
const below = result.belowThreshold;
const details = container.createEl("details");
details.createEl("summary", { text: `${below.length} passages below minimum score` });
details.createEl("p", { text: "Highest scores first. These passages are not included when you save the selection." });
const list = details.createDiv();
const more = details.createEl("button", { text: "Show next 25" });
let offset = 0, busy = false;
const page = async () => {
if (busy || !active || offset >= below.length) return;
busy = true; more.disabled = true;
const stage = list.createDiv();
const chunk = below.slice(offset, offset + 25);
await renderResult(app, stage, { status: "ready", selection: "jev", candidates: [], judgements: chunk }, { ...spec, adjacent: 0 }, component, expand, onInclude, true);
if (!active) return;
offset += chunk.length; busy = false; more.disabled = false; more.hidden = offset >= below.length;
};
component.registerDomEvent(details, "toggle", () => { if (details.open && offset === 0) void page(); });
component.registerDomEvent(more, "click", () => { void page(); });
}
if (result.warning) container.createDiv({ cls: "qq-warning", text: result.warning });
if (result.status !== "ready") {
container.createDiv({ cls: result.status === "error" ? "qq-error" : "qq-status", text: result.error ?? "No matching passages found. Try a broader question." });
return;
}
const visibleRanges: Array<{ path: string; start: number; end: number }> = [];
for (const judgement of result.judgements) {
if (!active) return;
const original = judgement.candidate;
let adjacent = spec.adjacent ?? 0;
const initial = adjacent && expand ? expand(original, adjacent) : original;
if (visibleRanges.some(range => range.path === initial.path && range.start <= initial.lineStart && range.end >= initial.lineEnd)) continue;
visibleRanges.push({ path: initial.path, start: initial.lineStart, end: initial.lineEnd });
const item = container.createDiv({ cls: "qq-result" });
const meta = item.createDiv({ cls: "qq-result-meta" });
const link = meta.createEl("a", { text: original.path, href: "#" });
component.registerDomEvent(link, "click", event => {
event.preventDefault();
const file = app.vault.getAbstractFileByPath(original.path);
if (!(file instanceof TFile)) return;
void app.workspace.getLeaf(event.ctrlKey || event.metaKey).openFile(file, { eState: { line: Math.max(0, original.lineStart - 1) } });
});
meta.createSpan({ cls: "qq-breadcrumb", text: original.headingPath.join(" › ") });
if (result.selection !== "local" && spec.criteria.mode === "default") meta.createSpan({ text: judgement.contribution });
meta.createSpan({ cls: "qq-score", text: result.selection === "local" ? "Local match" : `Jev score ${judgement.score.toFixed(2)}` });
if (judgement.manuallyIncluded) meta.createSpan({ text: "Manually included" });
if (onInclude && (belowThreshold || judgement.manuallyIncluded)) {
const control = item.createEl("button", { text: judgement.manuallyIncluded ? "Undo inclusion" : "Include passage" });
component.registerDomEvent(control, "click", async () => {
if (!active || control.disabled) return;
control.disabled = true;
try { await onInclude(judgement, !judgement.manuallyIncluded); }
catch (error) { if (active) { item.createDiv({ cls: "qq-error", text: error instanceof Error ? error.message : String(error) }); control.disabled = false; } }
});
}
const content = item.createDiv({ cls: "qq-source-content" });
let markdownChild: Component | undefined;
let serial = 0;
const show = async (block: Block): Promise<void> => {
if (!active) return;
const version = ++serial;
if (markdownChild) component.removeChild(markdownChild);
const child = new Component();
markdownChild = child;
component.addChild(child);
const stage = createDiv();
stage.addClass("qq-source-content");
try {
await MarkdownRenderer.render(app, inertQueryBlocks(block.renderText ?? block.text), stage, block.path, child);
} catch {
if (active && version === serial) content.setText("Could not render this passage. Open its source note.");
return;
}
if (!active || version !== serial) return;
content.empty();
content.appendChild(stage);
};
component.register(() => { serial++; });
await show(initial);
if (!active) return;
if (expand) {
const button = item.createEl("button", { cls: "qq-expand", text: "Show nearby text" });
component.registerDomEvent(button, "click", () => {
if (!active) return;
adjacent = adjacent ? 0 : 1;
button.setText(adjacent ? "Hide nearby text" : "Show nearby text");
void show(adjacent ? expand(original, adjacent) : original);
});
}
}
}
export function renderLoading(container: HTMLElement): void {
container.empty();
container.addClass("qq-view");
container.createDiv({ cls: "qq-status", text: "Finding passages…" });
}
import { Notice, PluginSettingTab, Setting, type App, type SettingDefinitionItem, type SettingDefinitionRender } from "obsidian";
import { MAX_CANDIDATES } from "./limits";
import type QualitativeQueryPlugin from "./main";
export interface Settings { apiKey: string; model: string; queryFolder: string; candidateLimit: number; resultLimit: number; threshold: number; excludedFolders: string; }
export const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
export const DEFAULT_SETTINGS: Settings = { apiKey: "", model: "jev-1.13.0", queryFolder: "Queries", candidateLimit: 1000, resultLimit: 8, threshold: 0.45, excludedFolders: "" };
/** Whitelist retained settings; discard retired provider, endpoint and model paths. */
export function normalizeSettings(value: unknown): Settings {
const data = value && typeof value === "object" ? value as Record<string, unknown> : {};
const result = { ...DEFAULT_SETTINGS };
for (const key of ["queryFolder", "excludedFolders"] as const) if (typeof data[key] === "string") result[key] = data[key];
if (typeof data.model === "string" && /^jev-[a-zA-Z0-9.-]+$/.test(data.model)) result.model = data.model;
for (const key of ["candidateLimit", "resultLimit", "threshold"] as const) {
const value = data[key];
if (typeof value === "number" && Number.isFinite(value)) result[key] = key === "threshold" ? Math.min(1, Math.max(0, value)) : Math.min(key === "candidateLimit" ? MAX_CANDIDATES : 100, Math.max(1, Math.floor(value)));
}
return result;
}
export class SettingsTab extends PluginSettingTab {
constructor(app: App, private readonly plugin: QualitativeQueryPlugin) { super(app, plugin); }
getSettingDefinitions(): SettingDefinitionItem[] { return this.definitions(); }
private definitions(): Array<Omit<SettingDefinitionRender, "render"> & { render: (setting: Setting) => void }> {
return [
{ name: "Jev API key", desc: "Jev selects passages that answer your question. Sends your question, shortlisted passages, and chosen context notes to TypeSafe. API charges may apply. Without a key, search stays local. The key uses Obsidian secret storage.", render: setting => { setting.addText((text) => { text.setPlaceholder("ts_").setValue(this.plugin.settings.apiKey); text.inputEl.type = "password"; text.onChange(async (value: string) => { this.plugin.settings.apiKey = value.trim(); await this.plugin.saveSettings(); this.plugin.rebuildClient(); }); }); } },
{ name: "Jev model", desc: "Model used to score passages. Keep the default unless TypeSafe recommends another.", render: setting => { setting.addText((text) => text.setValue(this.plugin.settings.model).onChange(async (value) => { this.plugin.settings.model = /^jev-[a-zA-Z0-9.-]+$/.test(value.trim()) ? value.trim() : DEFAULT_SETTINGS.model; await this.plugin.saveSettings(); this.plugin.rebuildClient(); })); } },
{ name: "Search by meaning", desc: "Optional local model finds related wording alongside keyword matches. " + this.plugin.embeddingStatus(), render: setting => { setting.addButton(button => button.setButtonText("Download search model").onClick(async () => { button.setDisabled(true); try { await this.plugin.restoreEmbeddings(); new Notice("Search model downloaded. Open a question to use it."); } catch (error) { new Notice(error instanceof Error ? error.message : String(error)); } finally { button.setDisabled(false); this.renderSettings(); } })); } },
{ name: "Restart local search", desc: "Reloads the downloaded model after a search failure. Does not download files.", render: setting => { setting.addButton(button => button.setButtonText("Restart local search").onClick(() => { this.plugin.restartLocalSearch(); this.renderSettings(); })); } },
{ name: "Questions folder", desc: "Where saved questions live. Results update when you open them or edit source notes.", render: setting => { setting.addText((text) => text.setValue(this.plugin.settings.queryFolder).onChange(async (value) => { this.plugin.settings.queryFolder = value.trim() || DEFAULT_SETTINGS.queryFolder; await this.plugin.saveSettings(); })); } },
{ name: "Passages to check", desc: "Maximum passages shortlisted for each question, up to 1,000. Jev checks this shortlist. Higher limits take longer and can cost more.", render: setting => { setting.addText((text) => text.setValue(String(this.plugin.settings.candidateLimit)).onChange(async (value) => { const parsed = Number(value); this.plugin.settings.candidateLimit = Math.min(MAX_CANDIDATES, Math.max(1, Number.isFinite(parsed) ? Math.floor(parsed) : DEFAULT_SETTINGS.candidateLimit)); await this.plugin.saveSettings(); })); } },
{ name: "Passages to show", desc: "Maximum results when the saved question does not specify a limit.", render: setting => { setting.addText((text) => text.setValue(String(this.plugin.settings.resultLimit)).onChange(async (value) => { const parsed = Number(value); this.plugin.settings.resultLimit = Math.min(100, Math.max(1, Number.isFinite(parsed) ? parsed : DEFAULT_SETTINGS.resultLimit)); await this.plugin.saveSettings(); })); } },
{ name: "Minimum Jev score", desc: "From 0 to 1. Raise it for fewer matches or lower it for more. A score is not a probability of correctness. Saved questions can override this. Local matches do not use this cutoff.", render: setting => { setting.addText((text) => text.setValue(String(this.plugin.settings.threshold)).onChange(async (value) => { const parsed = Number(value); this.plugin.settings.threshold = Math.min(1, Math.max(0, Number.isFinite(parsed) ? parsed : DEFAULT_SETTINGS.threshold)); await this.plugin.saveSettings(); })); } },
{ name: "Skip folders", desc: "Folder paths separated by commas. Questions, saved selections, and the vault settings folder are always skipped.", render: setting => { setting.addText((text) => text.setValue(this.plugin.settings.excludedFolders).onChange(async (value) => { this.plugin.settings.excludedFolders = value; await this.plugin.saveSettings(); })); } },
];
}
// Obsidian 1.11–1.12 requires display(); newer versions can search these same definitions.
display(): void { this.renderSettings(); }
private renderSettings(): void {
const { containerEl } = this; containerEl.empty();
containerEl.createEl("p", { text: "Ask your vault a question. Connect the passages that answer it." });
for (const definition of this.definitions()) {
const setting = new Setting(containerEl).setName(definition.name).setDesc(definition.desc ?? "");
definition.render(setting);
}
}
}
export type Contribution = "definition" | "condition" | "distinction" | "other";
export interface QueryCriteria {
mode: "default" | "generic";
instructions?: string;
true?: string;
false?: string;
criteria?: Array<{ id: string; instructions: string; true: string; false: string; weight?: number }>;
}
export interface QuerySpec {
question: string;
folder: string;
contextPaths: string[];
criteria: QueryCriteria;
limit?: number;
threshold?: number;
adjacent?: number;
}
export interface Block {
id: string;
path: string;
lineStart: number;
lineEnd: number;
text: string;
searchText: string;
/** Text assembled for display when a list item needs ancestor context. */
renderText?: string;
kind: "paragraph" | "heading" | "list" | "fence" | "table" | "callout" | "blockquote" | "yaml";
headingPath: string[];
parentId?: string;
sectionId?: string;
}
export interface IndexedFile {
path: string;
mtime: number;
blocks: Block[];
}
export interface Candidate extends Block {
lexicalScore: number;
retrievalScore?: number;
semanticScore?: number;
}
export interface Judgement {
manuallyIncluded?: boolean;
candidate: Candidate;
score: number;
contribution: Contribution;
scores: Record<string, number>;
}
export interface JevAnswer {
noul?: number;
choice?: string;
probabilities?: Record<string, number>;
confidence?: number;
}
export interface JevResponse {
answers: Record<string, JevAnswer>;
usage?: { input_tokens?: number; output_tokens?: number };
}
export interface QueryStats {
searchable: number;
shortlisted: number;
overlapRemoved: number;
windowLimit: number;
truncated: boolean;
checked: number;
cached: number;
shared: number;
requested: number;
retries: number;
skipped: number;
passed: number;
contextChars: number;
threshold: number;
}
export interface QueryResult {
status: "ready" | "needs-key" | "error" | "empty";
judgements: Judgement[];
belowThreshold?: Judgement[];
stats?: QueryStats;
candidates: Candidate[];
error?: string;
warning?: string;
selection?: "jev" | "local";
}
import { describe, expect, it, vi } from "vitest";
import { QueryEngine } from "../src/engine";
import { JevClient } from "../src/jev";
import type { Block, QuerySpec } from "../src/types";
const spec: QuerySpec = { question: "conflict", folder: "Queries", contextPaths: [], criteria: { mode: "generic" } };
const blocks: Block[] = Array.from({ length: 100 }, (_, i) => ({ id: String(i), path: `${i}.md`, lineStart: 1, lineEnd: 1, text: `conflict ${i}`, searchText: `conflict ${i}`, headingPath: [], kind: "paragraph" }));
const response = { answers: { relevant: { noul: 0.9 } } };
const delay = () => new Promise(resolve => setTimeout(resolve, 0));
describe("query lifetimes", () => {
for (const failing of [1, 50, 100]) it(`stops requests and callbacks after failure ${failing}`, async () => {
let calls = 0, late = 0, settled = false;
const engine = new QueryEngine({ rank: async () => { const call = ++calls; await delay(); if (call === failing) throw new Error("provider failed"); return response; } }, () => blocks);
await expect(engine.run(spec, 100, 6, 0.5, () => { if (settled) late++; }, () => { if (settled) late++; })).rejects.toThrow("provider failed");
settled = true;
const atSettlement = calls;
await new Promise(resolve => setTimeout(resolve, 30));
expect(calls).toBe(atSettlement);
expect(late).toBe(0);
});
it("releases a closed view without cancelling a duplicate subscriber", async () => {
const controller = new AbortController();
let finish!: (value: typeof response) => void;
let transportSignal!: AbortSignal;
const rank = vi.fn(async (_q, _p, _c, _ctx, _throttle, _retry, signal) => {
transportSignal = signal;
return new Promise<typeof response>(resolve => { finish = resolve; });
});
const engine = new QueryEngine({ rank }, () => blocks.slice(0, 1));
const closed = engine.run(spec, 1, 1, 0.5, undefined, undefined, controller.signal);
const survivor = engine.run(spec, 1, 1, 0.5);
await vi.waitFor(() => expect(rank).toHaveBeenCalledTimes(1));
controller.abort();
await expect(closed).rejects.toThrow(/cancelled/);
expect(transportSignal.aborted).toBe(false);
finish(response);
expect((await survivor).judgements).toHaveLength(1);
});
it("cancels active transport when its last consumer closes and never starts its queue", async () => {
const controller = new AbortController();
const signals: AbortSignal[] = [];
const rank = vi.fn(async (_q, _p, _c, _ctx, _throttle, _retry, signal: AbortSignal) => {
signals.push(signal);
return new Promise<typeof response>((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }));
});
const engine = new QueryEngine({ rank }, () => blocks);
const run = engine.run(spec, 100, 6, 0.5, undefined, undefined, controller.signal);
await vi.waitFor(() => expect(rank).toHaveBeenCalledTimes(16));
controller.abort();
await expect(run).rejects.toThrow(/cancelled/);
await delay();
expect(rank).toHaveBeenCalledTimes(16);
expect(signals.every(signal => signal.aborted)).toBe(true);
});
it("does not retry a non-abortable request after its consumer closes", async () => {
const controller = new AbortController();
let finish!: (value: { status: number; json: unknown }) => void;
const post = vi.fn(() => new Promise<{ status: number; json: unknown }>(resolve => { finish = resolve; }));
const client = new JevClient("key", "model", { post });
const run = client.rank("q", "p", { mode: "generic" }, "", undefined, undefined, controller.signal);
controller.abort(); finish({ status: 429, json: {} });
await expect(run).rejects.toThrow(/cancelled/);
expect(post).toHaveBeenCalledTimes(1);
});
});
it("many cancelled subscribers leave one survivor and do not consume its result", async () => {
const { SharedWork } = await import("../src/work");
let finish!: (value: number) => void;
const retired = vi.fn();
const task = new SharedWork<number>(async () => new Promise(resolve => { finish = resolve; }), retired);
const survivor = task.join();
await Promise.resolve();
for (let i = 0; i < 100; i++) {
const controller = new AbortController();
const closed = task.join(controller.signal);
controller.abort();
await expect(closed).rejects.toThrow(/cancelled/);
}
expect(retired).not.toHaveBeenCalled();
finish(7);
expect(await survivor).toBe(7);
expect(retired).toHaveBeenCalledTimes(1);
});
import "fake-indexeddb/auto";
import { parseMarkdown } from "../src/markdown";
import { describe, expect, it, vi } from "vitest";
import { QueryEngine } from "../src/engine";
import type { Block, QuerySpec } from "../src/types";
const block = (id: string, path: string, text: string): Block => ({ id, path, lineStart: 1, lineEnd: 1, text, searchText: text, kind: "paragraph", headingPath: [] });
const spec: QuerySpec = { question: "What defines conflict?", folder: "Queries", contextPaths: ["context.md"], criteria: { mode: "default" } };
describe("query engine", () => {
it("keeps the corpus vault-wide while passing explicit context to Jev", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { definition: { noul: 0.8 }, condition: { noul: 0.2 }, distinction: { noul: 0.1 }, contribution: { choice: "definition" } } });
const client = { rank } as any;
const corpus = [block("a", "a.md", "A conflict is a disagreement."), block("b", "b.md", "Conflict has conditions.")];
const engine = new QueryEngine(client, () => corpus, (path) => path === "context.md" ? [block("ctx", path, "In this project conflict means a design disagreement.")] : []);
const progress = vi.fn();
const result = await engine.run(spec, 10, 10, 0.4, progress);
expect(progress.mock.calls).toEqual([[0, 2], [1, 2], [2, 2]]);
expect(result.status).toBe("ready");
expect(rank).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.anything(), expect.stringContaining("design disagreement"), expect.any(Function), expect.any(Function), expect.any(AbortSignal));
expect(rank).toHaveBeenCalledTimes(2);
});
it("reports API failures instead of presenting an empty qualitative view", async () => {
const client = { rank: vi.fn().mockRejectedValue(new Error("Jev request failed (401).")) } as any;
const engine = new QueryEngine(client, () => [block("a", "a.md", "A conflict is a disagreement.")]);
await expect(engine.run({ ...spec, contextPaths: [] }, 10, 10, 0.4)).rejects.toThrow("401");
});
it("shares scores for identical text while preserving different source locations", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.8 } } });
const engine = new QueryEngine({ rank } as any, () => [block("a", "a.md", "same conflict passage"), block("b", "b.md", "same conflict passage")]);
const result = await engine.run({ ...spec, criteria: { mode: "generic" }, contextPaths: [] }, 10, 10, 0.4);
expect(result.judgements.map((item) => item.candidate.path).sort()).toEqual(["a.md", "b.md"]);
expect(rank).toHaveBeenCalledTimes(1);
});
it("invalidates judgement cache when context content changes", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.8 } } });
let context = "The first meaning.";
const engine = new QueryEngine({ rank } as any, () => [block("a", "a.md", "conflict definition")], () => [block("context", "context.md", context)]);
const generic = { ...spec, criteria: { mode: "generic" as const }, contextPaths: ["context.md"] };
await engine.run(generic, 10, 10, 0.4);
context = "The edited meaning.";
await engine.run(generic, 10, 10, 0.4);
expect(rank).toHaveBeenCalledTimes(2);
expect(rank.mock.calls[1][3]).toContain("edited meaning");
});
it("returns the current candidate when reusing a cached judgement", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.8 } } });
let corpus = [block("a", "a.md", "conflict definition")];
const engine = new QueryEngine({ rank } as any, () => corpus);
const generic = { ...spec, criteria: { mode: "generic" as const }, contextPaths: [] };
await engine.run(generic, 10, 10, 0.4);
const replacement = block("a", "a.md", "conflict definition");
corpus = [replacement];
const result = await engine.run(generic, 10, 10, 0.4);
expect(result.judgements[0].candidate.path).toBe(replacement.path);
expect(result.judgements[0].candidate.text).toBe(replacement.text);
expect(rank).toHaveBeenCalledTimes(1);
});
it("orders default contributions before score order", async () => {
const rank = vi.fn().mockImplementation(async (_q: string, passage: string) => ({ answers: {
definition: { noul: passage.includes("condition") ? 0.99 : 0.2 },
condition: { noul: passage.includes("condition") ? 0.2 : 0.9 },
distinction: { noul: 0.1 }, contribution: { choice: passage.includes("condition") ? "condition" : "definition" },
} }));
const engine = new QueryEngine({ rank } as any, () => [block("c", "c.md", "condition conflict"), block("d", "d.md", "definition conflict")]);
const result = await engine.run(spec, 10, 10, 0.05);
expect(result.judgements.map((item) => item.contribution)).toEqual(["definition", "condition"]);
});
it("rejects malformed model responses instead of returning empty", async () => {
const engine = new QueryEngine({ rank: vi.fn().mockResolvedValue({ answers: {} }) } as any, () => [block("a", "a.md", "conflict definition")]);
await expect(engine.run({ ...spec, contextPaths: [] }, 10, 10, 0.4)).rejects.toThrow(/invalid score/);
});
it("deduplicates overlapping parent and child ranges", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.8 } } });
const parent = block("parent", "a.md", "conflict parent\nchild text"); parent.lineStart = 1; parent.lineEnd = 3;
const child = block("child", "a.md", "conflict child"); child.lineStart = 2; child.lineEnd = 3;
const result = await new QueryEngine({ rank } as any, () => [parent, child]).run({ ...spec, criteria: { mode: "generic" }, contextPaths: [] }, 10, 10, 0.4);
expect(result.candidates).toHaveLength(1);
expect(result.candidates[0].id).toBe("child");
});
it("allows concurrent query views to complete independently", async () => {
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.8 } } });
const engine = new QueryEngine({ rank } as any, () => [block("a", "a.md", "conflict definition")]);
const one = engine.run({ ...spec, question: "conflict" , criteria: { mode: "generic" }, contextPaths: [] }, 10, 10, 0.4);
const two = engine.run({ ...spec, question: "definition", criteria: { mode: "generic" }, contextPaths: [] }, 10, 10, 0.4);
await expect(Promise.all([one, two])).resolves.toHaveLength(2);
expect(rank).toHaveBeenCalledTimes(2);
});
it("does not publish a request invalidated by a source refresh", async () => {
let resolve!: (value: unknown) => void;
const rank = vi.fn().mockReturnValue(new Promise((done) => { resolve = done; }));
const engine = new QueryEngine({ rank } as any, () => [block("a", "a.md", "conflict definition")]);
const stale = engine.run({ ...spec, criteria: { mode: "generic" }, contextPaths: [] }, 10, 10, 0.4);
await vi.waitFor(() => expect(rank).toHaveBeenCalled());
engine.invalidate();
resolve({ answers: { relevant: { noul: 0.9 } } });
await expect(stale).rejects.toThrow(/superseded/);
});
});
it("reuses persisted scores after engine restart and invalidates changed passages", async () => {
const { ScoreCache } = await import('../src/score-cache');
let saved: string | null = null;
const storage = { read: async () => saved, write: async (value: string) => { saved = value; } };
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.9 } } });
let corpus = [block('a', 'a.md', 'collective conflict resolution')];
const query = { ...spec, contextPaths: [], criteria: { mode: 'generic' as const } };
const first = new QueryEngine({ rank }, () => corpus, () => [], 'jev-v1', new ScoreCache(storage));
await first.run(query, 12, 8, 0.45); first.dispose();
const second = new QueryEngine({ rank }, () => corpus, () => [], 'jev-v1', new ScoreCache(storage));
expect((await second.run(query, 12, 8, 0.45)).judgements).toHaveLength(1);
expect(rank).toHaveBeenCalledTimes(1);
corpus = [block('a', 'a.md', 'collective conflict resolution revised')];
await second.run(query, 12, 8, 0.45);
expect(rank).toHaveBeenCalledTimes(2);
});
it("skips oversized model inputs without losing other results", async () => {
const rank = vi.fn().mockImplementation(async (_q: string, passage: string) => {
if (passage.includes('oversized')) throw Object.assign(new Error('too many tokens'), {code:'INPUT_TOO_LARGE'});
return {answers:{relevant:{noul:0.9}}};
});
const engine = new QueryEngine({rank}, () => [block('a','a.md','conflict oversized'),block('b','b.md','conflict definition')]);
const result = await engine.run({...spec,contextPaths:[],criteria:{mode:'generic'}},12,8,0.45);
expect(result.judgements).toHaveLength(1);
expect(result.warning).toContain('model input limit');
});
it("shows local retrieval without a key and does not apply Jev probability thresholds", async () => {
const blocks = parseMarkdown("source.md", "Mediation resolves conflict.");
const engine = new QueryEngine(null, () => blocks);
const result = await engine.run({question:"conflict",folder:"Queries",contextPaths:[],criteria:{mode:"default"}},12,8,1);
expect(result.status).toBe("ready");
expect(result.selection).toBe("local");
expect(result.judgements).toHaveLength(1);
expect(result.judgements[0].contribution).toBe("other");
expect(result.warning).toContain("Jev API key");
});
it("evaluates a thousand candidates without widening the displayed result limit", async () => {
const corpus = Array.from({ length: 1000 }, (_, i) => block(`passage-${i}`, `note-${i}.md`, `Conflict evidence ${i}.`));
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.9 } } });
const engine = new QueryEngine({ rank }, () => corpus);
const progress = vi.fn();
const result = await engine.run({ ...spec, contextPaths: [], criteria: { mode: "generic" }, limit: 6 }, 1000, 8, 0.5, progress);
expect(result.candidates).toHaveLength(1000);
expect(rank).toHaveBeenCalledTimes(1000);
expect(result.judgements).toHaveLength(6);
expect(progress).toHaveBeenLastCalledWith(1000, 1000);
});
it("revisits an earlier thousand-candidate query after other queries and a restart without rescoring", async () => {
const { ScoreCache } = await import("../src/score-cache");
const storage = { read: async () => null };
const options = { databaseName: "navigation-thousand-candidates" };
const corpus = Array.from({length:1000},(_,i)=>block(`retained-${i}`,`retained-${i}.md`,`Conflict evidence ${i}.`));
const rank = vi.fn().mockResolvedValue({answers:{relevant:{noul:0.9}}});
const firstCache = new ScoreCache(storage,options);
const engine = new QueryEngine({rank},()=>corpus,()=>[],"cache-scale-test",firstCache);
const query = {...spec,contextPaths:[],criteria:{mode:"generic" as const}};
for (const question of ["Conflict first", "Conflict second", "Conflict third"]) await engine.run({...query,question},1000,6,0.5);
expect(rank).toHaveBeenCalledTimes(3000);
engine.dispose(); await firstCache.close();
const restoredCache = new ScoreCache(storage,options);
const restored = new QueryEngine({rank},()=>corpus,()=>[],"cache-scale-test",restoredCache);
const result = await restored.run({...query,question:"Conflict first"},1000,6,0.5);
expect(result.judgements).toHaveLength(6);
expect(rank).toHaveBeenCalledTimes(3000);
restored.dispose();await restoredCache.close();
});
it("starts 16 requests in retrieval order, reduces concurrency on throttling, and publishes before completion", async () => {
const corpus = Array.from({length:40},(_,i)=>block(String(i),`${40-i}.md`,`conflict ${i}`));
const pending: Array<() => void> = [];
let active = 0, peak = 0;
const rank = vi.fn((_q, _p, _c, _ctx, throttle) => new Promise<any>(resolve => {
active++; peak = Math.max(peak, active);
pending.push(() => { active--; resolve({answers:{relevant:{noul:.8}}}); });
}));
const retrieval = () => ({ candidates: corpus.map((b,i)=>({...b,lexicalScore:40-i})) });
const engine = new QueryEngine({rank},()=>corpus,()=>[],"",undefined,retrieval);
const partial = vi.fn();
const run = engine.run({...spec,contextPaths:[],criteria:{mode:"generic"}},1000,6,.5,undefined,partial);
await vi.waitFor(()=>expect(rank).toHaveBeenCalledTimes(16));
expect(rank.mock.calls.map(c=>c[1])).toEqual(corpus.slice(0,16).map(b=>b.text));
rank.mock.calls[0][4]();
pending.shift()!();
await vi.waitFor(()=>expect(partial).toHaveBeenCalled());
expect(rank).toHaveBeenCalledTimes(16);
for(let i=0;i<7;i++) pending.shift()!();
await new Promise(resolve=>setTimeout(resolve,10));
expect(rank).toHaveBeenCalledTimes(16); // eight remain; reduced cap is eight
pending.shift()!();
await vi.waitFor(()=>expect(rank).toHaveBeenCalledTimes(17));
while(rank.mock.calls.length<40 || pending.length){
pending.splice(0).forEach(done=>done());
await new Promise(resolve=>setTimeout(resolve,1));
}
const result = await run;
expect(peak).toBe(16);
expect(result.judgements).toHaveLength(6);
});
it("reuses persisted content after moves but rescoring follows heading, rubric, and model changes", async () => {
const {ScoreCache}=await import('../src/score-cache');
const cache = new ScoreCache({read:async()=>null},{databaseName:'content-moves'});
const rank=vi.fn().mockResolvedValue({answers:{relevant:{noul:.9}}});
let corpus=[block('old','old.md','conflict evidence')];
const query={...spec,contextPaths:[],criteria:{mode:'generic' as const}};
await new QueryEngine({rank},()=>corpus,()=>[],'model-a',cache).run(query,1000,6,.5);
corpus=[{...block('new','new.md','conflict evidence'),lineStart:50,lineEnd:50}];
const engine=new QueryEngine({rank},()=>corpus,()=>[],'model-a',cache);
expect((await engine.run(query,1000,6,.5)).judgements[0].candidate.path).toBe('new.md');
expect(rank).toHaveBeenCalledTimes(1);
corpus[0].headingPath=['Changed meaning'];
await engine.run(query,1000,6,.5);
await engine.run({...query,criteria:{mode:'generic',instructions:'Different rubric'}},1000,6,.5);
await new QueryEngine({rank},()=>corpus,()=>[],'model-b',cache).run(query,1000,6,.5);
expect(rank).toHaveBeenCalledTimes(4);
await cache.close();
});
it("promotes legacy location scores using batched lookups without API calls", async () => {
const value={score:.9,contribution:'other' as const,scores:{relevant:.9}};
const stored=new Map<string,typeof value>();
const getMany=vi.fn(async(keys:string[])=>new Map(keys.flatMap(key=>{
if(JSON.parse(key).candidate)return [[key,value] as const];
return stored.has(key)?[[key,stored.get(key)!] as const]:[];
})));
const cache={get:vi.fn(),getMany,set:vi.fn(async(key:string,v:typeof value)=>{stored.set(key,v);})};
const rank=vi.fn();
const corpus=[block('a','a.md','conflict evidence')];
const engine=new QueryEngine({rank},()=>corpus,()=>[],'old-namespace',cache);
const result=await engine.run({...spec,contextPaths:[],criteria:{mode:'generic'}},1000,6,.5);
expect(result.judgements).toHaveLength(1);
expect(rank).not.toHaveBeenCalled();expect(cache.get).not.toHaveBeenCalled();
expect(cache.set).toHaveBeenCalledTimes(1);
expect(JSON.parse([...stored.keys()][0])).toMatchObject({keyVersion:2,passage:'conflict evidence'});
});
it("preserves retrieval warnings through partial and final Jev results", async () => {
const corpus = [block("a", "a.md", "Conflict is a disagreement.")];
const engine = new QueryEngine({ rank: async () => ({ answers: { relevant: { noul: 0.9 } } }) },
() => corpus, () => [], "", undefined,
() => ({ candidates: corpus.map(candidate => ({ ...candidate, lexicalScore: 1 })), warning: "Keyword fallback for this question." }));
const partial = vi.fn();
const result = await engine.run({ ...spec, contextPaths: [], criteria: { mode: "generic" } }, 10, 6, 0.5, undefined, partial);
expect(result.warning).toBe("Keyword fallback for this question.");
expect(partial.mock.calls[0][0].warning).toContain("Keyword fallback for this question.");
});
it("accounts for rejected scores and reuses them after lowering the threshold", async () => {
const corpus = [block("a", "a.md", "Conflict one"), block("b", "b.md", "Conflict two")];
const rank = vi.fn(async (_question: string, passage: string) => ({ answers: { relevant: { noul: passage.includes("one") ? 0.8 : 0.2 } } }));
const engine = new QueryEngine({ rank }, () => corpus);
const query = { ...spec, contextPaths: [], criteria: { mode: "generic" as const } };
const first = await engine.run(query, 10, 6, 0.5);
expect(first.stats).toMatchObject({ checked: 2, cached: 0, requested: 2, passed: 1, skipped: 0 });
expect(first.belowThreshold?.map(j => j.score)).toEqual([0.2]); expect(first.judgements).toHaveLength(1);
const second = await engine.run(query, 10, 6, 0.1);
expect(second.stats).toMatchObject({ checked: 2, cached: 2, requested: 0, passed: 2 });
expect(second.belowThreshold).toEqual([]); expect(rank).toHaveBeenCalledTimes(2);
});
it("counts shared requests separately and exposes all-rejected results", async () => {
const corpus = [block("a", "a.md", "Conflict same"), block("b", "b.md", "Conflict same")];
const rank = vi.fn(async () => ({ answers: { relevant: { noul: 0.1 } } }));
const engine = new QueryEngine({ rank }, () => corpus);
const result = await engine.run({ ...spec, contextPaths: [], criteria: { mode: "generic" } }, 10, 6, 0.5);
expect(result.stats).toMatchObject({ checked: 2, requested: 1, shared: 1, passed: 0 });
expect(result.status).toBe("empty"); expect(result.belowThreshold).toHaveLength(2);
});
it("deduplicates ordered context ranges with linear position work", async () => {
let reads = 0;
const context = Array.from({ length: 10_000 }, (_, i) => ({ ...block(String(i), "context.md", "x"), get lineStart() { reads++; return i * 2 + 1; }, lineEnd: i * 2 + 1 }));
const engine = new QueryEngine(null, () => [], () => context);
await engine.run({ ...spec, contextPaths: ["context.md"] }, 10, 10, 0.5);
expect(reads).toBeLessThan(10_000 * 10);
});
it("keeps the original context overlap/span/tie behavior across disjoint ranges", async () => {
const context = [
{ ...block("a", "context.md", "short"), lineStart: 1, lineEnd: 2 },
{ ...block("b", "context.md", "parent"), lineStart: 1, lineEnd: 5 },
{ ...block("c", "context.md", "child"), lineStart: 3, lineEnd: 4 },
{ ...block("d", "context.md", "next"), lineStart: 8, lineEnd: 9 },
{ ...block("e", "context.md", "equal-range duplicate"), lineStart: 8, lineEnd: 9 },
];
const rank = vi.fn().mockResolvedValue({ answers: { relevant: { noul: 0.9 } } });
const engine = new QueryEngine({ rank }, () => [block("source", "source.md", "conflict")], () => context);
await engine.run({ ...spec, criteria: { mode: "generic" }, contextPaths: ["context.md"] }, 10, 10, 0.5);
expect(rank.mock.calls[0][3]).toBe("parent\n\nnext");
});
import { describe, expect, it, vi } from "vitest";
import { JevClient } from "../src/jev";
describe("Jev transport", () => {
it("sends typed noul and choice questions in one request", async () => {
const post = vi.fn().mockResolvedValue({ status: 200, json: { answers: { definition: { noul: 0.9 }, contribution: { choice: "definition" } } } });
const client = new JevClient("secret", "jev-1.13.0", { post });
const response = await client.rank("What defines conflict?", "Conflict is...", { mode: "default" }, "A linked context note");
expect(response.answers.definition.noul).toBe(0.9);
const body = post.mock.calls[0][2] as any;
expect(body.state.context_notes).toContain("linked context");
expect(body.questions.definition.type).toBe("noul");
expect(body.questions.contribution.type).toBe("choice");
});
it("retries rate limits and does not retry client errors", async () => {
const post = vi.fn().mockResolvedValueOnce({ status: 429, json: {} }).mockResolvedValueOnce({ status: 200, json: { answers: {} } });
const client = new JevClient("secret", "jev-1.13.0", { post }, async () => {});
const throttle = vi.fn();
await client.rank("q", "c", { mode: "generic" }, "", throttle);
expect(throttle).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(2);
const bad = vi.fn().mockResolvedValue({ status: 422, json: {} });
await expect(new JevClient("secret", "jev-1.13.0", { post: bad }, async () => {}).rank("q", "c", { mode: "generic" })).rejects.toThrow("422");
expect(bad).toHaveBeenCalledTimes(1);
});
it("requires a key and sends credentials only to the fixed Jev endpoint", async () => {
const post = vi.fn().mockResolvedValue({ status: 200, json: { answers: {} } });
await expect(new JevClient("", "jev-1.13.0", { post }).rank("q", "p", { mode: "generic" })).rejects.toThrow("key");
expect(post).not.toHaveBeenCalled();
await new JevClient("secret", "jev-1.13.0", { post }).rank("q", "p", { mode: "generic" });
expect(post.mock.calls[0][0]).toBe("https://api.typesafe.ai/v1/systemone");
expect(post.mock.calls[0][1].Authorization).toBe("Bearer secret");
});
});
import { expect, it } from "vitest";
import { applyInclusions, inclusionFor, updateInclusions } from "../src/manual-selection";
import { parseMarkdown } from "../src/markdown";
import type { Judgement, QueryResult, QuerySpec } from "../src/types";
const spec: QuerySpec = { question: "What matters?", folder: "Queries", criteria: { mode: "generic" }, contextPaths: [], limit: 1, threshold: .5 };
const blocks = parseMarkdown("source.md", "Automatic passage.\n\nChosen passage.");
const judgement = (index: number, score: number): Judgement => ({ candidate: { ...blocks[index], lexicalScore: 1 }, score, contribution: "other", scores: { relevant: score } });
const automatic = judgement(0, .9), below = judgement(1, .2);
const result: QueryResult = { status: "ready", selection: "jev", candidates: [], judgements: [automatic], belowThreshold: [below] };
it("persists a manual addition outside the limit without changing scores, and undoes it", async () => {
const entry = await inclusionFor(below);
const text = updateInclusions("# Question\n", spec, entry, true);
const selected = await applyInclusions(text, { ...spec, threshold: .7 }, result, blocks);
expect(selected.result.judgements).toHaveLength(2);
expect(selected.result.judgements[1]).toMatchObject({ score: .2, manuallyIncluded: true });
expect(selected.result.belowThreshold).toEqual([]);
expect(selected.missing).toEqual([]);
const undone = await applyInclusions(updateInclusions(text, spec, entry, false), spec, result, blocks);
expect(undone.result.judgements).toEqual([automatic]);
expect(undone.result.belowThreshold).toEqual([below]);
});
it("restores a selection outside the retrieval window and tolerates line shifts and baked IDs", async () => {
const text = updateInclusions("", spec, await inclusionFor(below), true);
const moved = parseMarkdown("source.md", "New first paragraph.\n\nAutomatic passage.\n\nChosen passage. ^saved-id");
const restored = await applyInclusions(text, spec, { status: "empty", candidates: [], judgements: [] }, moved);
expect(restored.result.status).toBe("ready");
expect(restored.result.judgements[0].candidate.lineStart).toBe(5);
expect(restored.result.judgements[0].manuallyIncluded).toBe(true);
});
it("flags missing or ambiguous sources, does not reuse stale text, and permits removal", async () => {
const entry = await inclusionFor(below), text = updateInclusions("", spec, entry, true);
for (const source of ["Changed passage.", "Chosen passage.\n\nChosen passage."]) {
const restored = await applyInclusions(text, spec, result, parseMarkdown("source.md", source));
expect(restored.missing).toHaveLength(1);
expect(restored.result.judgements).toEqual([automatic]);
expect(updateInclusions(text, spec, restored.missing[0].entry, false)).not.toContain("qualitative-query-inclusions");
}
});
it("keeps different questions separate and rejects corrupt persistence", async () => {
const text = updateInclusions("", spec, await inclusionFor(below), true);
expect((await applyInclusions(text, { ...spec, question: "Another question" }, result, blocks)).result.judgements).toEqual([automatic]);
await expect(applyInclusions("<!-- qualitative-query-inclusions: broken -->", spec, result, blocks)).rejects.toThrow("could not be read");
});
it("does not duplicate a manually included passage when it later qualifies automatically", async () => {
const text = updateInclusions("", spec, await inclusionFor(below), true);
const qualified = { ...below, score: .8 };
const restored = await applyInclusions(text, spec, { ...result, judgements: [qualified], belowThreshold: [] }, blocks);
expect(restored.result.judgements).toHaveLength(1);
expect(restored.result.judgements[0]).toMatchObject({ score: .8, manuallyIncluded: true });
});