Forked from ceveyne/playbook
Forked from ceveyne/playbook
dist / index.js
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={playbookDirectories:[path.join(os.homedir(),"Documents","Playbook")]};const configSchematics=sdk.createConfigSchematics().field("playbookDirectories","stringArray",{displayName:"Playbook Directories",subtitle:"Absolute paths to directories containing your Markdown/skill files. The first directory is the write target for memorize; all directories are searched and readable."},[...DEFAULTS.playbookDirectories]).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,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
hash TEXT NOT NULL,
embedding BLOB,
updated_at TEXT NOT NULL
)
`);const columns=this.db.exec(`PRAGMA table_info(docs)`);const hasDescription=columns[0]?.values.some(row=>row[1]==="description");if(!hasDescription){this.db.run(`ALTER TABLE docs ADD COLUMN description TEXT NOT NULL DEFAULT ''`);}}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, description, tags, hash, embedding, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
id = excluded.id,
title = excluded.title,
description = excluded.description,
tags = excluded.tags,
hash = excluded.hash,
embedding = excluded.embedding,
updated_at = excluded.updated_at`,[doc.id,doc.filePath,doc.title,doc.description,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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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.directories,{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.directories.join(", "));this.callbacks.onReady?.();resolve();});})}async stop(){if(this.watcher){await this.watcher.close();this.watcher=null;}}constructor(directories,callbacks){_define_property(this,"watcher",null);_define_property(this,"directories",void 0);_define_property(this,"callbacks",void 0);this.directories=directories;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.description} ${d.tags.join(" ")} ${d.bodyText}`;this.bm25.addDocument(d.id,indexText);}}addToIndex(id,title,description,tags,bodyText){const indexText=`${title} ${description} ${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,description:d.description,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 isIndexableFile(filePath,playbookDirs){if(!isSupportedFile(filePath))return false;const root=findOwningRoot(filePath,playbookDirs);if(!root)return false;const rel=path$1.relative(root,filePath);const parts=rel.split(path$1.sep);if(parts.length===1)return true;return parts.length===2&&parts[1].toLowerCase()==="skill.md"}function findOwningRoot(filePath,playbookDirs){for(const dir of playbookDirs){const rel=path$1.relative(dir,filePath);if(rel&&!rel.startsWith("..")&&!path$1.isAbsolute(rel))return dir}return null}function parseFrontmatter(content){const fm=content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);if(!fm)return {title:null,name:null,description:null,tags:[],body:content};const yaml=fm[1];const body=fm[2]??"";const titleMatch=yaml.match(/^title:\s*(.+)$/m);const nameMatch=yaml.match(/^name:\s*(.+)$/m);const descriptionMatch=yaml.match(/^description:\s*(.+)$/m);const tagsMatch=yaml.match(/^tags:\s*\[([^\]]*)\]/m);const unquote=s=>s.trim().replace(/^["']|["']$/g,"");const title=titleMatch?unquote(titleMatch[1]):null;const name=nameMatch?unquote(nameMatch[1]):null;const description=descriptionMatch?unquote(descriptionMatch[1]):null;const tags=tagsMatch?tagsMatch[1].split(",").map(t=>t.trim().replace(/^["']|["']$/g,"")).filter(Boolean):[];return {title,name,description,tags,body}}class IndexManager{async initialize(){await this.docIndex.load();for(const dir of this.config.playbookDirectories){if(!fs.existsSync(dir)){fs.mkdirSync(dir,{recursive:true});}}await this.syncDirectory();await this.rebuildBM25();this.fileWatcher=new FileWatcher(this.config.playbookDirectories,{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 onDisk=new Set;for(const dir of this.config.playbookDirectories){for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const fp=path$1.join(dir,entry.name);if(entry.isFile()){if(!isSupportedFile(fp))continue;onDisk.add(fp);await this.syncOneFile(fp);continue}if(entry.isDirectory()){if(entry.name.startsWith(".")||entry.name==="node_modules")continue;const skillFile=path$1.join(fp,"SKILL.md");if(fs.existsSync(skillFile)&&fs.statSync(skillFile).isFile()){onDisk.add(skillFile);await this.syncOneFile(skillFile);}}}}for(const meta of this.docIndex.getAll()){if(!onDisk.has(meta.filePath)){this.docIndex.deleteByPath(meta.filePath);}}this.docIndex.save();}async syncOneFile(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);}}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,description:meta.description,tags:meta.tags,bodyText:body});}catch{}}this.docSearch.rebuild(entries);}async indexFile(filePath,content,hash){const{title:fmTitle,name:fmName,description:fmDescription,tags,body}=parseFrontmatter(content);const base=path$1.basename(filePath,path$1.extname(filePath));const isSkillEntry=base.toLowerCase()==="skill";const title=fmTitle??fmName??(isSkillEntry?path$1.basename(path$1.dirname(filePath)):base);const description=fmDescription??"";let embedding=null;if(this.embeddingClient){try{const passages=[`${title}
${description}
${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,description,tags,hash,embedding,updatedAt:new Date().toISOString()};this.docIndex.upsert(record);this.docSearch.addToIndex(record.id,title,description,tags,body);this.docIndex.save();}async handleFileChange(filePath){if(!isIndexableFile(filePath,this.config.playbookDirectories))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")}resolveReadPath(filename){const requested=filename.trim();if(!requested)return null;if(path$1.isAbsolute(requested)){return fs.existsSync(requested)?requested:null}const normalized=requested.replace(/\\/g,"/");for(const dir of this.config.playbookDirectories){const candidate=path$1.resolve(dir,normalized);const rel=path$1.relative(dir,candidate);if(rel.startsWith("..")||path$1.isAbsolute(rel))continue;if(fs.existsSync(candidate))return candidate}return null}toDisplayPath(absoluteFilePath){const root=findOwningRoot(absoluteFilePath,this.config.playbookDirectories);return root?path$1.relative(root,absoluteFilePath):absoluteFilePath}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.playbookDirectories[0]}getPlaybookDirectories(){return this.config.playbookDirectories}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 truncate(text,maxLength){return text.length>maxLength?`${text.slice(0,maxLength)}…`:text}function createRecallTool(_ctl,index){return sdk.tool({name:"recall",description:`Search the playbook — the agent's persistent Markdown knowledge base of notes and skills.
Returns document metadata only (title, filename, tags, description, score), never full file contents.
Skill bundles (folders containing a SKILL.md with \`name\`/\`description\` frontmatter) are returned exactly like notes — one hit per bundle, with its description shown instead of a title-only summary.
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 or skill already exists before writing a new one
Returns:
- Ranked list of matching documents with title, filename (relative path — may include a skill folder, e.g. "my-skill/SKILL.md"), tags, description, 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 (for a skill, read the SKILL.md hit first — it links to any companion files under its folder).
${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=index.toDisplayPath(r.filePath);const tagStr=r.tags.length>0?` [${r.tags.join(", ")}]`:"";const desc=r.description?` — ${truncate(r.description,300)}`:"";return `- **${r.title}** (${filename})${tagStr}${desc} — 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 — a note or a skill's SKILL.md.
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
- A SKILL.md you already read links to a companion file (e.g. "tools/my-tool.md") and you need its content too
Returns:
- The complete Markdown source including YAML frontmatter (title/tags or name/description, timestamps)
- Full body text without any truncation
Examples:
- filename: "api-auth-notes.md" — returns a flat note
- filename: "pdf-report/SKILL.md" — returns a skill bundle's entry point
- filename: "pdf-report/tools/render_table.md" — returns a companion file linked from that SKILL.md
${formatToolMetaBlock()}`,parameters:{filename:zod.z.string().describe("The filename of the document to read (e.g. 'my-note.md' or 'my-skill/SKILL.md'). Use the filename returned by recall, or a relative path linked from a SKILL.md you already read.")},implementation:async args=>{const filePath=index.resolveReadPath(args.filename);if(!filePath){return `Error: File not found — "${args.filename}". Use recall to find available documents.`}try{return index.readSource(filePath)}catch{return `Error: File not found — "${args.filename}". Use recall to find available documents.`}}})}
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 sanitizeRelativeFile(file){if(!file||path$1.isAbsolute(file))return null;const normalized=path$1.normalize(file);if(normalized==="."||normalized.split(path$1.sep).some(part=>part===".."))return null;const ext=path$1.extname(normalized).toLowerCase();if(ext!==".md"&&ext!==".txt")return null;return normalized}function readExistingCreated(filePath){if(!fs.existsSync(filePath))return undefined;const existing=fs.readFileSync(filePath,"utf8");const m=existing.match(/^created:\s*"?([^"\n]+)"?/m);return m?m[1].trim():undefined}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 buildSkillFrontmatter(name,description,now,existingCreated){const created=existingCreated??now;const esc=s=>s.replace(/"/g,'\\"');return `---
name: "${esc(name)}"
description: "${esc(description)}"
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. Supports plain notes and SKILL.md skill bundles.
Three modes, chosen by which parameters you set:
- Note: set \`title\` (+ optional \`tags\`). Writes a flat file \`<slug>.md\` at the playbook root with title/tags/created/updated frontmatter.
- Skill: set \`name\` + \`description\`. Writes \`<slug>/SKILL.md\` with name/description/created/updated frontmatter — the entry point recall/read will surface.
- Companion file: set \`name\` + \`file\` (e.g. "tools/my-tool.md"). Writes \`<slug>/<file>\` as plain Markdown with no frontmatter — link to it from SKILL.md's body with a relative path. Not indexed by recall; fetch it directly with read once its path is known.
The filename/folder name is always derived by slugifying \`title\` or \`name\`.
When multiple Playbook Directories are configured, new documents are always written to the first (primary) one; the others are read-only search/read sources.
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)
- Authoring or updating a reusable skill bundle for future sessions
Returns:
- Confirmation with the resulting relative path
- An error if required parameters for the selected mode are missing
Examples:
- title: "API Auth Strategy", tags: ["api", "auth"], body: "Use OAuth2 for..." → note
- name: "pdf-report", description: "Generate paginated PDF reports from tabular data.", body: "# PDF Report Skill
..." → pdf-report/SKILL.md
- name: "pdf-report", file: "tools/render_table.md", body: "# render_table
..." → pdf-report/tools/render_table.md
${formatToolMetaBlock()}`,parameters:{title:zod.z.string().optional().describe("Document title for a plain note. Required unless `name` is set (skill mode)."),name:zod.z.string().optional().describe("Skill identifier (kebab-case recommended). When set, targets a skill bundle folder instead of a flat note. Combine with `description` to write SKILL.md, or with `file` to write a companion file."),file:zod.z.string().optional().describe('Relative path inside the skill bundle for a companion file, e.g. "tools/my-tool.md". Requires `name`. Written as-is with no frontmatter.'),description:zod.z.string().optional().describe("One-paragraph discovery description. Required when writing SKILL.md (name set, file omitted) — this is what recall shows before the full file is read."),tags:zod.z.array(zod.z.string()).default([]).describe("List of tags to categorise this document. Only used for plain notes."),body:zod.z.string().describe("The main content of the document (plain Markdown, without frontmatter).")},implementation:async args=>{const now=new Date().toISOString();if(args.name){const slug=slugify(args.name);if(!slug)return "Error: name must contain at least one word character.";const skillDir=path$1.join(index.getPlaybookDirectory(),slug);if(args.file){const rel=sanitizeRelativeFile(args.file);if(!rel)return `Error: invalid file "${args.file}" — must be a relative .md/.txt path without "..".`;const filePath=path$1.join(skillDir,rel);await index.writeFile(filePath,args.body);return `Saved companion file → ${slug}/${rel}`}if(!args.description){return "Error: description is required to write a SKILL.md (or pass `file` to add a companion file instead)."}const filePath=path$1.join(skillDir,"SKILL.md");const existingCreated=readExistingCreated(filePath);const frontmatter=buildSkillFrontmatter(args.name,args.description,now,existingCreated);await index.writeFile(filePath,`${frontmatter}${args.body}`);return `Saved skill "${args.name}" → ${slug}/SKILL.md`}if(!args.title){return "Error: title is required for a plain note (or use `name`/`description` to write a skill)."}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 existingCreated=readExistingCreated(filePath);const frontmatter=buildFrontmatter(args.title,args.tags,now,existingCreated);await index.writeFile(filePath,`${frontmatter}${args.body}`);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' or 'my-skill/SKILL.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=index.resolveReadPath(args.filename);if(!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' or 'my-skill/SKILL.md'). Deleting a skill's SKILL.md does not remove its companion files.")},implementation:async args=>{const filePath=index.resolveReadPath(args.filename);if(!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 playbookDirectories=config.get("playbookDirectories");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={playbookDirectories,embeddingModel,lmStudioBaseUrl,maxRecallDocuments,minRecallScore};const index=new IndexManager(resolvedConfig,dataDir,lmStudioBaseUrl);if(playbookDirectories.length>0){index.initialize().catch(err=>{console.error("[playbook] IndexManager initialization failed:",err);});}else {console.warn("[playbook] playbookDirectories 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;
#!/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={playbookDirectories:[path.join(os.homedir(),"Documents","Playbook")]};const configSchematics=sdk.createConfigSchematics().field("playbookDirectories","stringArray",{displayName:"Playbook Directories",subtitle:"Absolute paths to directories containing your Markdown/skill files. The first directory is the write target for memorize; all directories are searched and readable."},[...DEFAULTS.playbookDirectories]).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,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
hash TEXT NOT NULL,
embedding BLOB,
updated_at TEXT NOT NULL
)
`);const columns=this.db.exec(`PRAGMA table_info(docs)`);const hasDescription=columns[0]?.values.some(row=>row[1]==="description");if(!hasDescription){this.db.run(`ALTER TABLE docs ADD COLUMN description TEXT NOT NULL DEFAULT ''`);}}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, description, tags, hash, embedding, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(file_path) DO UPDATE SET
id = excluded.id,
title = excluded.title,
description = excluded.description,
tags = excluded.tags,
hash = excluded.hash,
embedding = excluded.embedding,
updated_at = excluded.updated_at`,[doc.id,doc.filePath,doc.title,doc.description,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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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, description, 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"],description:r["description"],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.directories,{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.directories.join(", "));this.callbacks.onReady?.();resolve();});})}async stop(){if(this.watcher){await this.watcher.close();this.watcher=null;}}constructor(directories,callbacks){_define_property(this,"watcher",null);_define_property(this,"directories",void 0);_define_property(this,"callbacks",void 0);this.directories=directories;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.description} ${d.tags.join(" ")} ${d.bodyText}`;this.bm25.addDocument(d.id,indexText);}}addToIndex(id,title,description,tags,bodyText){const indexText=`${title} ${description} ${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,description:d.description,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 isIndexableFile(filePath,playbookDirs){if(!isSupportedFile(filePath))return false;const root=findOwningRoot(filePath,playbookDirs);if(!root)return false;const rel=path$1.relative(root,filePath);const parts=rel.split(path$1.sep);if(parts.length===1)return true;return parts.length===2&&parts[1].toLowerCase()==="skill.md"}function findOwningRoot(filePath,playbookDirs){for(const dir of playbookDirs){const rel=path$1.relative(dir,filePath);if(rel&&!rel.startsWith("..")&&!path$1.isAbsolute(rel))return dir}return null}function parseFrontmatter(content){const fm=content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);if(!fm)return {title:null,name:null,description:null,tags:[],body:content};const yaml=fm[1];const body=fm[2]??"";const titleMatch=yaml.match(/^title:\s*(.+)$/m);const nameMatch=yaml.match(/^name:\s*(.+)$/m);const descriptionMatch=yaml.match(/^description:\s*(.+)$/m);const tagsMatch=yaml.match(/^tags:\s*\[([^\]]*)\]/m);const unquote=s=>s.trim().replace(/^["']|["']$/g,"");const title=titleMatch?unquote(titleMatch[1]):null;const name=nameMatch?unquote(nameMatch[1]):null;const description=descriptionMatch?unquote(descriptionMatch[1]):null;const tags=tagsMatch?tagsMatch[1].split(",").map(t=>t.trim().replace(/^["']|["']$/g,"")).filter(Boolean):[];return {title,name,description,tags,body}}class IndexManager{async initialize(){await this.docIndex.load();for(const dir of this.config.playbookDirectories){if(!fs.existsSync(dir)){fs.mkdirSync(dir,{recursive:true});}}await this.syncDirectory();await this.rebuildBM25();this.fileWatcher=new FileWatcher(this.config.playbookDirectories,{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 onDisk=new Set;for(const dir of this.config.playbookDirectories){for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const fp=path$1.join(dir,entry.name);if(entry.isFile()){if(!isSupportedFile(fp))continue;onDisk.add(fp);await this.syncOneFile(fp);continue}if(entry.isDirectory()){if(entry.name.startsWith(".")||entry.name==="node_modules")continue;const skillFile=path$1.join(fp,"SKILL.md");if(fs.existsSync(skillFile)&&fs.statSync(skillFile).isFile()){onDisk.add(skillFile);await this.syncOneFile(skillFile);}}}}for(const meta of this.docIndex.getAll()){if(!onDisk.has(meta.filePath)){this.docIndex.deleteByPath(meta.filePath);}}this.docIndex.save();}async syncOneFile(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);}}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,description:meta.description,tags:meta.tags,bodyText:body});}catch{}}this.docSearch.rebuild(entries);}async indexFile(filePath,content,hash){const{title:fmTitle,name:fmName,description:fmDescription,tags,body}=parseFrontmatter(content);const base=path$1.basename(filePath,path$1.extname(filePath));const isSkillEntry=base.toLowerCase()==="skill";const title=fmTitle??fmName??(isSkillEntry?path$1.basename(path$1.dirname(filePath)):base);const description=fmDescription??"";let embedding=null;if(this.embeddingClient){try{const passages=[`${title}
${description}
${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,description,tags,hash,embedding,updatedAt:new Date().toISOString()};this.docIndex.upsert(record);this.docSearch.addToIndex(record.id,title,description,tags,body);this.docIndex.save();}async handleFileChange(filePath){if(!isIndexableFile(filePath,this.config.playbookDirectories))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")}resolveReadPath(filename){const requested=filename.trim();if(!requested)return null;if(path$1.isAbsolute(requested)){return fs.existsSync(requested)?requested:null}const normalized=requested.replace(/\\/g,"/");for(const dir of this.config.playbookDirectories){const candidate=path$1.resolve(dir,normalized);const rel=path$1.relative(dir,candidate);if(rel.startsWith("..")||path$1.isAbsolute(rel))continue;if(fs.existsSync(candidate))return candidate}return null}toDisplayPath(absoluteFilePath){const root=findOwningRoot(absoluteFilePath,this.config.playbookDirectories);return root?path$1.relative(root,absoluteFilePath):absoluteFilePath}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.playbookDirectories[0]}getPlaybookDirectories(){return this.config.playbookDirectories}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 truncate(text,maxLength){return text.length>maxLength?`${text.slice(0,maxLength)}…`:text}function createRecallTool(_ctl,index){return sdk.tool({name:"recall",description:`Search the playbook — the agent's persistent Markdown knowledge base of notes and skills.
Returns document metadata only (title, filename, tags, description, score), never full file contents.
Skill bundles (folders containing a SKILL.md with \`name\`/\`description\` frontmatter) are returned exactly like notes — one hit per bundle, with its description shown instead of a title-only summary.
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 or skill already exists before writing a new one
Returns:
- Ranked list of matching documents with title, filename (relative path — may include a skill folder, e.g. "my-skill/SKILL.md"), tags, description, 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 (for a skill, read the SKILL.md hit first — it links to any companion files under its folder).
${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=index.toDisplayPath(r.filePath);const tagStr=r.tags.length>0?` [${r.tags.join(", ")}]`:"";const desc=r.description?` — ${truncate(r.description,300)}`:"";return `- **${r.title}** (${filename})${tagStr}${desc} — 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 — a note or a skill's SKILL.md.
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
- A SKILL.md you already read links to a companion file (e.g. "tools/my-tool.md") and you need its content too
Returns:
- The complete Markdown source including YAML frontmatter (title/tags or name/description, timestamps)
- Full body text without any truncation
Examples:
- filename: "api-auth-notes.md" — returns a flat note
- filename: "pdf-report/SKILL.md" — returns a skill bundle's entry point
- filename: "pdf-report/tools/render_table.md" — returns a companion file linked from that SKILL.md
${formatToolMetaBlock()}`,parameters:{filename:zod.z.string().describe("The filename of the document to read (e.g. 'my-note.md' or 'my-skill/SKILL.md'). Use the filename returned by recall, or a relative path linked from a SKILL.md you already read.")},implementation:async args=>{const filePath=index.resolveReadPath(args.filename);if(!filePath){return `Error: File not found — "${args.filename}". Use recall to find available documents.`}try{return index.readSource(filePath)}catch{return `Error: File not found — "${args.filename}". Use recall to find available documents.`}}})}
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 sanitizeRelativeFile(file){if(!file||path$1.isAbsolute(file))return null;const normalized=path$1.normalize(file);if(normalized==="."||normalized.split(path$1.sep).some(part=>part===".."))return null;const ext=path$1.extname(normalized).toLowerCase();if(ext!==".md"&&ext!==".txt")return null;return normalized}function readExistingCreated(filePath){if(!fs.existsSync(filePath))return undefined;const existing=fs.readFileSync(filePath,"utf8");const m=existing.match(/^created:\s*"?([^"\n]+)"?/m);return m?m[1].trim():undefined}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 buildSkillFrontmatter(name,description,now,existingCreated){const created=existingCreated??now;const esc=s=>s.replace(/"/g,'\\"');return `---
name: "${esc(name)}"
description: "${esc(description)}"
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. Supports plain notes and SKILL.md skill bundles.
Three modes, chosen by which parameters you set:
- Note: set \`title\` (+ optional \`tags\`). Writes a flat file \`<slug>.md\` at the playbook root with title/tags/created/updated frontmatter.
- Skill: set \`name\` + \`description\`. Writes \`<slug>/SKILL.md\` with name/description/created/updated frontmatter — the entry point recall/read will surface.
- Companion file: set \`name\` + \`file\` (e.g. "tools/my-tool.md"). Writes \`<slug>/<file>\` as plain Markdown with no frontmatter — link to it from SKILL.md's body with a relative path. Not indexed by recall; fetch it directly with read once its path is known.
The filename/folder name is always derived by slugifying \`title\` or \`name\`.
When multiple Playbook Directories are configured, new documents are always written to the first (primary) one; the others are read-only search/read sources.
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)
- Authoring or updating a reusable skill bundle for future sessions
Returns:
- Confirmation with the resulting relative path
- An error if required parameters for the selected mode are missing
Examples:
- title: "API Auth Strategy", tags: ["api", "auth"], body: "Use OAuth2 for..." → note
- name: "pdf-report", description: "Generate paginated PDF reports from tabular data.", body: "# PDF Report Skill
..." → pdf-report/SKILL.md
- name: "pdf-report", file: "tools/render_table.md", body: "# render_table
..." → pdf-report/tools/render_table.md
${formatToolMetaBlock()}`,parameters:{title:zod.z.string().optional().describe("Document title for a plain note. Required unless `name` is set (skill mode)."),name:zod.z.string().optional().describe("Skill identifier (kebab-case recommended). When set, targets a skill bundle folder instead of a flat note. Combine with `description` to write SKILL.md, or with `file` to write a companion file."),file:zod.z.string().optional().describe('Relative path inside the skill bundle for a companion file, e.g. "tools/my-tool.md". Requires `name`. Written as-is with no frontmatter.'),description:zod.z.string().optional().describe("One-paragraph discovery description. Required when writing SKILL.md (name set, file omitted) — this is what recall shows before the full file is read."),tags:zod.z.array(zod.z.string()).default([]).describe("List of tags to categorise this document. Only used for plain notes."),body:zod.z.string().describe("The main content of the document (plain Markdown, without frontmatter).")},implementation:async args=>{const now=new Date().toISOString();if(args.name){const slug=slugify(args.name);if(!slug)return "Error: name must contain at least one word character.";const skillDir=path$1.join(index.getPlaybookDirectory(),slug);if(args.file){const rel=sanitizeRelativeFile(args.file);if(!rel)return `Error: invalid file "${args.file}" — must be a relative .md/.txt path without "..".`;const filePath=path$1.join(skillDir,rel);await index.writeFile(filePath,args.body);return `Saved companion file → ${slug}/${rel}`}if(!args.description){return "Error: description is required to write a SKILL.md (or pass `file` to add a companion file instead)."}const filePath=path$1.join(skillDir,"SKILL.md");const existingCreated=readExistingCreated(filePath);const frontmatter=buildSkillFrontmatter(args.name,args.description,now,existingCreated);await index.writeFile(filePath,`${frontmatter}${args.body}`);return `Saved skill "${args.name}" → ${slug}/SKILL.md`}if(!args.title){return "Error: title is required for a plain note (or use `name`/`description` to write a skill)."}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 existingCreated=readExistingCreated(filePath);const frontmatter=buildFrontmatter(args.title,args.tags,now,existingCreated);await index.writeFile(filePath,`${frontmatter}${args.body}`);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' or 'my-skill/SKILL.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=index.resolveReadPath(args.filename);if(!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' or 'my-skill/SKILL.md'). Deleting a skill's SKILL.md does not remove its companion files.")},implementation:async args=>{const filePath=index.resolveReadPath(args.filename);if(!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 playbookDirectories=config.get("playbookDirectories");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={playbookDirectories,embeddingModel,lmStudioBaseUrl,maxRecallDocuments,minRecallScore};const index=new IndexManager(resolvedConfig,dataDir,lmStudioBaseUrl);if(playbookDirectories.length>0){index.initialize().catch(err=>{console.error("[playbook] IndexManager initialization failed:",err);});}else {console.warn("[playbook] playbookDirectories 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;