Project Files
dist / index.js
#!/usr/bin/env node
'use strict';
var sdk = require('@lmstudio/sdk');
var os = require('os');
var path = require('path');
var fs = require('node:fs');
var path$1 = require('node:path');
var uuid = require('uuid');
var initSqlJs = require('sql.js');
var crypto = require('node:crypto');
var chokidar = require('chokidar');
var zod = require('zod');
var node_child_process = require('node:child_process');
var node_util = require('node:util');
var os$1 = require('node:os');
const DEFAULTS={playbookDirectory:path.join(os.homedir(),"Documents","Playbook")};const configSchematics=sdk.createConfigSchematics().field("playbookDirectory","string",{displayName:"Playbook Directory",subtitle:"Absolute path to the directory containing your Markdown knowledge files.",placeholder:path.join(os.homedir(),"Documents","Playbook")},DEFAULTS.playbookDirectory).field("embeddingModel","string",{displayName:"Embedding Model",subtitle:"Name of the embedding model loaded in LM Studio. Leave empty to use BM25-only search."},"text-embedding-nomic-embed-text-v1.5").field("lmStudioBaseUrl","string",{displayName:"LM Studio Base URL",subtitle:"Base URL for the LM Studio local API."},"http://127.0.0.1:1234").field("maxRecallDocuments","numeric",{int:true,min:1,max:20,displayName:"Max Recall Documents",subtitle:"Maximum number of documents returned by a single recall call.",slider:{min:1,max:20,step:1}},5).field("minRecallScore","numeric",{int:true,min:0,max:100,displayName:"Min Recall Score",subtitle:"Minimum relevance score (0–100) for a document to appear in recall results.",slider:{min:0,max:100,step:5}},60).build();
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true });
} else obj[key] = value;
return obj;
}
let SQL=null;async function getSql(){if(!SQL){SQL=await initSqlJs();}return SQL}function hashContent(content){return crypto.createHash("sha256").update(content,"utf8").digest("hex")}class DocIndex{async load(){const sql=await getSql();if(fs.existsSync(this.dbPath)){const data=fs.readFileSync(this.dbPath);this.db=new sql.Database(data);}else {this.db=new sql.Database;}this.ensureSchema();}ensureSchema(){this.db.run(`
CREATE TABLE IF NOT EXISTS docs (
id TEXT PRIMARY KEY,
file_path TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
hash TEXT NOT NULL,
embedding BLOB,
updated_at TEXT NOT NULL
)
`);}save(){if(!this.db)return;const data=this.db.export();const dir=path$1.dirname(this.dbPath);if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(this.dbPath,data);}upsert(doc){const embeddingBlob=doc.embedding?Buffer.from(doc.embedding.buffer):null;this.db.run(`INSERT INTO docs (id, file_path, title, tags, hash, embedding, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
id = excluded.id,
title = excluded.title,
tags = excluded.tags,
hash = excluded.hash,
embedding = excluded.embedding,
updated_at = excluded.updated_at`,[doc.id,doc.filePath,doc.title,JSON.stringify(doc.tags),doc.hash,embeddingBlob,doc.updatedAt]);}deleteByPath(filePath){const stmt=this.db.prepare("DELETE FROM docs WHERE file_path = ?");stmt.run([filePath]);const changed=this.db.getRowsModified()>0;stmt.free();return changed}getAll(){const stmt=this.db.prepare("SELECT id, file_path, title, tags, hash, updated_at FROM docs ORDER BY updated_at DESC");const rows=[];while(stmt.step()){const r=stmt.getAsObject();rows.push({id:r["id"],filePath:r["file_path"],title:r["title"],tags:JSON.parse(r["tags"]),hash:r["hash"],updatedAt:r["updated_at"]});}stmt.free();return rows}getByTitle(title){const stmt=this.db.prepare("SELECT id, file_path, title, tags, hash, updated_at FROM docs WHERE lower(title) = lower(?)");stmt.bind([title]);if(stmt.step()){const r=stmt.getAsObject();stmt.free();return {id:r["id"],filePath:r["file_path"],title:r["title"],tags:JSON.parse(r["tags"]),hash:r["hash"],updatedAt:r["updated_at"]}}stmt.free();return null}getByPath(filePath){const stmt=this.db.prepare("SELECT id, file_path, title, tags, hash, updated_at FROM docs WHERE file_path = ?");stmt.bind([filePath]);if(stmt.step()){const r=stmt.getAsObject();stmt.free();return {id:r["id"],filePath:r["file_path"],title:r["title"],tags:JSON.parse(r["tags"]),hash:r["hash"],updatedAt:r["updated_at"]}}stmt.free();return null}getAllWithEmbeddings(){const stmt=this.db.prepare("SELECT id, file_path, title, tags, hash, embedding, updated_at FROM docs ORDER BY updated_at DESC");const rows=[];while(stmt.step()){const r=stmt.getAsObject();const embRaw=r["embedding"];const embedding=embRaw?new Float32Array(embRaw.buffer):null;rows.push({id:r["id"],filePath:r["file_path"],title:r["title"],tags:JSON.parse(r["tags"]),hash:r["hash"],updatedAt:r["updated_at"],embedding});}stmt.free();return rows}isLoaded(){return this.db!==null}constructor(dataDir){_define_property(this,"db",null);_define_property(this,"dbPath",void 0);this.dbPath=path$1.join(dataDir,"playbook-index.db");}}
const SUPPORTED_EXTENSIONS=new Set([".md",".txt"]);function isSupported(filePath){return SUPPORTED_EXTENSIONS.has(path$1.extname(filePath).toLowerCase())}class FileWatcher{start(){return new Promise(resolve=>{this.watcher=chokidar.watch(this.directory,{persistent:true,ignoreInitial:true,awaitWriteFinish:{stabilityThreshold:1500,pollInterval:100},ignored:filePath=>{const basename=path$1.basename(filePath);if(basename.startsWith("."))return true;if(filePath.includes("node_modules"))return true;const ext=path$1.extname(filePath);if(ext&&!SUPPORTED_EXTENSIONS.has(ext.toLowerCase()))return true;return false}});this.watcher.on("add",async filePath=>{if(!isSupported(filePath))return;try{await this.callbacks.onFileAdded(filePath);}catch(err){console.error(`[FileWatcher] onFileAdded error for ${filePath}:`,err);}}).on("change",async filePath=>{if(!isSupported(filePath))return;try{await this.callbacks.onFileChanged(filePath);}catch(err){console.error(`[FileWatcher] onFileChanged error for ${filePath}:`,err);}}).on("unlink",async filePath=>{if(!isSupported(filePath))return;try{await this.callbacks.onFileDeleted(filePath);}catch(err){console.error(`[FileWatcher] onFileDeleted error for ${filePath}:`,err);}}).on("error",err=>{console.error("[FileWatcher] error:",err);}).on("ready",()=>{console.log("[FileWatcher] ready, watching:",this.directory);this.callbacks.onReady?.();resolve();});})}async stop(){if(this.watcher){await this.watcher.close();this.watcher=null;}}constructor(directory,callbacks){_define_property(this,"watcher",null);_define_property(this,"directory",void 0);_define_property(this,"callbacks",void 0);this.directory=directory;this.callbacks=callbacks;}}
class BM25Index{tokenize(text){return text.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(t=>t.length>=2)}addDocument(id,content){const tokens=this.tokenize(content);const tf=new Map;for(const t of tokens)tf.set(t,(tf.get(t)??0)+1);if(this.docs.has(id))this.removeDocument(id);for(const term of tf.keys()){this.df.set(term,(this.df.get(term)??0)+1);}this.docs.set(id,{id,terms:tf,length:tokens.length});this.recalcAvg();}removeDocument(id){const doc=this.docs.get(id);if(!doc)return;for(const term of doc.terms.keys()){const n=(this.df.get(term)??1)-1;if(n<=0)this.df.delete(term);else this.df.set(term,n);}this.docs.delete(id);this.recalcAvg();}recalcAvg(){if(this.docs.size===0){this.avgLen=0;return}let sum=0;for(const d of this.docs.values())sum+=d.length;this.avgLen=sum/this.docs.size;}idf(term){const n=this.df.get(term)??0;if(n===0)return 0;return Math.log((this.docs.size-n+.5)/(n+.5)+1)}search(query,limit=10){const terms=this.tokenize(query);if(terms.length===0||this.docs.size===0)return [];const scores=new Map;for(const[docId,doc]of this.docs){let score=0;for(const term of terms){const tf=doc.terms.get(term)??0;if(tf===0)continue;const idf=this.idf(term);score+=idf*(tf*(this.k1+1)/(tf+this.k1*(1-this.b+this.b*(doc.length/this.avgLen))));}if(score>0)scores.set(docId,score);}return [...scores.entries()].map(([id,score])=>({id,score})).sort((a,b)=>b.score-a.score).slice(0,limit)}clear(){this.docs.clear();this.df.clear();this.avgLen=0;}get size(){return this.docs.size}constructor(){_define_property(this,"k1",1.5);_define_property(this,"b",.75);_define_property(this,"docs",new Map);_define_property(this,"df",new Map);_define_property(this,"avgLen",0);}}
const RRF_K=60;function cosine(a,b){let dot=0,normA=0,normB=0;for(let i=0;i<a.length;i++){dot+=a[i]*b[i];normA+=a[i]*a[i];normB+=b[i]*b[i];}if(normA===0||normB===0)return 0;return dot/(Math.sqrt(normA)*Math.sqrt(normB))}function rrfScore(rank){return 1/(RRF_K+rank+1)}class DocSearch{setEmbeddingClient(client){this.embeddingClient=client;}rebuild(docs){this.bm25.clear();for(const d of docs){const indexText=`${d.title} ${d.tags.join(" ")} ${d.bodyText}`;this.bm25.addDocument(d.id,indexText);}}addToIndex(id,title,tags,bodyText){const indexText=`${title} ${tags.join(" ")} ${bodyText}`;this.bm25.addDocument(id,indexText);}removeFromIndex(id){this.bm25.removeDocument(id);}async search(query,docs,opts){const filtered=opts.tags&&opts.tags.length>0?docs.filter(d=>opts.tags.every(t=>d.tags.includes(t))):docs;if(filtered.length===0)return [];const bm25Results=this.bm25.search(query,filtered.length);const bm25RankMap=new Map(bm25Results.map((r,i)=>[r.id,i]));let semanticRankMap=new Map;if(this.embeddingClient){try{const queryVec=await this.embeddingClient.embedQuery(query);const withSim=filtered.filter(d=>d.embedding!==null).map(d=>({id:d.id,sim:cosine(queryVec,d.embedding)})).sort((a,b)=>b.sim-a.sim);semanticRankMap=new Map(withSim.map((r,i)=>[r.id,i]));}catch{}}const rrfScores=new Map;const hasSemanticData=semanticRankMap.size>0;for(const doc of filtered){let score=0;const bm25Rank=bm25RankMap.get(doc.id);if(bm25Rank!==undefined){score+=rrfScore(bm25Rank);}if(hasSemanticData){const semRank=semanticRankMap.get(doc.id);if(semRank!==undefined){score+=rrfScore(semRank);}}if(score>0)rrfScores.set(doc.id,score);}const sorted=[...rrfScores.entries()].sort((a,b)=>b[1]-a[1]).slice(0,opts.limit*3);if(sorted.length===0)return [];const maxRrf=sorted[0][1];const normalised=sorted.map(([id,rrf])=>({id,score:Math.round(rrf/maxRrf*100)}));const docMap=new Map(filtered.map(d=>[d.id,d]));return normalised.filter(r=>r.score>=opts.minScore).slice(0,opts.limit).map(r=>{const d=docMap.get(r.id);return {id:d.id,filePath:d.filePath,title:d.title,tags:d.tags,score:r.score}})}constructor(){_define_property(this,"bm25",new BM25Index);_define_property(this,"embeddingClient",null);}}
const SUPPORTED_EXT=new Set([".md",".txt"]);function isSupportedFile(filePath){return SUPPORTED_EXT.has(path$1.extname(filePath).toLowerCase())}function parseFrontmatter(content){const fm=content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);if(!fm)return {title:null,tags:[],body:content};const yaml=fm[1];const body=fm[2]??"";const titleMatch=yaml.match(/^title:\s*(.+)$/m);const tagsMatch=yaml.match(/^tags:\s*\[([^\]]*)\]/m);const title=titleMatch?titleMatch[1].trim().replace(/^["']|["']$/g,""):null;const tags=tagsMatch?tagsMatch[1].split(",").map(t=>t.trim().replace(/^["']|["']$/g,"")).filter(Boolean):[];return {title,tags,body}}class IndexManager{async initialize(){await this.docIndex.load();if(!fs.existsSync(this.config.playbookDirectory)){fs.mkdirSync(this.config.playbookDirectory,{recursive:true});}await this.syncDirectory();await this.rebuildBM25();this.fileWatcher=new FileWatcher(this.config.playbookDirectory,{onFileAdded:fp=>this.handleFileChange(fp),onFileChanged:fp=>this.handleFileChange(fp),onFileDeleted:fp=>this.handleFileDeleted(fp),onReady:()=>{this.ready=true;}});await this.fileWatcher.start();this.ready=true;console.log("[IndexManager] Ready.");}async syncDirectory(){const dir=this.config.playbookDirectory;const onDisk=new Set;for(const entry of fs.readdirSync(dir,{withFileTypes:true})){if(!entry.isFile())continue;const fp=path$1.join(dir,entry.name);if(!isSupportedFile(fp))continue;onDisk.add(fp);const content=fs.readFileSync(fp,"utf8");const hash=hashContent(content);const existing=this.docIndex.getByPath(fp);if(!existing||existing.hash!==hash){await this.indexFile(fp,content,hash);}}for(const meta of this.docIndex.getAll()){if(!onDisk.has(meta.filePath)){this.docIndex.deleteByPath(meta.filePath);}}this.docIndex.save();}async rebuildBM25(){const all=this.docIndex.getAll();const entries=[];for(const meta of all){if(!fs.existsSync(meta.filePath))continue;try{const body=parseFrontmatter(fs.readFileSync(meta.filePath,"utf8")).body;entries.push({id:meta.id,title:meta.title,tags:meta.tags,bodyText:body});}catch{}}this.docSearch.rebuild(entries);}async indexFile(filePath,content,hash){const{title:fmTitle,tags,body}=parseFrontmatter(content);const title=fmTitle??path$1.basename(filePath,path$1.extname(filePath));let embedding=null;if(this.embeddingClient){try{const passages=[`${title}
${body}`];[embedding]=await this.embeddingClient.embedPassages(passages);}catch(err){console.warn("[IndexManager] Embedding failed for",filePath,err);}}const existing=this.docIndex.getByPath(filePath);const record={id:existing?.id??uuid.v4(),filePath,title,tags,hash,embedding,updatedAt:new Date().toISOString()};this.docIndex.upsert(record);this.docSearch.addToIndex(record.id,title,tags,body);this.docIndex.save();}async handleFileChange(filePath){if(!isSupportedFile(filePath))return;try{const content=fs.readFileSync(filePath,"utf8");const hash=hashContent(content);const existing=this.docIndex.getByPath(filePath);if(existing?.hash===hash)return;await this.indexFile(filePath,content,hash);}catch(err){console.error("[IndexManager] handleFileChange error:",filePath,err);}}async handleFileDeleted(filePath){const meta=this.docIndex.getByPath(filePath);if(meta){this.docSearch.removeFromIndex(meta.id);this.docIndex.deleteByPath(filePath);this.docIndex.save();}}setEmbeddingClient(client){this.embeddingClient=client;this.docSearch.setEmbeddingClient(client);}getConfig(){return this.config}getDocumentCount(){return this.docIndex.getAll().length}async search(query,tags){const docsWithEmbeddings=this.docIndex.getAllWithEmbeddings();return this.docSearch.search(query,docsWithEmbeddings,{limit:this.config.maxRecallDocuments,minScore:this.config.minRecallScore,tags})}readSource(filePath){return fs.readFileSync(filePath,"utf8")}async writeFile(filePath,content){const dir=path$1.dirname(filePath);if(!fs.existsSync(dir))fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(filePath,content,"utf8");await this.handleFileChange(filePath);}async editFile(filePath,oldText,newText){const content=fs.readFileSync(filePath,"utf8");const count=content.split(oldText).length-1;if(count===0)throw new Error("oldText not found in file.");if(count>1)throw new Error("oldText matches multiple locations; make it more specific.");let result=content.replace(oldText,newText);result=result.replace(/^(updated:\s*")[^"]*(")/m,`$1${new Date().toISOString()}$2`);fs.writeFileSync(filePath,result,"utf8");await this.handleFileChange(filePath);}async deleteFile(filePath){if(fs.existsSync(filePath))fs.unlinkSync(filePath);await this.handleFileDeleted(filePath);}getPlaybookDirectory(){return this.config.playbookDirectory}isReady(){return this.ready}async shutdown(){await this.fileWatcher?.stop();}constructor(config,dataDir,baseUrl){_define_property(this,"docIndex",void 0);_define_property(this,"docSearch",void 0);_define_property(this,"fileWatcher",null);_define_property(this,"embeddingClient",null);_define_property(this,"config",void 0);_define_property(this,"baseUrl",void 0);_define_property(this,"ready",false);this.config=config;this.baseUrl=baseUrl;this.docIndex=new DocIndex(dataDir);this.docSearch=new DocSearch;}}
class EmbeddingClient{async embedPassages(texts){return this.embed(texts.map(t=>`passage: ${t}`))}async embedQuery(text){const[vec]=await this.embed([`query: ${text}`]);return vec}async embed(texts){if(texts.length===0)return [];const controller=new AbortController;const tid=setTimeout(()=>controller.abort(),this.config.timeout);try{const response=await fetch(`${this.config.baseUrl}/v1/embeddings`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model:this.config.model,input:texts}),signal:controller.signal});if(!response.ok){const body=await response.text();throw new Error(`LM Studio /v1/embeddings error ${response.status}: ${body}`)}const data=await response.json();return data.data.sort((a,b)=>a.index-b.index).map(item=>new Float32Array(item.embedding))}catch(err){if(err instanceof Error&&err.name==="AbortError"){throw new Error(`Embedding request timed out (${this.config.timeout}ms)`)}throw err}finally{clearTimeout(tid);}}getModelName(){return this.config.model}async getDimension(){const[vec]=await this.embed(["test"]);return vec.length}constructor(config){_define_property(this,"config",void 0);this.config={timeout:3e4,...config};}}
const execAsync=node_util.promisify(node_child_process.exec);async function findLmsCli(){const TIMEOUT=3e3;const candidates=[path$1.join(os$1.homedir(),".lmstudio","bin","lms"),"/usr/local/bin/lms","/opt/homebrew/bin/lms"];for(const c of candidates){try{await execAsync(`"${c}" -h`,{timeout:TIMEOUT});return c}catch{}}try{const{stdout}=await execAsync("which lms",{timeout:TIMEOUT});const p=stdout.trim();if(p)return p}catch{}return null}async function isModelInstalled(lmsCli,modelId){try{const{stdout}=await execAsync(`"${lmsCli}" ls --json`,{timeout:1e4});const models=JSON.parse(stdout);if(!Array.isArray(models))return {installed:false};const q=modelId.toLowerCase();const found=models.find(m=>{const key=String(m?.modelKey??"").toLowerCase();const name=String(m?.displayName??"").toLowerCase();return key.includes(q)||name.includes(q)||key.includes("embed")&&name.includes(q.replace("text-embedding-",""))});return found?{installed:true,modelKey:found.modelKey}:{installed:false}}catch{return {installed:false}}}async function getLoadedModels(baseUrl){try{const response=await fetch(`${baseUrl}/v1/models`,{method:"GET",signal:AbortSignal.timeout(2e3)});if(!response.ok)return {embeddingModels:[],allModels:[],reachable:true};const data=await response.json();const models=data.data??[];const allModels=models.map(m=>m.id);const embeddingModels=models.filter(m=>m.type==="embedding"||m.id.toLowerCase().includes("embed")).map(m=>m.id);return {embeddingModels,allModels,reachable:true}}catch{return {embeddingModels:[],allModels:[],reachable:false}}}async function loadModelViaCli(lmsCli,modelKey,opts){const gpu=opts.gpuMode==="off"?"--gpu off":opts.gpuMode==="max"?"--gpu max":`--gpu ${opts.gpuMode??"max"}`;const parts=[`"${lmsCli}"`,"load",modelKey,gpu];if(opts.ttlSeconds)parts.push(`--ttl ${opts.ttlSeconds}`);parts.push(`--identifier "playbook-embedding"`);try{await execAsync(parts.join(" "),{timeout:12e4});return {ok:true}}catch(e){return {ok:false,error:e?.stderr??e?.message??String(e)}}}async function checkEmbeddingCapability(config){const{modelId,baseUrl,autoLoad=false,gpuMode="max",ttlSeconds}=config;const{embeddingModels,reachable}=await getLoadedModels(baseUrl);if(!reachable){return {ready:false,modelId,isLoaded:false,disableSemantic:true,userMessage:`**Semantic Search Unavailable:**
Cannot reach LM Studio API at \`${baseUrl}\`.
`+`**Fallback:** Using keyword-only search.
`+`**To enable semantic search:**
1. Start LM Studio
2. Load an embedding model`,messageSeverity:"warning",error:"LM Studio API not reachable"}}const norm=modelId.toLowerCase();if(embeddingModels.some(m=>m.toLowerCase()===norm)){return {ready:true,modelId,isLoaded:true,availableEmbeddingModels:embeddingModels}}const similar=embeddingModels.find(m=>m.toLowerCase().includes(norm.replace("text-embedding-","")));if(similar){return {ready:true,modelId:similar,isLoaded:true,availableEmbeddingModels:embeddingModels,userMessage:`Using loaded embedding model: ${similar}
(Configured: ${modelId})`,messageSeverity:"info"}}if(embeddingModels.length>0){return {ready:true,modelId:embeddingModels[0],isLoaded:true,availableEmbeddingModels:embeddingModels,userMessage:`**Semantic Search:** Configured model \`${modelId}\` is not loaded.
`+`Using available model: \`${embeddingModels[0]}\``,messageSeverity:"info"}}const lmsCli=await findLmsCli();if(!lmsCli){return {ready:false,modelId,isLoaded:false,disableSemantic:true,userMessage:`**Semantic Search Unavailable:**
No embedding model is loaded and \`lms\` CLI not found.
`+`**Fallback:** Using keyword-only search.
`+`**To enable:** Load an embedding model in LM Studio.`,messageSeverity:"warning"}}const{installed,modelKey}=await isModelInstalled(lmsCli,modelId);if(!installed){return {ready:false,modelId,isLoaded:false,isInstalled:false,disableSemantic:true,userMessage:`**Semantic Search Unavailable:**
Model \`${modelId}\` is not installed locally.
`+`**Fallback:** Using keyword-only search.
`+`**To enable:** Download an embedding model in LM Studio → Discover.`,messageSeverity:"warning"}}if(autoLoad&&modelKey){const res=await loadModelViaCli(lmsCli,modelKey,{gpuMode,ttlSeconds});if(res.ok){const{embeddingModels:fresh}=await getLoadedModels(baseUrl);if(fresh.length>0){return {ready:true,modelId:fresh[0],isLoaded:true,isInstalled:true,wasAutoLoaded:true,availableEmbeddingModels:fresh,userMessage:`**Semantic Search Enabled:** Auto-loaded \`${fresh[0]}\``,messageSeverity:"info"}}}}return {ready:false,modelId,isLoaded:false,isInstalled:true,disableSemantic:true,userMessage:`**Semantic Search Unavailable:**
Model \`${modelKey??modelId}\` is installed but not loaded.
`+`**Fallback:** Using keyword-only search.
`+`**To enable:**
\`\`\`bash
lms load ${modelKey??modelId}
\`\`\``,messageSeverity:"warning"}}
function getModuleDir(){try{return __dirname}catch{return process.cwd()}}function readJsonFile(filePath){try{return JSON.parse(fs.readFileSync(filePath,"utf8"))}catch{return null}}function findUpwards(startDir,fileName,maxDepth=8){let dir=startDir;for(let i=0;i<maxDepth;i++){const candidate=path$1.join(dir,fileName);if(fs.existsSync(candidate))return candidate;const parent=path$1.dirname(dir);if(parent===dir)break;dir=parent;}return null}function getPluginMeta(){const moduleDir=getModuleDir();const packageJson=readJsonFile(findUpwards(moduleDir,"package.json")??"");const manifestJson=readJsonFile(findUpwards(moduleDir,"manifest.json")??"");const version=String(packageJson?.version??"unknown");const owner=String(manifestJson?.owner??"").trim();const name=String(manifestJson?.name??"").trim();const revisionsUrl=owner&&name?`https://raw.githubusercontent.com/${owner}/${name}-docs/main/docs/CHANGELOG.md`:"https://raw.githubusercontent.com";const pluginIdentifier=owner&&name?`${owner}/${name}`:name||"unknown";return {version,owner,name,revisionsUrl,pluginIdentifier}}function formatToolMetaBlock(meta=getPluginMeta()){return `Plugin-Identifier: ${meta.pluginIdentifier}
Plugin version: ${meta.version}`}
let embeddingClient=null;let cachedCapabilityResult=null;let lastCapabilityCheckMs=0;const CAPABILITY_CHECK_INTERVAL_MS=3e4;function createRecallTool(_ctl,index){return sdk.tool({name:"recall",description:`Search the playbook — the agent's persistent Markdown knowledge base.
Returns document metadata only (title, filename, tags, score), never file contents.
Use this tool:
- At the start of complex or multi-step tasks
- Before advising on a topic you may have notes about
- When the user references something familiar but you're not certain about
- To check whether a note already exists before writing a new one
Returns:
- Ranked list of matching documents with title, filename, tags, and relevance score (0–100)
- Documents below the configured minRecallScore are excluded
Examples:
- "project planning strategies" — finds notes about planning approaches
- "API authentication" — finds any notes on auth topics
- "Abendroutine" — also finds "evening routine" when semantic search is enabled
Follow up with read to retrieve the full text of any matching document.
${formatToolMetaBlock()}`,parameters:{query:zod.z.string().describe("Natural-language search query."),tags:zod.z.array(zod.z.string()).optional().describe("Optional list of tags to filter by. Only documents that have ALL listed tags will be returned.")},implementation:async(args,ctx)=>{const config=index.getConfig();const now=Date.now();const needsCheck=!cachedCapabilityResult||now-lastCapabilityCheckMs>CAPABILITY_CHECK_INTERVAL_MS||embeddingClient?.getModelName()!==config.embeddingModel;if(needsCheck){ctx.status("Checking embedding model availability...");cachedCapabilityResult=await checkEmbeddingCapability({modelId:config.embeddingModel,baseUrl:config.lmStudioBaseUrl});lastCapabilityCheckMs=Date.now();if(cachedCapabilityResult.ready){embeddingClient=new EmbeddingClient({baseUrl:config.lmStudioBaseUrl,model:cachedCapabilityResult.modelId});index.setEmbeddingClient(embeddingClient);}else {embeddingClient=null;index.setEmbeddingClient(null);}}const docCount=index.getDocumentCount();ctx.status(`Searching ${docCount} documents...`);const results=await index.search(args.query,args.tags);const lines=[];if(results.length===0){lines.push("No documents found matching your query.");}else {lines.push(...results.map(r=>{const filename=path$1.basename(r.filePath);const tagStr=r.tags.length>0?` [${r.tags.join(", ")}]`:"";return `- **${r.title}** (${filename})${tagStr} — score: ${r.score}`}));}if(cachedCapabilityResult&&!cachedCapabilityResult.ready&&cachedCapabilityResult.userMessage){lines.push("",cachedCapabilityResult.userMessage);}else if(cachedCapabilityResult?.userMessage&&cachedCapabilityResult.messageSeverity==="info"){lines.push("",`_${cachedCapabilityResult.userMessage}_`);}return lines.join("\n")}})}
function createReadTool(_ctl,index){return sdk.tool({name:"read",description:`Read the full content of a playbook document.
Use this tool after recall has returned a relevant hit.
Use this tool when:
- recall returned one or more matching documents and you need the actual text
- The user asks you to quote or expand on something from the playbook
Returns:
- The complete Markdown source including YAML frontmatter (title, tags, timestamps)
- Full body text without any truncation
Example:
- filename: "api-auth-notes.md" — returns the entire file
${formatToolMetaBlock()}`,parameters:{filename:zod.z.string().describe("The filename of the document to read (e.g. 'my-note.md'). Use the filename returned by recall.")},implementation:async args=>{const filePath=path$1.join(index.getPlaybookDirectory(),args.filename);let content;try{content=index.readSource(filePath);}catch{return `Error: File not found — "${args.filename}". Use recall to find available documents.`}return content}})}
function slugify(title){return title.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^\w\s-]/g,"").trim().replace(/[\s_]+/g,"-").replace(/-+/g,"-").slice(0,80)}function buildFrontmatter(title,tags,now,existingCreated){const created=existingCreated??now;const tagList=tags.map(t=>`"${t}"`).join(", ");return `---
title: "${title}"
tags: [${tagList}]
created: "${created}"
updated: "${now}"
---
`}function createMemorizeTool(_ctl,index){return sdk.tool({name:"memorize",description:`Write a new document to the playbook, or completely replace an existing one.
The filename is derived automatically from the title (slugified).
YAML frontmatter (title, tags, created, updated) is generated and maintained automatically.
Use this tool when:
- The user explicitly asks you to remember something
- You derive or discover information useful for future conversations
- Summarising the outcome of a completed task
- Creating a structured reference note (how-tos, decisions, preferences)
Returns:
- Confirmation with title and filename
Examples:
- title: "API Auth Strategy", tags: ["api", "auth"], body: "Use OAuth2 for..."
- title: "User Preferences", tags: ["preferences"], body: "Prefers concise answers..."
${formatToolMetaBlock()}`,parameters:{title:zod.z.string().describe("Document title (used as frontmatter title and to derive the filename)."),tags:zod.z.array(zod.z.string()).default([]).describe("List of tags to categorise this document."),body:zod.z.string().describe("The main content of the document (plain Markdown, without frontmatter).")},implementation:async args=>{const slug=slugify(args.title);if(!slug)return "Error: Title must contain at least one word character.";const filename=`${slug}.md`;const filePath=path$1.join(index.getPlaybookDirectory(),filename);const now=new Date().toISOString();let existingCreated;if(fs.existsSync(filePath)){const existing=fs.readFileSync(filePath,"utf8");const m=existing.match(/^created:\s*"?([^"\n]+)"?/m);existingCreated=m?m[1].trim():undefined;}const frontmatter=buildFrontmatter(args.title,args.tags,now,existingCreated);const content=`${frontmatter}${args.body}`;await index.writeFile(filePath,content);return `Saved "${args.title}" → ${filename}`}})}
function createRewriteTool(_ctl,index){return sdk.tool({name:"rewrite",description:`Update part of an existing playbook document with a surgical text replacement.
oldText must appear exactly once in the file.
Always call read first to see the current content.
Use this tool when:
- Correcting or extending a specific section of a note
- Updating a value without replacing the whole document
- Applying a targeted change where context around it must be preserved
Returns:
- Confirmation on success
- An error if oldText is not found or matches more than once (make it more specific)
Examples:
- oldText: "Use OAuth1", newText: "Use OAuth2" — updates just that line
- oldText: "## Status
In progress", newText: "## Status
Completed" — uses surrounding context for uniqueness
${formatToolMetaBlock()}`,parameters:{filename:zod.z.string().describe("Filename of the document to edit (e.g. 'my-note.md')."),oldText:zod.z.string().describe("The exact text to replace. Must appear exactly once in the file."),newText:zod.z.string().describe("The text to substitute in place of oldText.")},implementation:async args=>{const filePath=path$1.join(index.getPlaybookDirectory(),args.filename);if(!fs.existsSync(filePath)){return `Error: File not found — "${args.filename}". Use recall to find available documents.`}try{await index.editFile(filePath,args.oldText,args.newText);return `Edited "${args.filename}" successfully.`}catch(err){return `Error: ${err instanceof Error?err.message:String(err)}`}}})}
function createForgetTool(_ctl,index){return sdk.tool({name:"forget",description:`Permanently delete a playbook document and remove it from the search index.
This action cannot be undone. Always call recall first to confirm the exact filename.
Use this tool when:
- The user explicitly asks to remove a note
- A document is outdated and should no longer appear in search results
Returns:
- Confirmation with the deleted filename
- An error if the file does not exist
${formatToolMetaBlock()}`,parameters:{filename:zod.z.string().describe("Filename of the document to delete (e.g. 'my-note.md').")},implementation:async args=>{const filePath=path$1.join(index.getPlaybookDirectory(),args.filename);if(!fs.existsSync(filePath)){return `Error: File not found — "${args.filename}". Nothing was deleted.`}try{await index.deleteFile(filePath);return `Deleted "${args.filename}".`}catch(err){return `Error: ${err instanceof Error?err.message:String(err)}`}}})}
const toolsProvider=async ctl=>{const config=ctl.getGlobalPluginConfig(configSchematics);const playbookDirectory=config.get("playbookDirectory");const embeddingModel=config.get("embeddingModel");const lmStudioBaseUrl=config.get("lmStudioBaseUrl");const maxRecallDocuments=config.get("maxRecallDocuments");const minRecallScore=config.get("minRecallScore");const dataDir=path$1.join(process.cwd(),"playbook-db");const resolvedConfig={playbookDirectory,embeddingModel,lmStudioBaseUrl,maxRecallDocuments,minRecallScore};const index=new IndexManager(resolvedConfig,dataDir,lmStudioBaseUrl);if(playbookDirectory){index.initialize().catch(err=>{console.error("[playbook] IndexManager initialization failed:",err);});}else {console.warn("[playbook] playbookDirectory not configured — tools are registered but inoperative.");}return [createRecallTool(ctl,index),createReadTool(ctl,index),createMemorizeTool(ctl,index),createRewriteTool(ctl,index),createForgetTool(ctl,index)]};
async function main(context){context.withGlobalConfigSchematics(configSchematics).withToolsProvider(toolsProvider);}
exports.main = main;