(() => {
function arrayFromJumbleResponse(response) {
if (!response)
return null;
const splitWords = (gobbledeegoop) => {
let words;
if (Array.isArray(gobbledeegoop)) {
words = gobbledeegoop;
} else if (gobbledeegoop.includes(",")) {
words = gobbledeegoop.split(",");
} else if (gobbledeegoop.includes("\n")) {
words = gobbledeegoop.split("\n");
} else {
words = [gobbledeegoop];
}
return words.map((w) => w.trim());
};
let properArray;
if (Array.isArray(response)) {
properArray = response.reduce((arr, gobbledeegoop) => arr.concat(splitWords(gobbledeegoop)), []);
} else {
properArray = splitWords(response);
}
return properArray;
}
function arrayFromResponseString(responseString) {
if (typeof responseString !== "string")
return null;
const listItems = responseString.match(/^[\-*\d.]+\s+(.*)$/gm);
if (listItems?.length) {
return listItems.map((item) => optionWithoutPrefix(item));
} else {
return null;
}
}
function cleanTextFromAnswer(answer) {
return answer.split("\n").filter((line) => !/^(~~~|```(markdown)?)$/.test(line.trim())).join("\n");
}
function debugData(noteHandleOrSearchCandidate) {
const isNoteHandle = "keywordDensityEstimate" in noteHandleOrSearchCandidate;
const contentAttr = isNoteHandle ? "content" : "bodyContent";
return Object.fromEntries(["name", "keywordDensityEstimate", "preContentMatchScore", "tags", contentAttr].map((k) => {
const value = noteHandleOrSearchCandidate[k];
if (!value || Array.isArray(value) && !value.length) {
return null;
} else if (typeof value === "string" && value.length > 100) {
return [k, `${value.slice(0, 100)}...`];
} else if (Number.isFinite(value)) {
return [k, Math.round(10 * value) / 10];
} else {
return [k, value];
}
}).filter(Boolean));
}
function jsonFromAiText(jsonText) {
let json;
const trimmed = jsonText.trim();
let arrayStart = trimmed.indexOf("[");
let objectStart = trimmed.indexOf("{");
const hasClosingBrace = trimmed.includes("}");
const hasClosingBracket = trimmed.includes("]");
let isArray = false;
let jsonStart = -1;
if (hasClosingBrace && objectStart === -1) {
jsonText = `{${jsonText}`;
objectStart = 0;
}
if (hasClosingBracket && arrayStart === -1) {
jsonText = `[${jsonText}`;
arrayStart = 0;
}
arrayStart = jsonText.indexOf("[");
objectStart = jsonText.indexOf("{");
if (arrayStart !== -1 && (objectStart === -1 || arrayStart < objectStart)) {
isArray = true;
jsonStart = arrayStart;
} else if (objectStart !== -1) {
isArray = false;
jsonStart = objectStart;
} else {
jsonText = `{${jsonText}}`;
jsonStart = 0;
isArray = false;
}
const endChar = isArray ? "]" : "}";
let jsonEnd = jsonText.lastIndexOf(endChar);
if (jsonEnd === -1) {
if (jsonText[jsonText.length - 1] === ",")
jsonText = jsonText.substring(0, jsonText.length - 1);
const missingArrayClose = jsonText.includes("[") && !jsonText.includes("]");
const missingObjectClose = jsonText.includes("{") && !jsonText.includes("}");
if (missingArrayClose && missingObjectClose) {
jsonText += "}]";
} else if (missingArrayClose) {
jsonText += "]";
} else if (missingObjectClose) {
jsonText += "}";
} else {
jsonText = isArray ? `${jsonText}]` : `${jsonText}}`;
}
} else {
jsonText = jsonText.substring(jsonStart, jsonEnd + 1);
}
try {
json = JSON.parse(jsonText);
return json;
} catch (e) {
const parseTextWas = jsonText;
jsonText = balancedJsonFromString(jsonText);
console.error(`Failed to parse jsonText START:
${parseTextWas}
END
due to ${e}. Attempted rebalance yielded: ${jsonText} (original size ${parseTextWas.length || "(null)"}, rebalance size ${jsonText?.length || "(0)"})`);
try {
json = JSON.parse(jsonText);
return json;
} catch (e2) {
console.error("Rebalanced jsonText still fails", e2);
}
let reformattedText = jsonText.replace(/"""/g, `"\\""`).replace(/"\n/g, `"\\n`);
reformattedText = reformattedText.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)":/g, '$1"$2":');
reformattedText = reformattedText.replace(/\n\s*['“”]/g, `
"`).replace(/['“”],\s*\n/g, `",
`).replace(/['“”]\s*([\n\]])/, `"$1`);
if (reformattedText !== jsonText) {
try {
json = JSON.parse(reformattedText);
return json;
} catch (e2) {
console.error("Reformatted text still fails", e2);
}
}
}
return null;
}
function noteUrlFromUUID(noteUUID) {
return `https://www.amplenote.com/notes/${noteUUID}`;
}
function optionWithoutPrefix(option) {
if (!option)
return option;
const withoutStarAndNumber = option.trim().replace(/^[\-*\d.]+\s+/, "");
const withoutCheckbox = withoutStarAndNumber.replace(/^-?\s*\[\s*]\s+/, "");
return withoutCheckbox;
}
function pluralize(number, noun) {
const numValue = typeof number === "string" ? parseFloat(number) : number;
if (!Number.isInteger(numValue) && !Number.isFinite(numValue)) {
throw new Error("pluralize() requires an integer to be given");
}
const numberPart = numValue.toLocaleString();
return `${numberPart} ${noun}${parseInt(numValue) === 1 ? "" : "s"}`;
}
async function trimNoteContentFromAnswer(app, answer, { replaceToken = null, replaceIndex = null } = {}) {
const noteUUID = app.context.noteUUID;
const note = await app.notes.find(noteUUID);
const noteContent = await note.content();
let refinedAnswer = answer;
if (replaceIndex || replaceToken) {
replaceIndex = replaceIndex || noteContent.indexOf(replaceToken);
const upToReplaceToken = noteContent.substring(0, replaceIndex - 1);
const substring = upToReplaceToken.match(/(?:[\n\r.]|^)(.*)$/)?.[1];
const maxSentenceStartLength = 100;
const sentenceStart = !substring || substring.length > maxSentenceStartLength ? null : substring;
if (replaceToken) {
refinedAnswer = answer.replace(replaceToken, "").trim();
if (sentenceStart && sentenceStart.trim().length > 1) {
console.debug(`Replacing sentence start fragment: "${sentenceStart}"`);
refinedAnswer = refinedAnswer.replace(sentenceStart, "");
}
const afterTokenIndex = replaceIndex + replaceToken.length;
const afterSentence = noteContent.substring(afterTokenIndex + 1, afterTokenIndex + 100).trim();
if (afterSentence.length) {
const afterSentenceIndex = refinedAnswer.indexOf(afterSentence);
if (afterSentenceIndex !== -1) {
console.error("LLM response seems to have returned content after prompt. Truncating");
refinedAnswer = refinedAnswer.substring(0, afterSentenceIndex);
}
}
}
}
const originalLines = noteContent.split("\n").map((w) => w.trim());
const withoutOriginalLines = refinedAnswer.split("\n").filter((line) => !originalLines.includes(line.trim())).join("\n");
const withoutJunkLines = cleanTextFromAnswer(withoutOriginalLines);
console.debug(`Answer originally ${answer.length} length, refined answer length ${refinedAnswer.length} ("${refinedAnswer}"). Without repeated lines ${withoutJunkLines.length} length`);
return withoutJunkLines.trim();
}
function truncate(text, limit) {
return text.length > limit ? text.slice(0, limit) : text;
}
function balancedJsonFromString(string) {
const jsonStart = string.indexOf("{");
if (jsonStart === -1)
return null;
const jsonAndAfter = string.substring(jsonStart).trim();
const pendingBalance = [];
let jsonText = "";
for (const char of jsonAndAfter) {
jsonText += char;
if (char === "{") {
pendingBalance.push("}");
} else if (char === "}") {
if (pendingBalance[pendingBalance.length - 1] === "}")
pendingBalance.pop();
} else if (char === "[") {
pendingBalance.push("]");
} else if (char === "]") {
if (pendingBalance[pendingBalance.length - 1] === "]")
pendingBalance.pop();
}
if (pendingBalance.length === 0)
break;
}
if (pendingBalance.length) {
console.debug("Found", pendingBalance.length, "characters to append to balance", jsonText, ". Adding ", pendingBalance.reverse().join(""));
jsonText += pendingBalance.reverse().join("");
}
return jsonText;
}
var MAX_WORDS_TO_SHOW_RHYME = 4;
var MAX_WORDS_TO_SHOW_THESAURUS = 4;
var MAX_REALISTIC_THESAURUS_RHYME_WORDS = 4;
var REJECTED_RESPONSE_PREFIX = "The following responses were rejected:\n";
var KILOBYTE = 1024;
var TOKEN_CHARACTERS = 4;
var DALL_E_DEFAULT = "1024x1024~dall-e-3";
var DEFAULT_MODEL_TOKEN_LIMIT = 50 * KILOBYTE * TOKEN_CHARACTERS;
var LOOK_UP_OLLAMA_MODEL_ACTION_LABEL = "Look up available Ollama models";
var MIN_API_KEY_CHARACTERS = {
anthropic: 80,
deepseek: 40,
gemini: 30,
grok: 40,
openai: 50,
perplexity: 40
};
var OLLAMA_URL = "http://localhost:11434";
var OLLAMA_TOKEN_CHARACTER_LIMIT = 2e4;
var OLLAMA_MODEL_PREFERENCES = [
"mistral",
"openhermes2.5-mistral",
"llama2"
];
var PROVIDER_API_KEY_RETRIEVE_URL = {
anthropic: "https://console.anthropic.com/settings/keys",
deepseek: "https://platform.deepseek.com/api_keys",
gemini: "https://aistudio.google.com/app/api-keys",
grok: "https://console.x.ai/team/default/api-keys",
openai: "https://platform.openai.com/api-keys",
perplexity: "https://www.perplexity.ai/account/api/keys"
};
var PROVIDER_DEFAULT_MODEL = {
anthropic: "claude-sonnet-4-5",
deepseek: "deepseek-chat",
gemini: "gemini-3-pro-preview",
grok: "grok-4-1-fast",
openai: "gpt-5.2",
perplexity: "sonar-pro"
};
var PROVIDER_ENDPOINTS = {
anthropic: "https://api.anthropic.com/v1/messages",
deepseek: "https://api.deepseek.com/v1/chat/completions",
gemini: "https://generativelanguage.googleapis.com/v1beta/models/{model-name}:generateContent",
grok: "https://api.x.ai/v1/chat/completions",
openai: "https://api.openai.com/v1/chat/completions",
perplexity: "https://api.perplexity.ai/chat/completions"
};
var REMOTE_AI_PROVIDER_EMS = Object.keys(PROVIDER_ENDPOINTS);
var ANTHROPIC_TOKEN_LIMITS = {
"claude-sonnet-4-5": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-sonnet-4-5-20250929": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-haiku-4-5": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-haiku-4-5-20251001": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-5": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-5-20251101": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-1": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-1-20250805": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-sonnet-4-0": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-sonnet-4-20250514": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-7-sonnet-latest": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-7-sonnet-20250219": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-0": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-opus-4-20250514": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-5-haiku-latest": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-5-haiku-20241022": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-5-sonnet-latest": 200 * KILOBYTE * TOKEN_CHARACTERS,
"claude-3-haiku-20240307": 200 * KILOBYTE * TOKEN_CHARACTERS
};
var DEEPSEEK_TOKEN_LIMITS = {
"deepseek-chat": 64 * KILOBYTE * TOKEN_CHARACTERS,
"deepseek-reasoner": 64 * KILOBYTE * TOKEN_CHARACTERS,
"deepseek-r1": 64 * KILOBYTE * TOKEN_CHARACTERS,
"deepseek-r1-0528": 64 * KILOBYTE * TOKEN_CHARACTERS
};
var GEMINI_TOKEN_LIMITS = {
"gemini-3-flash": 64 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-3-flash-preview": 64 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-3-pro": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-3-pro-preview": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-3-pro-image-preview": 64 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.5-pro": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.5-flash": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.5-flash-lite": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.5-flash-lite-preview-06-17": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.0-flash": 1024 * KILOBYTE * TOKEN_CHARACTERS,
"gemini-2.0-flash-lite": 1024 * KILOBYTE * TOKEN_CHARACTERS
};
var GROK_TOKEN_LIMITS = {
"grok-4-1-fast": 2048 * KILOBYTE * TOKEN_CHARACTERS,
"grok-4-fast": 2048 * KILOBYTE * TOKEN_CHARACTERS,
"grok-4": 256 * KILOBYTE * TOKEN_CHARACTERS,
"grok-4-0709": 256 * KILOBYTE * TOKEN_CHARACTERS,
"grok-3": 128 * KILOBYTE * TOKEN_CHARACTERS,
"grok-3-beta": 128 * KILOBYTE * TOKEN_CHARACTERS,
"grok-3-mini": 128 * KILOBYTE * TOKEN_CHARACTERS,
"grok-3-mini-beta": 128 * KILOBYTE * TOKEN_CHARACTERS,
"grok-2-vision-1212": 8 * KILOBYTE * TOKEN_CHARACTERS,
"grok-2-image-1212": 128 * KILOBYTE * TOKEN_CHARACTERS,
"grok-2-1212": 128 * KILOBYTE * TOKEN_CHARACTERS
};
var OPENAI_TOKEN_LIMITS = {
"gpt-5.2": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-5.1": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-5.1-codex-max": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-5": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-5-fast": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-5-thinking": 400 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4.1": 1e3 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4.1-mini": 128 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4o": 128 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4o-mini": 128 * KILOBYTE * TOKEN_CHARACTERS,
"o3": 200 * KILOBYTE * TOKEN_CHARACTERS,
"o3-mini": 200 * KILOBYTE * TOKEN_CHARACTERS,
"o3-pro": 200 * KILOBYTE * TOKEN_CHARACTERS,
"o4-mini": 200 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4": 8 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4-1106-preview": 128 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4-32k": 32 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4-32k-0613": 32 * KILOBYTE * TOKEN_CHARACTERS,
"gpt-4-vision-preview": 128 * KILOBYTE * TOKEN_CHARACTERS
};
var PERPLEXITY_TOKEN_LIMITS = {
"sonar-pro": 200 * KILOBYTE * TOKEN_CHARACTERS,
"sonar": 128 * KILOBYTE * TOKEN_CHARACTERS,
"sonar-reasoning-pro": 128 * KILOBYTE * TOKEN_CHARACTERS,
"sonar-reasoning": 128 * KILOBYTE * TOKEN_CHARACTERS,
"sonar-deep-research": 128 * KILOBYTE * TOKEN_CHARACTERS
};
var MODEL_TOKEN_LIMITS = {
...ANTHROPIC_TOKEN_LIMITS,
...DEEPSEEK_TOKEN_LIMITS,
...GEMINI_TOKEN_LIMITS,
...GROK_TOKEN_LIMITS,
...OPENAI_TOKEN_LIMITS
};
var MODELS_PER_PROVIDER = {
anthropic: Object.keys(ANTHROPIC_TOKEN_LIMITS),
deepseek: Object.keys(DEEPSEEK_TOKEN_LIMITS),
gemini: Object.keys(GEMINI_TOKEN_LIMITS),
grok: Object.keys(GROK_TOKEN_LIMITS),
openai: Object.keys(OPENAI_TOKEN_LIMITS)
};
var APP_OPTION_VALUE_USE_PROMPT = "What would you like to do with this result?";
var IMAGE_GENERATION_PROMPT = "What would you like to generate an image of?";
var NO_MODEL_FOUND_TEXT = `No AI provider has been to setup.
For casual-to-intermediate users, we recommend using OpenAI, Anthropic and Gemini, since all offers high quality results. OpenAI can generate images.`;
var OLLAMA_INSTALL_TEXT = `Rough installation instructions:
1. Download Ollama: https://ollama.ai/download
2. Install Ollama
3. Install one or more LLMs that will fit within the RAM your computer (examples at https://github.com/jmorganca/ollama)
4. Ensure that Ollama isn't already running, then start it in the console using "OLLAMA_ORIGINS=https://plugins.amplenote.com ollama serve"
You can test whether Ollama is running by invoking Quick Open and running the "${LOOK_UP_OLLAMA_MODEL_ACTION_LABEL}" action`;
var OPENAI_API_KEY_URL = "https://platform.openai.com/account/api-keys";
var OPENAI_API_KEY_TEXT = `Paste your LLM API key in the field below.
Once you have an OpenAI account, get your key here: ${OPENAI_API_KEY_URL}`;
var PROVIDER_INVALID_KEY_TEXT = "That doesn't seem to be a valid API key. You can enter one later in the settings for this plugin.";
var QUESTION_ANSWER_PROMPT = "What would you like to know?";
var PROVIDER_API_KEY_TEXT = {
anthropic: `Paste your Anthropic API key in the field below.
Your API key should start with "sk-ant-api03-". Get your key here:
${PROVIDER_API_KEY_RETRIEVE_URL.anthropic}`,
deepseek: `Paste your DeepSeek API key in the field below.
Sign up for a DeepSeek account and get your API key here:
${PROVIDER_API_KEY_RETRIEVE_URL.deepseek}`,
gemini: `Paste your Gemini API key in the field below.
Your API key should start with "AIza". Get your key from Google AI Studio:
${PROVIDER_API_KEY_RETRIEVE_URL.gemini}`,
grok: `Paste your Grok API key in the field below.
Your API key should start with "xai-". Get your key from the xAI console:
${PROVIDER_API_KEY_RETRIEVE_URL.grok}`,
openai: `Paste your OpenAI API key in the field below.
Your API key should start with "sk-". Get your key here:
${PROVIDER_API_KEY_RETRIEVE_URL.openai}`,
perplexity: `Paste your Perplexity API key in the field below.
Your API key should start with "pplx-". Get your key here:
${PROVIDER_API_KEY_RETRIEVE_URL.perplexity}`
};
var ATTEMPT_FIRST_PASS = "first_pass";
var ATTEMPT_INDIVIDUAL = "individual";
var ATTEMPT_STRATEGIES = [ATTEMPT_FIRST_PASS, ATTEMPT_INDIVIDUAL];
var DEFAULT_SEARCH_NOTES_RETURNED = 10;
var KEYWORD_BODY_PRIMARY_WEIGHT = 1;
var KEYWORD_BODY_SECONDARY_WEIGHT = 0.5;
var KEYWORD_DENSITY_DIVISOR = 500;
var KEYWORD_TAG_PRIMARY_WEIGHT = 1;
var KEYWORD_TAG_SECONDARY_WEIGHT = 0.5;
var KEYWORD_TITLE_PRIMARY_WEIGHT = 5;
var KEYWORD_TITLE_SECONDARY_WEIGHT = 2;
var MAX_CANDIDATES_FOR_DENSITY_CALCULATION = 100;
var MAX_PHASE4_TIMEOUT_RETRIES = 3;
var MAX_CHARACTERS_TO_SEARCH_BODY = 4e3;
var MAX_NOTES_PER_QUERY = 100;
var MAX_CANDIDATES_PER_KEYWORD = 30;
var MAX_DEEP_ANALYZED_NOTES = 30;
var MAX_SEARCH_CONCURRENCY = 10;
var MAX_SECONDARY_KEYWORDS_TO_QUERY = 15;
var MIN_KEEP_RESULT_SCORE = 5;
var MIN_PHASE2_TARGET_CANDIDATES = 50;
var PHASE4_TIMEOUT_SECONDS = 60;
var POLLING_INTERVAL_EMBED_MILLISECONDS = 1500;
var PRE_CONTENT_MAX_SCORE_PER_KEYWORD = 10;
var PRE_CONTENT_MIN_PRIMARY_SCORE = 0.5;
var PRE_CONTENT_MIN_SECONDARY_SCORE = 0.2;
var PRE_CONTENT_SECONDARY_MULTIPLIER = 0.5;
var PRE_CONTENT_TAG_WORD_PRIMARY_SCORE = 0.2;
var PRE_CONTENT_TAG_WORD_SECONDARY_SCORE = 0.1;
var RANK_MATCH_COUNT_CAP = 10;
var RESULT_TAG_DEFAULT = "plugins/ample-ai/search-results";
function settingKeyLabel(providerEm) {
return PROVIDER_SETTING_KEY_LABELS[providerEm];
}
var ADD_PROVIDER_API_KEY_LABEL = "Add Provider API key";
var AI_LEGACY_MODEL_LABEL = "Preferred AI model (e.g., 'gpt-4')";
var AI_MODEL_LABEL = "Preferred AI models (comma separated)";
var CORS_PROXY = "https://wispy-darkness-7716.amplenote.workers.dev";
var IMAGE_FROM_PRECEDING_LABEL = "Image from preceding text";
var IMAGE_FROM_PROMPT_LABEL = "Image from prompt";
var IS_TEST_ENVIRONMENT = typeof process !== "undefined" && process.env?.NODE_ENV === "test";
var MAX_SPACES_ABORT_RESPONSE = 30;
var SEARCH_AGENT_RESULT_TAG_LABEL = `Tag to apply to search result summary notes`;
var SEARCH_USING_AGENT_LABEL = "AI Search Agent";
var SUGGEST_TASKS_LABEL = "Suggest tasks";
var PLUGIN_NAME = "AmpleAI";
var PROVIDER_SETTING_KEY_LABELS = {
anthropic: "Anthropic API Key",
deepseek: "DeepSeek API Key",
gemini: "Gemini API Key",
grok: "Grok API Key",
openai: "OpenAI API Key"
};
async function apiKeyFromAppOrUser(app, providerEm) {
const apiKey = apiKeyFromApp(app, providerEm) || await apiKeyFromUser(app, providerEm);
if (!apiKey) {
app.alert("Couldn't find a valid OpenAI API key. An OpenAI account is necessary to generate images.");
return null;
}
return apiKey;
}
function apiKeyFromApp(app, providerEm) {
const providerKeyLabel = settingKeyLabel(providerEm);
if (app.settings[providerKeyLabel]) {
return app.settings[providerKeyLabel].trim();
} else if (app.settings["API Key"] || app.settings[AI_LEGACY_MODEL_LABEL]) {
const deprecatedKey = (app.settings["API Key"] || app.settings[AI_LEGACY_MODEL_LABEL]).trim();
app.setSetting(settingKeyLabel("openai"), deprecatedKey);
return deprecatedKey;
} else {
if (IS_TEST_ENVIRONMENT) {
throw new Error(`Couldnt find a ${providerEm} key in ${app.settings}`);
} else {
app.alert("Please configure your OpenAI key in plugin settings.");
}
return null;
}
}
async function apiKeyFromUser(app, providerEm) {
const apiKey = await app.prompt(OPENAI_API_KEY_TEXT);
if (apiKey) {
app.setSetting(settingKeyLabel(providerEm), apiKey);
}
return apiKey;
}
function configuredProvidersSorted(appSettings) {
const modelsSetting = appSettings[AI_MODEL_LABEL];
const preferredModels2 = parsePreferredModels(modelsSetting);
const sortedProviders = [];
for (const model of preferredModels2) {
const providerEm = providerFromModel(model);
const settingKey = PROVIDER_SETTING_KEY_LABELS[providerEm];
const minKeyLength = MIN_API_KEY_CHARACTERS[providerEm];
const isConfigured = appSettings[settingKey]?.trim()?.length >= minKeyLength;
if (isConfigured && !sortedProviders.includes(providerEm)) {
sortedProviders.push(providerEm);
}
}
for (const providerEm of REMOTE_AI_PROVIDER_EMS) {
const settingKey = PROVIDER_SETTING_KEY_LABELS[providerEm];
const minKeyLength = MIN_API_KEY_CHARACTERS[providerEm];
const isConfigured = appSettings[settingKey]?.trim()?.length >= minKeyLength;
if (isConfigured && !sortedProviders.includes(providerEm)) {
sortedProviders.push(providerEm);
}
}
return sortedProviders;
}
function defaultProviderModel(providerEm) {
return PROVIDER_DEFAULT_MODEL[providerEm];
}
function modelTokenLimit(model) {
return MODEL_TOKEN_LIMITS[model] || DEFAULT_MODEL_TOKEN_LIMIT;
}
function isModelOllama(model) {
return !remoteAiModels().includes(model);
}
function modelForProvider(modelsSetting, providerEm) {
const preferredModels2 = parsePreferredModels(modelsSetting);
const providerModels = MODELS_PER_PROVIDER[providerEm];
for (const model of preferredModels2) {
if (providerModels && providerModels.includes(model)) {
return model;
}
}
return PROVIDER_DEFAULT_MODEL[providerEm];
}
function parsePreferredModels(modelsSetting) {
if (!modelsSetting || typeof modelsSetting !== "string")
return [];
return modelsSetting.split(",").map((m) => m.trim()).filter(Boolean);
}
function preferredModel(app, lastUsedModel = null) {
const models = preferredModels(app);
if (lastUsedModel && models.includes(lastUsedModel)) {
return lastUsedModel;
}
return models?.at(0);
}
function preferredModels(app) {
if (!app || !app.settings)
return [];
const preferredModelsFromSetting = parsePreferredModels(app.settings[AI_MODEL_LABEL]);
if (preferredModelsFromSetting?.length)
return preferredModelsFromSetting;
const providers = configuredProvidersSorted(app.settings);
return providers.map((providerEm) => PROVIDER_DEFAULT_MODEL[providerEm]);
}
function providerEndpointUrl(model, apiKey) {
const providerEm = providerFromModel(model);
let endpoint = PROVIDER_ENDPOINTS[providerEm];
endpoint = endpoint.replace("{model-name}", model);
if (providerEm === "gemini") {
endpoint = `${endpoint}?key=${apiKey}`;
}
return endpoint;
}
function providerFromModel(model) {
for (const [providerEm, models] of Object.entries(MODELS_PER_PROVIDER)) {
if (models.includes(model)) {
return providerEm;
}
}
throw new Error(`Model ${model} not found in any provider`);
}
function providerNameFromProviderEm(providerEm) {
const providerNames = {
anthropic: "Anthropic",
deepseek: "DeepSeek",
gemini: "Gemini",
grok: "Grok",
openai: "OpenAI",
perplexity: "Perplexity"
};
return providerNames[providerEm] || providerEm.charAt(0).toUpperCase() + providerEm.slice(1);
}
function remoteAiModels() {
return Object.values(MODELS_PER_PROVIDER).flat();
}
function isJsonPrompt(promptKey) {
return !!["rhyming", "thesaurus", "sortGroceriesJson", "suggestTasks"].find((key) => key === promptKey);
}
function useLongContentContext(promptKey) {
return ["continue", "insertTextComplete"].includes(promptKey);
}
function limitContextLines(aiModel, _promptKey) {
return !/(gpt-4|gpt-3)/.test(aiModel);
}
function tooDumbForExample(aiModel) {
const smartModel = ["mistral"].includes(aiModel) || aiModel.includes("gpt-4");
return !smartModel;
}
function frequencyPenaltyFromPromptKey(promptKey) {
if (["rhyming", "suggestTasks", "thesaurus"].find((key) => key === promptKey)) {
return 2;
} else if (["answer"].find((key) => key === promptKey)) {
return 1;
} else if (["revise", "sortGroceriesJson", "sortGroceriesText"].find((key) => key === promptKey)) {
return -1;
} else {
return 0;
}
}
var streamTimeoutSeconds = 2;
function shouldStream(plugin2) {
return !plugin2.constants.isTestEnvironment || plugin2.constants.streamTest;
}
function streamPrefaceString(aiModel, modelsQueried, promptKey, jsonResponseExpected) {
let responseText = "";
if (["chat"].indexOf(promptKey) === -1 && modelsQueried.length > 1) {
responseText += `Response from ${modelsQueried[modelsQueried.length - 1]} was rejected as invalid.
`;
}
responseText += `${aiModel} is now generating ${jsonResponseExpected ? "JSON " : ""}response...`;
return responseText;
}
function jsonFromMessages(messages) {
const json = {};
const systemMessage = messages.find((message) => message.role === "system");
if (systemMessage) {
json.system = systemMessage.content;
messages = messages.filter((message) => message !== systemMessage);
}
const rejectedResponseMessage = messages.find((message) => message.role === "user" && message.content.startsWith(REJECTED_RESPONSE_PREFIX));
if (rejectedResponseMessage) {
json.rejectedResponses = rejectedResponseMessage.content;
messages = messages.filter((message) => message !== rejectedResponseMessage);
}
json.prompt = messages[0].content;
if (messages[1]) {
console.error("Unexpected messages for JSON:", messages.slice(1));
}
return json;
}
function extractJsonFromString(inputString) {
let jsonText = inputString.trim();
let jsonStart = jsonText.indexOf("{");
if (jsonStart === -1) {
jsonText = "{" + jsonText;
}
let responses;
if (jsonText.split("}{").length > 1) {
responses = jsonText.split("}{").map((text) => `${text[0] === "{" ? "" : "{"}${text}${text[text.length - 1] === "}" ? "" : "}"}`);
console.log("Received multiple responses from AI, evaluating each of", responses);
} else {
responses = [jsonText];
}
const jsonResponses = responses.map((jsonText2) => {
return jsonFromAiText(jsonText2);
});
const formedResponses = jsonResponses.filter((n) => n);
if (formedResponses.length) {
if (formedResponses.length > 1) {
const result = formedResponses[0];
Object.entries(result).forEach(([key, value]) => {
for (const altResponse of formedResponses.slice(1)) {
const altValue = altResponse[key];
if (altValue) {
if (Array.isArray(altValue) && Array.isArray(value)) {
result[key] = [... new Set([...value, ...altValue])].filter((w) => w);
}
}
}
});
return result;
} else {
return formedResponses[0];
}
}
return null;
}
function contentFromProviderResponse(providerEm, jsonResponse) {
let content;
switch (providerEm) {
case "anthropic":
content = jsonResponse?.content?.at(0)?.text;
break;
case "gemini":
content = jsonResponse?.candidates?.at(0)?.content?.parts?.at(0)?.text;
break;
case "ollama":
content = jsonResponse?.message?.content || jsonResponse?.response;
break;
case "deepseek":
case "grok":
case "openai":
case "perplexity":
default:
content = jsonResponse?.choices?.at(0)?.message?.content || jsonResponse?.choices?.at(0)?.message?.tool_calls?.at(0)?.function?.arguments;
break;
}
if (!content) {
console.debug(`Could not extract content from ${providerEm} response:`, JSON.stringify(jsonResponse, null, 2));
}
return content || null;
}
async function responseFromStreamOrChunk(app, response, model, promptKey, streamCallback, allowResponse, { timeoutSeconds = 30 } = {}) {
const jsonResponseExpected = isJsonPrompt(promptKey);
const providerEm = providerFromModel(model);
let result;
if (streamCallback) {
result = await responseTextFromStreamResponse(app, response, model, jsonResponseExpected, streamCallback);
app.alert(result, { scrollToEnd: true });
} else {
try {
await Promise.race([
new Promise(async (resolve, _) => {
const jsonResponse = await response.json();
result = contentFromProviderResponse(providerEm, jsonResponse);
resolve(result);
}),
new Promise(
(_, reject) => setTimeout(() => reject(new Error(`${providerEm} Timeout`)), timeoutSeconds * 1e3)
)
]);
} catch (e) {
console.error("Failed to parse response from", model, "error", e);
throw e;
}
}
const resultBeforeTransform = result;
if (jsonResponseExpected) {
result = extractJsonFromString(result);
}
if (!allowResponse || allowResponse(result)) {
return result;
}
if (resultBeforeTransform) {
console.debug("Received", resultBeforeTransform, "but could not parse as a valid result");
}
return null;
}
function fetchJson(endpoint, attrs) {
attrs = attrs || {};
if (!attrs.headers)
attrs.headers = {};
attrs.headers["Accept"] = "application/json";
attrs.headers["Content-Type"] = "application/json";
const method = (attrs.method || "GET").toUpperCase();
if (attrs.payload) {
if (method === "GET") {
endpoint = extendUrlWithParameters(endpoint, attrs.payload);
} else {
attrs.body = JSON.stringify(attrs.payload);
}
}
return fetch(endpoint, attrs).then((response) => {
if (response.ok) {
return response.json();
} else {
throw new Error(`Could not fetch ${endpoint}: ${response}`);
}
});
}
function jsonResponseFromStreamChunk(supposedlyJsonContent, failedParseContent) {
let jsonResponse;
const testContent = supposedlyJsonContent.replace(/^data:\s?/, "").trim();
try {
jsonResponse = JSON.parse(testContent);
} catch (e) {
if (failedParseContent) {
try {
jsonResponse = JSON.parse(failedParseContent + testContent);
} catch (err) {
return { failedParseContent: failedParseContent + testContent };
}
} else {
const jsonStart = testContent.indexOf("{");
if (jsonStart) {
try {
jsonResponse = JSON.parse(testContent.substring(jsonStart));
return { failedParseContent: null, jsonResponse };
} catch (err) {
}
}
return { failedParseContent: testContent };
}
}
return { failedParseContent: null, jsonResponse };
}
async function responseTextFromStreamResponse(app, response, aiModel, responseJsonExpected, streamCallback) {
if (typeof global !== "undefined" && typeof global.fetch !== "undefined") {
return await streamIsomorphicFetch(app, response, aiModel, responseJsonExpected, streamCallback);
} else {
return await streamWindowFetch(app, response, aiModel, responseJsonExpected, streamCallback);
}
}
async function streamIsomorphicFetch(app, response, aiModel, responseJsonExpected, callback) {
const responseBody = response.body;
let abort = false;
let receivedContent = "";
let failedParseContent, incrementalContents;
await new Promise((resolve, _reject) => {
const readStream = () => {
let failLoops = 0;
const processChunk = () => {
const chunk = responseBody.read();
if (chunk) {
failLoops = 0;
const decoded = chunk.toString();
const responseObject = callback(app, decoded, receivedContent, aiModel, responseJsonExpected, failedParseContent);
({ abort, failedParseContent, incrementalContents, receivedContent } = responseObject);
if (abort || !shouldContinueStream(incrementalContents, receivedContent)) {
resolve();
return;
}
processChunk();
} else {
failLoops += 1;
if (failLoops < 3) {
setTimeout(processChunk, streamTimeoutSeconds * 1e3);
} else {
resolve();
}
}
};
processChunk();
};
responseBody.on("readable", readStream);
});
return receivedContent;
}
async function streamWindowFetch(app, response, aiModel, responseJsonExpected, callback) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let abort, error, failedParseContent, incrementalContents;
let failLoops = 0;
let receivedContent = "";
while (!error) {
let value = null, done = false;
try {
await Promise.race([
{ done, value } = await reader.read(),
new Promise(
(_, reject) => setTimeout(() => reject(new Error("Timeout")), streamTimeoutSeconds * 1e3)
)
]);
} catch (e) {
error = e;
console.log(`Failed to receive further stream data in time`, e);
break;
}
if (done || failLoops > 3) {
console.debug("Completed generating response length");
break;
} else if (value) {
const decodedValue = decoder.decode(value, { stream: true });
try {
if (typeof decodedValue === "string") {
failLoops = 0;
const response2 = callback(app, decodedValue, receivedContent, aiModel, responseJsonExpected, failedParseContent);
if (response2) {
({ abort, failedParseContent, incrementalContents, receivedContent } = response2);
if (abort)
break;
if (!shouldContinueStream(incrementalContents, receivedContent))
break;
} else {
console.error("Failed to parse stream from", value, "as JSON");
failLoops += 1;
}
} else {
console.error("Failed to parse stream from", value, "as JSON");
failLoops += 1;
}
} catch (error2) {
console.error("There was an error parsing the response from stream:", error2);
break;
}
} else {
failLoops += 1;
}
}
return receivedContent;
}
function shouldContinueStream(chunkStrings, accumulatedResponse) {
let tooMuchSpace;
if (chunkStrings?.length && (accumulatedResponse?.length || 0) >= MAX_SPACES_ABORT_RESPONSE) {
const sansNewlines = accumulatedResponse.replace(/\n/g, " ");
tooMuchSpace = sansNewlines.substring(sansNewlines.length - MAX_SPACES_ABORT_RESPONSE).trim() === "";
if (tooMuchSpace)
console.debug("Response exceeds empty space threshold. Aborting");
}
return !tooMuchSpace;
}
function extendUrlWithParameters(basePath, paramObject) {
let path = basePath;
if (basePath.indexOf("?") !== -1) {
path += "&";
} else {
path += "?";
}
function deepSerialize(object, prefix) {
const keyValues = [];
for (let property in object) {
if (object.hasOwnProperty(property)) {
const key = prefix ? prefix + "[" + property + "]" : property;
const value = object[property];
keyValues.push(
value !== null && typeof value === "object" ? deepSerialize(value, key) : encodeURIComponent(key) + "=" + encodeURIComponent(value)
);
}
}
return keyValues.join("&");
}
path += deepSerialize(paramObject);
return path;
}
async function callOllama(plugin2, app, model, messages, promptKey, allowResponse, modelsQueried = []) {
const stream = shouldStream(plugin2);
const jsonEndpoint = isJsonPrompt(promptKey);
let response;
const streamCallback = stream ? streamAccumulate.bind(null, modelsQueried, promptKey) : null;
if (jsonEndpoint) {
response = await responsePromiseFromGenerate(
app,
messages,
model,
promptKey,
streamCallback,
allowResponse,
plugin2.constants.requestTimeoutSeconds
);
} else {
response = await responseFromChat(
app,
messages,
model,
promptKey,
streamCallback,
allowResponse,
plugin2.constants.requestTimeoutSeconds,
{ isTestEnvironment: plugin2.isTestEnvironment }
);
}
console.debug("Ollama", model, "model sez:\n", response);
return response;
}
async function ollamaAvailableModels(plugin2, alertOnEmptyApp = null) {
try {
const json = await fetchJson(`${OLLAMA_URL}/api/tags`);
if (!json)
return null;
if (json?.models?.length) {
const availableModels = json.models.map((m) => m.name);
const transformedModels = availableModels.map((m) => m.split(":")[0]);
const uniqueModels = transformedModels.filter((value, index, array) => array.indexOf(value) === index);
const sortedModels = uniqueModels.sort((a, b) => {
const aValue = OLLAMA_MODEL_PREFERENCES.indexOf(a) === -1 ? 10 : OLLAMA_MODEL_PREFERENCES.indexOf(a);
const bValue = OLLAMA_MODEL_PREFERENCES.indexOf(b) === -1 ? 10 : OLLAMA_MODEL_PREFERENCES.indexOf(b);
return aValue - bValue;
});
console.debug("Ollama reports", availableModels, "available models, transformed to", sortedModels);
return sortedModels;
} else {
if (alertOnEmptyApp) {
if (Array.isArray(json?.models)) {
alertOnEmptyApp.alert("Ollama is running but no LLMs are reported as available. Have you Run 'ollama run mistral' yet?");
} else {
alertOnEmptyApp.alert(`Unable to fetch Ollama models. Was Ollama started with "OLLAMA_ORIGINS=https://plugins.amplenote.com ollama serve"?`);
}
}
return null;
}
} catch (error) {
console.log("Error trying to fetch Ollama versions: ", error, "Are you sure Ollama was started with 'OLLAMA_ORIGINS=https://plugins.amplenote.com ollama serve'");
}
}
async function responseFromChat(app, messages, model, promptKey, streamCallback, allowResponse, timeoutSeconds, { isTestEnvironment = false } = {}) {
if (isTestEnvironment)
console.log("Calling Ollama with", model, "and streamCallback", streamCallback);
let response;
try {
await Promise.race([
response = await fetch(`${OLLAMA_URL}/api/chat`, {
body: JSON.stringify({ model, messages, stream: !!streamCallback }),
method: "POST"
}),
new Promise((_, reject) => setTimeout(() => reject(new Error("Ollama Generate Timeout")), timeoutSeconds * 1e3))
]);
} catch (e) {
throw e;
}
if (response?.ok) {
return await responseFromStreamOrChunk(app, response, model, promptKey, streamCallback, allowResponse, { timeoutSeconds });
} else {
throw new Error("Failed to call Ollama with", model, messages, "and stream", !!streamCallback, "response was", response, "at", new Date());
}
}
async function responsePromiseFromGenerate(app, messages, model, promptKey, streamCallback, allowResponse, timeoutSeconds) {
const jsonQuery = jsonFromMessages(messages);
jsonQuery.model = model;
jsonQuery.stream = !!streamCallback;
let response;
try {
await Promise.race([
response = await fetch(`${OLLAMA_URL}/api/generate`, {
body: JSON.stringify(jsonQuery),
method: "POST"
}),
new Promise(
(_, reject) => setTimeout(() => reject(new Error("Ollama Generate Timeout")), timeoutSeconds * 1e3)
)
]);
} catch (e) {
throw e;
}
return await responseFromStreamOrChunk(
app,
response,
model,
promptKey,
streamCallback,
allowResponse,
{ timeoutSeconds }
);
}
function streamAccumulate(modelsQueriedArray, promptKey, app, decodedValue, receivedContent, aiModel, jsonResponseExpected, failedParseContent) {
let jsonResponse, content = "";
const responses = decodedValue.replace(/}\s*\n\{/g, "} \n{").split(" \n");
const incrementalContents = [];
for (const response of responses) {
const parseableJson = response.replace(/"\n/, `"\\n`).replace(/"""/, `"\\""`);
({ failedParseContent, jsonResponse } = jsonResponseFromStreamChunk(parseableJson, failedParseContent));
if (jsonResponse) {
const responseContent = jsonResponse.message?.content || jsonResponse.response;
if (responseContent) {
incrementalContents.push(responseContent);
content += responseContent;
} else {
console.debug("No response content found. Response", response, "\nParses to", parseableJson, "\nWhich yields JSON received", jsonResponse);
}
}
if (content) {
receivedContent += content;
const userSelection = app.alert(receivedContent, {
actions: [{ icon: "pending", label: "Generating response" }],
preface: streamPrefaceString(aiModel, modelsQueriedArray, promptKey, jsonResponseExpected),
scrollToEnd: true
});
if (userSelection === 0) {
console.error("User chose to abort stream. Todo: return abort here?");
}
} else if (failedParseContent) {
console.debug("Attempting to parse yielded failure. Received content so far is", receivedContent, "this stream deduced", responses.length, "responses");
}
}
return { abort: jsonResponse.done, failedParseContent, incrementalContents, receivedContent };
}
function toolsValueFromPrompt(promptKey) {
let openaiFunction;
switch (promptKey) {
case "rhyming":
case "thesaurus":
const description = promptKey === "rhyming" ? "Array of 10 contextually relevant rhyming words" : "Array of 10 contextually relevant alternate words";
openaiFunction = {
"type": "function",
"function": {
"name": `calculate_${promptKey}_array`,
"description": `Return the best ${promptKey} responses`,
"parameters": {
"type": "object",
"properties": {
"result": {
"type": "array",
"description": description,
"items": {
"type": "string"
}
}
},
"required": ["result"]
}
}
};
}
if (openaiFunction) {
return [openaiFunction];
} else {
return null;
}
}
var TIMEOUT_SECONDS = 30;
async function callRemoteAI(plugin2, app, model, messages, promptKey, allowResponse, modelsQueried = []) {
const providerEm = providerFromModel(model);
model = model?.trim()?.length ? model : defaultProviderModel(providerEm);
const tools = toolsValueFromPrompt(promptKey);
const streamCallback = shouldStream(plugin2) ? streamAccumulate2.bind(null, modelsQueried, promptKey) : null;
try {
return await requestWithRetry(
app,
model,
messages,
tools,
promptKey,
streamCallback,
allowResponse,
{ timeoutSeconds: plugin2.constants.requestTimeoutSeconds }
);
} catch (error) {
if (plugin2.isTestEnvironment) {
throw error;
} else {
const providerName = providerNameFromProviderEm(providerEm);
app.alert(`Failed to call ${providerName}: ${error}`);
}
return null;
}
}
async function llmPrompt(app, plugin2, prompt, { aiModel = null, concurrency = 1, jsonResponse = false, timeoutSeconds = null } = {}) {
let modelCandidates = preferredModels(app);
if (aiModel) {
modelCandidates = modelCandidates.filter((m) => m !== aiModel);
modelCandidates.unshift(aiModel);
}
const modelToUse = modelCandidates.shift();
const messages = [{ role: "user", content: prompt }];
const requestOptions = timeoutSeconds ? { timeoutSeconds } : {};
const fetchResponse = await makeRequest(app, messages, modelToUse, requestOptions);
const promptKey = jsonResponse ? "llmPromptJson" : null;
const response = await responseFromStreamOrChunk(app, fetchResponse, modelToUse, promptKey, null, null);
if (response && jsonResponse) {
return extractJsonFromString(response);
} else {
return response;
}
}
function headersForProvider(providerEm, apiKey) {
const baseHeaders = { "Content-Type": "application/json" };
switch (providerEm) {
case "anthropic":
return {
...baseHeaders,
"x-api-key": apiKey,
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-version": "2023-06-01"
};
case "gemini":
return baseHeaders;
default:
return {
...baseHeaders,
"Authorization": `Bearer ${apiKey}`
};
}
}
function requestBodyForProvider(messages, model, stream, tools, { promptKey = null } = {}) {
let body;
const providerEm = providerFromModel(model);
const jsonResponseExpected = isJsonPrompt(promptKey);
switch (providerEm) {
case "anthropic": {
const systemMessage = messages.find((m) => m.role === "system");
const nonSystemMessages = messages.filter((m) => m.role !== "system");
body = {
"max_tokens": 4096,
model,
messages: nonSystemMessages
};
if (stream) {
body.stream = stream;
}
if (systemMessage) {
body.system = systemMessage.content;
}
break;
}
case "gemini": {
const systemMsg = messages.find((m) => m.role === "system");
const nonSystemMsgs = messages.filter((m) => m.role !== "system");
body = {
contents: nonSystemMsgs.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }]
}))
};
if (systemMsg) {
body.systemInstruction = { parts: [{ text: systemMsg.content }] };
}
if (jsonResponseExpected) {
body.generationConfig = { responseMimeType: "application/json" };
}
break;
}
case "grok":
case "perplexity":
body = { model, messages };
if (stream)
body.stream = stream;
if (tools)
body.tools = tools;
break;
case "deepseek": {
body = { model, messages };
if (stream)
body.stream = stream;
if (tools)
body.tools = tools;
body.frequency_penalty = frequencyPenaltyFromPromptKey(promptKey);
if (jsonResponseExpected) {
body.response_format = { type: "json_object" };
}
break;
}
case "openai":
default: {
body = { model, messages };
if (stream)
body.stream = stream;
if (tools)
body.tools = tools;
const supportsFrequencyPenalty = !model.match(/^(o\d|gpt-5)/);
if (supportsFrequencyPenalty) {
body.frequency_penalty = frequencyPenaltyFromPromptKey(promptKey);
}
if (jsonResponseExpected) {
body.response_format = { type: "json_object" };
}
break;
}
}
return body;
}
async function makeRequest(app, messages, model, {
attemptNumber = 1,
promptKey = null,
stream = null,
timeoutSeconds = TIMEOUT_SECONDS,
tools = null
} = {}) {
const providerEm = providerFromModel(model);
if (attemptNumber > 0)
console.debug(`Attempt #${attemptNumber}: Trying ${model} with ${promptKey || "no promptKey"}`);
const apiKey = apiKeyFromApp(app, providerEm);
const body = requestBodyForProvider(messages, model, stream, tools, { promptKey });
const endpoint = providerEndpointUrl(model, apiKey);
console.debug(`Calling ${providerEm} at ${endpoint} with body ${JSON.stringify(body)} at ${ new Date()}`);
const headers = headersForProvider(providerEm, apiKey);
const fetchResponse = await Promise.race([
fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body)
}),
new Promise(
(_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutSeconds * 1e3)
)
]);
if (!fetchResponse.ok) {
const err = new Error(`Request failed with status ${fetchResponse.status}`);
err.response = fetchResponse;
throw err;
}
return fetchResponse;
}
async function requestWithRetry(app, model, messages, tools, promptKey, streamCallback, allowResponse, {
suppressParallel = false,
retries = 3,
timeoutSeconds = TIMEOUT_SECONDS
} = {}) {
let error, response;
const providerEm = providerFromModel(model);
const providerName = providerNameFromProviderEm(providerEm);
const stream = !!streamCallback;
if (suppressParallel) {
for (let i = 0; i < retries; i++) {
try {
response = await makeRequest(
app,
messages,
model,
{ attemptNumber: i, promptKey, stream, timeoutSeconds, tools }
);
break;
} catch (e) {
error = e;
response = e.response;
console.log(`Attempt ${i + 1} failed with`, e, `at ${ new Date()}. Retrying...`);
}
}
} else {
const promises = Array.from(
{ length: retries },
(_, i) => makeRequest(app, messages, model, { attemptNumber: i, promptKey, stream, timeoutSeconds, tools }).catch((e) => {
console.log(`Parallel attempt ${i + 1} failed with`, e, `at ${ new Date()}`);
throw e;
})
);
try {
response = await Promise.any(promises);
} catch (aggregateError) {
error = aggregateError.errors?.[0] || aggregateError;
response = error?.response;
}
}
console.debug("Response from promises is", response, "specifically response?.ok", response?.ok);
if (response?.ok) {
return await responseFromStreamOrChunk(app, response, model, promptKey, streamCallback, allowResponse, { timeoutSeconds });
} else if (!response) {
app.alert(`Failed to call ${providerName}: ${error}`);
return null;
} else if (response.status === 401) {
app.alert(`Invalid ${providerName} API key. Please configure your ${providerName} key in plugin settings.`);
return null;
} else {
const result = await response.json();
console.error(`API error response from ${providerName}:`, result);
if (result && result.error) {
const errorMessage = result.error.message || JSON.stringify(result.error);
app.alert(`Failed to call ${providerName}: ${errorMessage}`);
return null;
}
}
}
function parseAnthropicStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected) {
let stop = false;
const incrementalContents = [];
const lines = decodedValue.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith("event:")) {
const eventType = line.substring(6).trim();
if (eventType === "message_stop") {
console.debug("Received message_stop from Anthropic");
stop = true;
break;
}
} else if (line.startsWith("data:")) {
try {
const data = JSON.parse(line.substring(5).trim());
if (data.type === "content_block_delta" && data.delta?.text) {
const content = data.delta.text;
incrementalContents.push(content);
receivedContent += content;
app.alert(receivedContent, {
actions: [{ icon: "pending", label: "Generating response" }],
preface: streamPrefaceString(aiModel, modelsQueriedArray, promptKey, jsonResponseExpected),
scrollToEnd: true
});
}
} catch (e) {
}
}
}
return { stop, incrementalContents, receivedContent };
}
function parseGeminiStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected, failedParseContent) {
let stop = false;
const incrementalContents = [];
const responses = decodedValue.split(/^data: /m).filter((s) => s.trim().length);
for (const jsonString of responses) {
if (jsonString.includes("[DONE]")) {
console.debug("Received [DONE] from Gemini");
stop = true;
break;
}
let jsonResponse;
({ failedParseContent, jsonResponse } = jsonResponseFromStreamChunk(jsonString, failedParseContent));
if (jsonResponse) {
const content = jsonResponse.candidates?.[0]?.content?.parts?.[0]?.text;
if (content) {
incrementalContents.push(content);
receivedContent += content;
app.alert(receivedContent, {
actions: [{ icon: "pending", label: "Generating response" }],
preface: streamPrefaceString(aiModel, modelsQueriedArray, promptKey, jsonResponseExpected),
scrollToEnd: true
});
} else if (jsonResponse.candidates?.[0]?.finishReason) {
console.log("Finishing Gemini stream for reason", jsonResponse.candidates[0].finishReason);
stop = true;
break;
}
}
}
return { stop, incrementalContents, receivedContent, failedParseContent };
}
function parseOpenAICompatibleStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected, failedParseContent) {
let stop = false;
const incrementalContents = [];
const responses = decodedValue.split(/^data: /m).filter((s) => s.trim().length);
for (const jsonString of responses) {
if (jsonString.includes("[DONE]")) {
console.debug("Received [DONE] from jsonString");
stop = true;
break;
}
let jsonResponse;
({ failedParseContent, jsonResponse } = jsonResponseFromStreamChunk(jsonString, failedParseContent));
if (jsonResponse) {
const content = jsonResponse.choices?.[0]?.delta?.content || jsonResponse.choices?.[0]?.delta?.tool_calls?.[0]?.function?.arguments;
if (content) {
incrementalContents.push(content);
receivedContent += content;
app.alert(receivedContent, {
actions: [{ icon: "pending", label: "Generating response" }],
preface: streamPrefaceString(aiModel, modelsQueriedArray, promptKey, jsonResponseExpected),
scrollToEnd: true
});
} else {
stop = !!jsonResponse?.finish_reason?.length || !!jsonResponse?.choices?.[0]?.finish_reason?.length;
if (stop) {
console.log("Finishing stream for reason", jsonResponse?.finish_reason || jsonResponse?.choices?.[0]?.finish_reason);
break;
}
}
}
}
return { stop, incrementalContents, receivedContent, failedParseContent };
}
function streamAccumulate2(modelsQueriedArray, promptKey, app, decodedValue, receivedContent, aiModel, jsonResponseExpected, failedParseContent) {
const providerEm = providerFromModel(aiModel);
let result;
switch (providerEm) {
case "anthropic":
result = parseAnthropicStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected);
break;
case "gemini":
result = parseGeminiStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected, failedParseContent);
break;
case "deepseek":
case "grok":
case "openai":
case "perplexity":
result = parseOpenAICompatibleStream(decodedValue, app, receivedContent, aiModel, modelsQueriedArray, promptKey, jsonResponseExpected, failedParseContent);
break;
default:
console.error(`Unknown provider for streaming: ${providerEm}`);
result = { stop: true, incrementalContents: [], receivedContent, failedParseContent };
}
return {
abort: result.stop,
failedParseContent: result.failedParseContent || null,
incrementalContents: result.incrementalContents,
receivedContent: result.receivedContent
};
}
var PROMPT_KEYS = [
"answer",
"answerSelection",
"complete",
"reviseContent",
"reviseText",
"rhyming",
"sortGroceriesText",
"sortGroceriesJson",
"suggestTasks",
"summarize",
"thesaurus"
];
async function contentfulPromptParams(app, noteUUID, promptKey, promptKeyParams, aiModel, { contentIndex = null, contentIndexText = null, inputLimit = null } = {}) {
let noteContent = "", noteName = "";
if (!inputLimit) {
inputLimit = isModelOllama(aiModel) ? OLLAMA_TOKEN_CHARACTER_LIMIT : modelTokenLimit(aiModel);
}
if (noteUUID) {
const note = await app.notes.find(noteUUID);
noteContent = await note.content();
noteName = note.name;
}
if (!Number.isInteger(contentIndex) && contentIndexText && noteContent) {
contentIndex = contentIndexFromParams(contentIndexText, noteContent);
}
let boundedContent = noteContent || "";
const longContent = useLongContentContext(promptKey);
const noteContentCharacterLimit = Math.min(inputLimit * 0.5, longContent ? 5e3 : 1e3);
boundedContent = boundedContent.replace(/<!--\s\{[^}]+\}\s-->/g, "");
if (noteContent && noteContent.length > noteContentCharacterLimit) {
boundedContent = relevantContentFromContent(noteContent, contentIndex, noteContentCharacterLimit);
}
const limitedLines = limitContextLines(aiModel, promptKey);
if (limitedLines && Number.isInteger(contentIndex)) {
boundedContent = relevantLinesFromContent(boundedContent, contentIndex);
}
return { ...promptKeyParams, noteContent: boundedContent, noteName };
}
function promptsFromPromptKey(promptKey, promptParams, rejectedResponses, aiModel) {
let messages = [];
if (tooDumbForExample(aiModel)) {
promptParams = { ...promptParams, suppressExample: true };
}
messages.push({ role: "system", content: systemPromptFromPromptKey(promptKey) });
const userPrompt = userPromptFromPromptKey(promptKey, promptParams);
if (Array.isArray(userPrompt)) {
userPrompt.forEach((content) => {
messages.push({ role: "user", content: truncate(content) });
});
} else {
messages.push({ role: "user", content: truncate(userPrompt) });
}
const substantiveRejectedResponses = rejectedResponses?.filter((rejectedResponse) => rejectedResponse?.length > 0);
if (substantiveRejectedResponses?.length) {
let message = REJECTED_RESPONSE_PREFIX;
substantiveRejectedResponses.forEach((rejectedResponse) => {
message += `* ${rejectedResponse}
`;
});
const multiple = substantiveRejectedResponses.length > 1;
message += `
Do NOT repeat ${multiple ? "any" : "the"} rejected response, ${multiple ? "these are" : "this is"} the WRONG RESPONSE.`;
messages.push({ role: "user", content: message });
}
return messages;
}
var SYSTEM_PROMPTS = {
defaultPrompt: "You are a helpful assistant that responds with markdown-formatted content.",
reviseContent: "You are a helpful assistant that revises markdown-formatted content, as instructed.",
reviseText: "You are a helpful assistant that revises text, as instructed.",
rhyming: "You are a helpful rhyming word generator that responds in JSON with an array of rhyming words",
sortGroceriesJson: "You are a helpful assistant that responds in JSON with sorted groceries using the 'instruction' key as a guide",
suggestTasks: "You are a Fortune 100 CEO that returns an array of insightful tasks within the 'result' key of a JSON response",
summarize: "You are a helpful assistant that summarizes notes that are markdown-formatted.",
thesaurus: "You are a helpful thesaurus that responds in JSON with an array of alternate word choices that fit the context provided"
};
function messageArrayFromPrompt(promptKey, promptParams) {
if (!PROMPT_KEYS.includes(promptKey))
throw `Please add "${promptKey}" to PROMPT_KEYS array`;
const userPrompts = {
answer: ({ instruction }) => [
`Succinctly answer the following question: ${instruction}`,
"Do not explain your answer. Do not mention the question that was asked. Do not include unnecessary punctuation."
],
answerSelection: ({ text }) => [text],
complete: ({ noteContent }) => `Continue the following markdown-formatted content:
${noteContent}`,
reviseContent: ({ noteContent, instruction }) => [instruction, noteContent],
reviseText: ({ instruction, text }) => [instruction, text],
rhyming: ({ noteContent, text }) => [
JSON.stringify({
instruction: `Respond with a JSON object containing ONLY ONE KEY called "result", that contains a JSON array of up to 10 rhyming words or phrases`,
rhymesWith: text,
rhymingWordContext: noteContent.replace(text, `<replace>${text}</replace>`),
example: { input: { rhymesWith: "you" }, response: { result: ["knew", "blue", "shoe", "slew", "shrew", "debut", "voodoo", "field of view", "kangaroo", "view"] } }
})
],
sortGroceriesText: ({ groceryArray }) => [
`Sort the following list of groceries by where it can be found in the grocery store:`,
`- [ ] ${groceryArray.join(`
- [ ]`)}`,
`Prefix each grocery aisle (each task section) with a "# ".
For example, if the input groceries were "Bananas", "Donuts", and "Bread", then the correct answer would be "# Produce
[ ] Bananas
# Bakery
[ ] Donuts
[ ] Bread"`,
`DO NOT RESPOND WITH ANY EXPLANATION, only groceries and aisles. Return the exact same ${groceryArray.length} groceries provided in the array, without additions or subtractions.`
],
sortGroceriesJson: ({ groceryArray }) => [
JSON.stringify({
instruction: `Respond with a JSON object, where the key is the aisle/department in which a grocery can be found, and the value is the array of groceries that can be found in that aisle/department.
Return the EXACT SAME ${groceryArray.length} groceries from the "groceries" key, without additions or subtractions.`,
groceries: groceryArray,
example: {
input: { groceries: [" Bananas", "Donuts", "Grapes", "Bread", "salmon fillets"] },
response: { "Produce": ["Bananas", "Grapes"], "Bakery": ["Donuts", "Bread"], "Seafood": ["salmon fillets"] }
}
})
],
suggestTasks: ({ chosenTasks, noteContent, noteName, text }) => {
const queryJson = {
instruction: `Respond with a JSON object that contains an array of 10 tasks that will be inserted at the <inserTasks> token in the provided markdown content`,
taskContext: `Title: ${noteName}
Content:
${noteContent.replace(text, `<insertTasks>`)}`,
example: {
input: { taskContext: `Title: Clean the house
Content:
- [ ] Mop the floors
<insertTasks>` },
response: {
result: [
"Dust the living room furniture",
"Fold and put away the laundry",
"Water indoor plants",
"Hang up any recent mail",
"Fold and put away laundry",
"Take out the trash & recycling",
"Wipe down bathroom mirrors & counter",
"Sweep the entry and porch",
"Organize the pantry",
"Vacuum"
]
}
}
};
if (chosenTasks) {
queryJson.alreadyAcceptedTasks = `The following tasks have been proposed and accepted already. DO NOT REPEAT THESE, but do suggest complementary tasks:
* ${chosenTasks.join("\n * ")}`;
}
return JSON.stringify(queryJson);
},
summarize: ({ noteContent }) => `Summarize the following markdown-formatted note:
${noteContent}`,
thesaurus: ({ noteContent, text }) => [
JSON.stringify({
instruction: `Respond with a JSON object containing ONLY ONE KEY called "result". The value for the "result" key should be a 10-element array of the best words or phrases to replace "${text}" while remaining consistent with the included "replaceWordContext" markdown document.`,
replaceWord: text,
replaceWordContext: noteContent.replace(text, `<replaceWord>${text}</replaceWord>`),
example: {
input: { replaceWord: "helpful", replaceWordContext: "Mother always said that I should be <replaceWord>helpful</replaceWord> with my coworkers" },
response: { result: ["useful", "friendly", "constructive", "cooperative", "sympathetic", "supportive", "kind", "considerate", "beneficent", "accommodating"] }
}
})
]
};
return userPrompts[promptKey]({ ...promptParams });
}
function userPromptFromPromptKey(promptKey, promptParams) {
let userPrompts;
if (["continue", "insertTextComplete", "replaceTextComplete"].find((key) => key === promptKey)) {
const { noteContent } = promptParams;
let tokenAndSurroundingContent;
if (promptKey === "replaceTextComplete") {
tokenAndSurroundingContent = promptParams.text;
} else {
const replaceToken = promptKey === "insertTextComplete" ? `${PLUGIN_NAME}: Complete` : `${PLUGIN_NAME}: Continue`;
console.debug("Note content", noteContent, "replace token", replaceToken);
tokenAndSurroundingContent = `~~~
${noteContent.replace(`{${replaceToken}}`, "<replaceToken>")}
~~~`;
}
userPrompts = [
`Respond with text that will replace <replaceToken> in the following input markdown document, delimited by ~~~:`,
tokenAndSurroundingContent,
`Your response should be grammatically correct and not repeat the markdown document. DO NOT explain your answer.`,
`Most importantly, DO NOT respond with <replaceToken> itself and DO NOT repeat word sequences from the markdown document. BE CONCISE.`
];
} else {
userPrompts = messageArrayFromPrompt(promptKey, promptParams);
if (promptParams.suppressExample && userPrompts[0]?.includes("example")) {
try {
const json = JSON.parse(userPrompts[0]);
delete json.example;
userPrompts[0] = JSON.stringify(json);
} catch (e) {
}
}
}
return userPrompts;
}
function relevantContentFromContent(content, contentIndex, contentLimit) {
if (content && content.length > contentLimit) {
if (!Number.isInteger(contentIndex)) {
const pluginNameIndex = content.indexOf(PLUGIN_NAME);
contentIndex = pluginNameIndex === -1 ? contentLimit * 0.5 : pluginNameIndex;
}
const startIndex = Math.max(0, Math.round(contentIndex - contentLimit * 0.75));
const endIndex = Math.min(content.length, Math.round(contentIndex + contentLimit * 0.25));
content = content.substring(startIndex, endIndex);
}
return content;
}
function relevantLinesFromContent(content, contentIndex) {
const maxContextLines = 4;
const lines = content.split("\n").filter((l) => l.length);
if (lines.length > maxContextLines) {
let traverseChar = 0;
let targetContentLine = lines.findIndex((line) => {
if (traverseChar + line.length > contentIndex)
return true;
traverseChar += line.length + 1;
});
if (targetContentLine >= 0) {
const startLine = Math.max(0, targetContentLine - Math.floor(maxContextLines * 0.75));
const endLine = Math.min(lines.length, targetContentLine + Math.floor(maxContextLines * 0.25));
console.debug("Submitting line index", startLine, "through", endLine, "of", lines.length, "lines");
content = lines.slice(startLine, endLine).join("\n");
}
}
return content;
}
function systemPromptFromPromptKey(promptKey) {
const systemPrompts = SYSTEM_PROMPTS;
return systemPrompts[promptKey] || systemPrompts.defaultPrompt;
}
function contentIndexFromParams(contentIndexText, noteContent) {
let contentIndex = null;
if (contentIndexText) {
contentIndex = noteContent.indexOf(contentIndexText);
}
if (contentIndex === -1)
contentIndex = null;
return contentIndex;
}
var MAX_CANDIDATE_MODELS = 3;
async function notePromptResponse(plugin2, app, noteUUID, promptKey, promptParams, {
preferredModels: preferredModels2 = null,
confirmInsert = true,
contentIndex = null,
rejectedResponses = null,
allowResponse = null,
contentIndexText
} = {}) {
preferredModels2 = preferredModels2 || await recommendedAiModels(plugin2, app, promptKey);
if (!preferredModels2.length)
return;
const startAt = new Date();
const { response, modelUsed } = await sendQuery(
plugin2,
app,
noteUUID,
promptKey,
promptParams,
{ allowResponse, contentIndex, contentIndexText, preferredModels: preferredModels2, rejectedResponses }
);
if (response === null) {
app.alert("Failed to receive a usable response from AI");
console.error("No result was returned from sendQuery with models", preferredModels2);
return;
}
if (confirmInsert) {
const actions = [];
preferredModels2.forEach((model) => {
const modelLabel = model.split(":")[0];
actions.push({ icon: "chevron_right", label: `Try ${modelLabel}${model === modelUsed ? " again" : ""}` });
});
const primaryAction = { icon: "check_circle", label: "Approve" };
let responseAsText = response, jsonResponse = false;
if (typeof response === "object") {
if (response.result?.length) {
responseAsText = "Results:\n* " + response.result.join("\n * ");
} else {
jsonResponse = true;
responseAsText = JSON.stringify(response);
}
}
const selectedValue = await app.alert(responseAsText, {
actions,
preface: `${jsonResponse ? "JSON response s" : "S"}uggested by ${modelUsed}
Will be utilized after your preliminary approval`,
primaryAction
});
console.debug("User chose", selectedValue, "from", actions);
if (selectedValue === -1) {
return response;
} else if (preferredModels2[selectedValue]) {
const preferredModel2 = preferredModels2[selectedValue];
const updatedRejects = rejectedResponses || [];
updatedRejects.push(responseAsText);
preferredModels2 = [preferredModel2, ...preferredModels2.filter((model) => model !== preferredModel2)];
console.debug("User chose to try", preferredModel2, "next so preferred models are", preferredModels2, "Rejected responses now", updatedRejects);
return await notePromptResponse(plugin2, app, noteUUID, promptKey, promptParams, {
confirmInsert,
contentIndex,
preferredModels: preferredModels2,
rejectedResponses: updatedRejects
});
} else if (Number.isInteger(selectedValue)) {
app.alert(`Did not recognize your selection "${selectedValue}"`);
}
} else {
const secondsUsed = Math.floor(( new Date() - startAt) / 1e3);
app.alert(`Finished generating ${response} response with ${modelUsed} in ${secondsUsed} second${secondsUsed === 1 ? "" : "s"}`);
return response;
}
}
async function recommendedAiModels(plugin2, app, promptKey) {
let candidateAiModels = [];
if (app.settings[plugin2.constants.labelAiModel]?.trim()) {
candidateAiModels = app.settings[plugin2.constants.labelAiModel].trim().split(",").map((w) => w.trim()).filter((n) => n);
}
if (plugin2.lastModelUsed && (!isModelOllama(plugin2.lastModelUsed) || plugin2.ollamaModelsFound?.includes(plugin2.lastModelUsed))) {
candidateAiModels.push(plugin2.lastModelUsed);
}
if (!plugin2.noFallbackModels) {
const ollamaModels = plugin2.ollamaModelsFound || !plugin2.noLocalModels && await ollamaAvailableModels(plugin2, app);
if (ollamaModels && !plugin2.ollamaModelsFound) {
plugin2.ollamaModelsFound = ollamaModels;
}
candidateAiModels = includingFallbackModels(plugin2, app, candidateAiModels);
if (!candidateAiModels.length) {
candidateAiModels = await aiModelFromUserIntervention(plugin2, app);
if (!candidateAiModels?.length)
return null;
}
}
if (["sortGroceriesJson"].includes(promptKey)) {
candidateAiModels = candidateAiModels.filter((m) => m.includes("gpt-4"));
}
return candidateAiModels.slice(0, MAX_CANDIDATE_MODELS);
}
async function sendQuery(plugin2, app, noteUUID, promptKey, promptParams, {
contentIndex = null,
contentIndexText = null,
preferredModels: preferredModels2 = null,
rejectedResponses = null,
allowResponse = null
} = {}) {
preferredModels2 = (preferredModels2 || await recommendedAiModels(plugin2, app, promptKey)).filter((n) => n);
console.debug("Starting to query", promptKey, "with preferredModels", preferredModels2);
let modelsQueried = [];
for (const aiModel of preferredModels2) {
const queryPromptParams = await contentfulPromptParams(
app,
noteUUID,
promptKey,
promptParams,
aiModel,
{ contentIndex, contentIndexText }
);
const messages = promptsFromPromptKey(promptKey, queryPromptParams, rejectedResponses, aiModel);
let response;
plugin2.callCountByModel[aiModel] = (plugin2.callCountByModel[aiModel] || 0) + 1;
plugin2.lastModelUsed = aiModel;
modelsQueried.push(aiModel);
try {
response = await responseFromPrompts(plugin2, app, aiModel, promptKey, messages, { allowResponse, modelsQueried });
} catch (e) {
console.error("Caught exception trying to make call with", aiModel, e);
}
if (response && (!allowResponse || allowResponse(response))) {
return { response, modelUsed: aiModel };
} else {
plugin2.errorCountByModel[aiModel] = (plugin2.errorCountByModel[aiModel] || 0) + 1;
console.error(`Failed to make call with "${aiModel}" response "${response}" while messages are "${messages}". Error counts`, plugin2.errorCountByModel);
}
}
if (modelsQueried.length && modelsQueried.find((m) => isModelOllama(m)) && !plugin2.noLocalModels) {
const availableModels = await ollamaAvailableModels(plugin2, app);
plugin2.ollamaModelsFound = availableModels;
console.debug("Found availableModels", availableModels, "after receiving no results in sendQuery. plugin.ollamaModelsFound is now", plugin2.ollamaModelsFound);
}
plugin2.lastModelUsed = null;
return { response: null, modelUsed: null };
}
function responseFromPrompts(plugin2, app, aiModel, promptKey, messages, { allowResponse = null, modelsQueried = null } = {}) {
if (isModelOllama(aiModel)) {
return callOllama(plugin2, app, aiModel, messages, promptKey, allowResponse, modelsQueried);
} else {
return callRemoteAI(plugin2, app, aiModel, messages, promptKey, allowResponse, modelsQueried);
}
}
async function aiModelFromUserIntervention(plugin2, app, { defaultProvider = "openai", optionSelected = null } = {}) {
const providerOptions = [
{ label: "Anthropic: Versatile provider most known for excellent coding models", value: "anthropic" },
{ label: "Google: Gemini has shown dramatic improvement over 2025", value: "gemini" },
{ label: "OpenAI: Popular all-around model. Offers image generation", value: "openai" },
{ label: "Grok: Elon is spending a lot of money to play catchup, is it working?", value: "grok" },
{ label: "DeepSeek: Chinese model good for deep thinking", value: "deepseek" },
{ label: "Ollama: best for experts who want high customization, or a free option", value: "ollama" }
];
const sortedConfiguredProviderEms = configuredProvidersSorted(app.settings);
const configuredProviderNames = sortedConfiguredProviderEms.map((providerEm) => providerNameFromProviderEm(providerEm));
for (const option of providerOptions) {
if (option.value !== "ollama" && sortedConfiguredProviderEms.includes(option.value)) {
const modelName = modelForProvider(app.settings[AI_MODEL_LABEL], option.value);
option.label += ` \u2705 Currently using ${modelName}`;
}
}
const promptText = configuredProviderNames.length ? `Configured providers: ${configuredProviderNames.join(", ")}` : NO_MODEL_FOUND_TEXT;
optionSelected = optionSelected || await app.prompt(promptText, {
inputs: [
{
type: "radio",
label: "Which AI provider would you like enable?",
options: providerOptions,
value: defaultProvider
}
]
});
if (optionSelected === "ollama") {
await app.alert(OLLAMA_INSTALL_TEXT);
return null;
}
if (REMOTE_AI_PROVIDER_EMS.includes(optionSelected)) {
const providerPrompt = PROVIDER_API_KEY_TEXT[optionSelected];
const existingKey = app.settings[PROVIDER_SETTING_KEY_LABELS[optionSelected]] || "";
const apiKey = await app.prompt(providerPrompt, { inputs: [{ label: "API Key", type: "string", value: existingKey }] });
const minKeyLength = MIN_API_KEY_CHARACTERS[optionSelected];
if (apiKey && apiKey.trim().length >= minKeyLength) {
const settingKey = PROVIDER_SETTING_KEY_LABELS[optionSelected];
await app.setSetting(settingKey, apiKey.trim());
app.settings[settingKey] = apiKey.trim();
return await promptForProviderPrecedence(app);
} else {
console.debug(`User entered invalid ${optionSelected} key`);
const nextStep = await app.alert(PROVIDER_INVALID_KEY_TEXT, { actions: [
{ icon: "settings", label: "Retry entering key" }
] });
console.debug("nextStep selected", nextStep);
if (nextStep === 0) {
return await aiModelFromUserIntervention(plugin2, app, { optionSelected });
}
return null;
}
}
return null;
}
function includingFallbackModels(plugin2, app, candidateAiModels) {
for (const providerEm of REMOTE_AI_PROVIDER_EMS) {
const providerSettingLabel = PROVIDER_SETTING_KEY_LABELS[providerEm];
if (app.settings[providerSettingLabel]?.length && !candidateAiModels.find((m) => m === PROVIDER_DEFAULT_MODEL[providerEm])) {
candidateAiModels.push(PROVIDER_DEFAULT_MODEL[providerEm]);
console.debug(`Added ${providerSettingLabel} model ${PROVIDER_DEFAULT_MODEL[providerEm]} to candidates`);
}
}
if (plugin2.ollamaModelsFound?.length) {
candidateAiModels = candidateAiModels.concat(plugin2.ollamaModelsFound.filter((m) => !candidateAiModels.includes(m)));
}
console.debug("Available models are", candidateAiModels);
return candidateAiModels;
}
async function promptForProviderPrecedence(app) {
const configuredProviderEms = configuredProvidersSorted(app.settings);
console.log("Found configuredProviderEms", configuredProviderEms, "from settings", app.settings[AI_MODEL_LABEL]);
if (configuredProviderEms.length === 0)
return [];
if (configuredProviderEms.length === 1) {
return app.settings[AI_MODEL_LABEL]?.length ? [app.settings[AI_MODEL_LABEL]] : [PROVIDER_DEFAULT_MODEL[configuredProviderEms[0]]];
}
const inputs = configuredProviderEms.map((providerEm, index) => ({
type: "string",
label: `${providerNameFromProviderEm(providerEm)} precedence`,
value: String(index + 1),
placeholder: "Enter number (1 = highest priority)"
}));
const promptText = "Set the priority for each AI provider (1 = highest priority, will be tried first)";
const results = await app.prompt(promptText, { inputs });
if (!results)
return null;
const providerPrecedence = [];
for (let i = 0; i < configuredProviderEms.length; i++) {
const providerEm = configuredProviderEms[i];
const precedenceValue = parseInt(results[i]) || i + 1;
providerPrecedence.push({ providerEm, precedence: precedenceValue });
}
providerPrecedence.sort((a, b) => a.precedence - b.precedence);
const sortedModels = providerPrecedence.map(({ providerEm }) => modelForProvider(app.settings[AI_MODEL_LABEL], providerEm));
app.setSetting(AI_MODEL_LABEL, sortedModels.join(", "));
return sortedModels;
}
async function initiateChat(plugin2, app, messageHistory = []) {
const aiModels = preferredModels(app);
let promptHistory;
if (messageHistory.length) {
promptHistory = messageHistory;
} else {
promptHistory = [{ content: "What's on your mind?", role: "assistant" }];
}
const modelsQueried = [];
while (true) {
const conversation = promptHistory.map((chat) => `${chat.role}: ${chat.content}`).join("\n\n");
console.debug("Prompting user for next message to send to", plugin2.lastModelUsed || aiModels[0]);
const [userMessage, modelToUse] = await app.prompt(conversation, {
inputs: [
{ type: "text", label: "Message to send" },
{
type: "radio",
label: "Send to",
options: aiModels.map((model) => ({ label: model, value: model })),
value: plugin2.lastModelUsed || aiModels[0]
}
]
}, { scrollToBottom: true });
if (modelToUse) {
promptHistory.push({ role: "user", content: userMessage });
modelsQueried.push(modelToUse);
const response = await responseFromPrompts(plugin2, app, modelToUse, "chat", promptHistory, { modelsQueried });
if (response) {
promptHistory.push({ role: "assistant", content: `[${modelToUse}] ${response}` });
const alertResponse = await app.alert(response, { preface: conversation, actions: [{ icon: "navigate_next", label: "Ask a follow up question" }] });
if (alertResponse === 0)
continue;
}
}
break;
}
console.debug("Finished chat with history", promptHistory);
}
function groceryArrayFromContent(content) {
const lines = content.split("\n");
const groceryLines = lines.filter((line) => line.match(/^[-*\[]\s/));
const groceryArray = groceryLines.map((line) => optionWithoutPrefix(line).replace(/<!--.*-->/g, "").trim());
return groceryArray;
}
async function groceryContentFromJsonOrText(plugin2, app, noteUUID, groceryArray) {
const jsonModels = await recommendedAiModels(plugin2, app, "sortGroceriesJson");
if (jsonModels.length) {
const confirmation = groceryCountJsonConfirmation.bind(null, groceryArray.length);
const jsonGroceries = await notePromptResponse(
plugin2,
app,
noteUUID,
"sortGroceriesJson",
{ groceryArray },
{ allowResponse: confirmation }
);
if (typeof jsonGroceries === "object") {
return noteContentFromGroceryJsonResponse(jsonGroceries);
}
} else {
const sortedListContent = await notePromptResponse(
plugin2,
app,
noteUUID,
"sortGroceriesText",
{ groceryArray },
{ allowResponse: groceryCountTextConfirmation.bind(null, groceryArray.length) }
);
if (sortedListContent?.length) {
return noteContentFromGroceryTextResponse(sortedListContent);
}
}
}
function noteContentFromGroceryJsonResponse(jsonGroceries) {
let text = "";
for (const aisle of Object.keys(jsonGroceries)) {
const groceries = jsonGroceries[aisle];
text += `# ${aisle}
`;
groceries.forEach((grocery) => {
text += `- [ ] ${grocery}
`;
});
text += "\n";
}
return text;
}
function noteContentFromGroceryTextResponse(text) {
text = text.replace(/^[\\-]{3,100}/g, "");
text = text.replace(/^([-\\*]|\[\s\])\s/g, "- [ ] ");
text = text.replace(/^[\s]*```.*/g, "");
return text.trim();
}
function groceryCountJsonConfirmation(originalCount, proposedJson) {
if (!proposedJson || typeof proposedJson !== "object")
return false;
const newCount = Object.values(proposedJson).reduce((sum, array) => sum + array.length, 0);
console.debug("Original list had", originalCount, "items, AI-proposed list appears to have", newCount, "items", newCount === originalCount ? "Accepting response" : "Rejecting response");
return newCount === originalCount;
}
function groceryCountTextConfirmation(originalCount, proposedContent) {
if (!proposedContent?.length)
return false;
const newCount = proposedContent.match(/^[-*\s]*\[[\s\]]+[\w]/gm)?.length || 0;
console.debug("Original list had", originalCount, "items, AI-proposed list appears to have", newCount, "items", newCount === originalCount ? "Accepting response" : "Rejecting response");
return newCount === originalCount;
}
async function imageFromPreceding(plugin2, app, apiKey) {
const note = await app.notes.find(app.context.noteUUID);
const noteContent = await note.content();
const promptIndex = noteContent.indexOf(`{${plugin2.constants.pluginName}: ${IMAGE_FROM_PRECEDING_LABEL}`);
const precedingContent = noteContent.substring(0, promptIndex).trim();
const prompt = precedingContent.split("\n").pop();
console.debug("Deduced prompt", prompt);
if (prompt?.trim()) {
try {
const markdown = await imageMarkdownFromPrompt(plugin2, app, prompt.trim(), apiKey, { note });
if (markdown) {
app.context.replaceSelection(markdown);
}
} catch (e) {
console.error("Error generating images from preceding text", e);
app.alert("There was an error generating images from preceding text:" + e);
}
} else {
app.alert("Could not determine preceding text to use as a prompt");
}
}
async function imageFromPrompt(plugin2, app, apiKey) {
const instruction = await app.prompt(IMAGE_GENERATION_PROMPT);
if (!instruction)
return;
const note = await app.notes.find(app.context.noteUUID);
const markdown = await imageMarkdownFromPrompt(plugin2, app, instruction, apiKey, { note });
if (markdown) {
app.context.replaceSelection(markdown);
}
}
async function sizeModelFromUser(plugin2, app, prompt) {
const [sizeModel, style] = await app.prompt(`Generating image for "${prompt.trim()}"`, {
inputs: [
{
label: "Model & Size",
options: [
{ label: "Dall-e-2 3x 512x512", value: "512x512~dall-e-2" },
{ label: "Dall-e-2 3x 1024x1024", value: "1024x1024~dall-e-2" },
{ label: "Dall-e-3 1x 1024x1024", value: "1024x1024~dall-e-3" },
{ label: "Dall-e-3 1x 1792x1024", value: "1792x1024~dall-e-3" },
{ label: "Dall-e-3 1x 1024x1792", value: "1024x1792~dall-e-3" }
],
type: "radio",
value: plugin2.lastImageModel || DALL_E_DEFAULT
},
{
label: "Style - Used by Dall-e-3 models only (Optional)",
options: [
{ label: "Vivid (default)", value: "vivid" },
{ label: "Natural", value: "natural" }
],
type: "select",
value: "vivid"
}
]
});
plugin2.lastImageModel = sizeModel;
const [size, model] = sizeModel.split("~");
return [size, model, style];
}
async function imageMarkdownFromPrompt(plugin2, app, prompt, apiKey, { note = null } = {}) {
if (!prompt) {
app.alert("Couldn't find a prompt to generate image from");
return null;
}
const [size, model, style] = await sizeModelFromUser(plugin2, app, prompt);
const jsonBody = { prompt, model, n: model === "dall-e-2" ? 3 : 1, size };
if (style && model === "dall-e-3")
jsonBody.style = style;
app.alert(`Generating ${jsonBody.n} image${jsonBody.n === 1 ? "" : "s"} for "${prompt.trim()}"...`);
const response = await fetch("https://api.openai.com/v1/images/generations", {
method: "POST",
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(jsonBody)
});
const result = await response.json();
const { data } = result;
if (data?.length) {
const urls = data.map((d) => d.url);
console.debug("Received options", urls, "at", new Date());
const radioOptions = urls.map((url) => ({ image: url, value: url }));
radioOptions.push({ label: "Regenerate image", value: "more" });
const chosenImageURL = await app.prompt(`Received ${urls.length} options`, {
inputs: [{
label: "Choose an image",
options: radioOptions,
type: "radio"
}]
});
if (chosenImageURL === "more") {
return imageMarkdownFromPrompt(plugin2, app, prompt, apiKey, { note });
} else if (chosenImageURL) {
console.debug("Fetching and uploading chosen URL", chosenImageURL);
const imageData = await fetchImageAsDataURL(chosenImageURL);
console.debug("Got", imageData ? imageData.length : "no", "length image data");
if (!note)
note = await app.notes.find(app.context.noteUUID);
console.debug("Got note", note, "to insert image into");
const ampleImageUrl = await note.attachMedia(imageData);
console.debug("Ample image URL returned as", ampleImageUrl, "returning it as image");
return ``;
}
return null;
} else {
return null;
}
}
async function fetchImageAsDataURL(url) {
const response = await fetch(`${CORS_PROXY}/${url}`);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target.result);
};
reader.onerror = function(event) {
reader.abort();
reject(event.target.error);
};
reader.readAsDataURL(blob);
});
}
var LLM_SCORE_BODY_CONTENT_LENGTH = 3e3;
var MAX_NOTES_PER_RANKING = 10;
var MIN_ACCEPT_SCORE = 8;
async function phase4_scoreAndRank(searchAgent, analyzedCandidates, criteria, userQuery) {
const previouslyRatedUuids = searchAgent.ratedNoteUuids || new Set();
const unratedCandidates = analyzedCandidates.filter((candidate) => !previouslyRatedUuids.has(candidate.uuid));
if (previouslyRatedUuids.size > 0 && unratedCandidates.length < analyzedCandidates.length) {
const excludedCount = analyzedCandidates.length - unratedCandidates.length;
console.log(`[Phase 4] Excluding ${excludedCount} already-rated notes from scoring`);
}
const candidatesToAnalyze = unratedCandidates.slice(0, MAX_DEEP_ANALYZED_NOTES);
searchAgent.emitProgress(`Phase 4: Ranking ${pluralize(candidatesToAnalyze.length, "result")}...`);
const batches = [];
for (let i = 0; i < candidatesToAnalyze.length; i += MAX_NOTES_PER_RANKING) {
batches.push(candidatesToAnalyze.slice(i, i + MAX_NOTES_PER_RANKING));
}
console.log(`[Phase 4] ${candidatesToAnalyze.length > MAX_NOTES_PER_RANKING ? "Split" : "Taking"} ${candidatesToAnalyze.length} candidates in ${pluralize(batches.length, "batch")} for final scoring`);
const batchResults = await Promise.all(
batches.map((batch) => scoreCandidateBatchWithRetry(searchAgent, batch, criteria, userQuery))
);
const rankedNotes = batchResults.flat();
const sortedNotes = rankedNotes.sort((a, b) => b.finalScore - a.finalScore);
const ratedUuids = sortedNotes.map((note) => note.uuid);
if (ratedUuids.length) {
searchAgent.recordRatedNoteUuids(ratedUuids);
}
console.log(`[Phase 4] Finished with ${sortedNotes.length} sorted notes, headlined by "${sortedNotes[0]?.name}"`);
return sortedNotes;
}
async function phase5_sanityCheck(searchAgent, rankedNotes, criteria, userQuery) {
searchAgent.emitProgress(`Phase 5: Verifying ${pluralize(rankedNotes.length, "result")}...`);
if (rankedNotes.length === 0) {
return searchAgent.handleNoResults(criteria);
}
const pruneResult = rankedNotesAfterRemovingPoorMatches(rankedNotes);
if (pruneResult.removedCount) {
rankedNotes = pruneResult.rankedNotes;
console.log(`[Phase 5] Pruned ${pluralize(pruneResult.removedCount, "low quality result")} (score < ${MIN_KEEP_RESULT_SCORE} or "poor match"), leaving ${pruneResult.rankedNotes.length} notes:`, rankedNotes.map((n) => debugData(n)));
} else {
console.log(`[Phase 5] No results pruned among ${rankedNotes.length} candidates:`, rankedNotes.map((n) => debugData(n)));
}
const topResult = rankedNotes[0];
const enoughDesiredResults = rankedNotes.length >= criteria.resultCount * 0.5;
const warrantsNextAttempt = !enoughDesiredResults && searchAgent.retryCount === 0;
if (topResult.finalScore >= MIN_ACCEPT_SCORE && warrantsNextAttempt) {
searchAgent.emitProgress(`[Phase 5] Found ${Math.min(topResult.finalScore, 10)}/10 match, returning up to ${pluralize(criteria.resultCount, "result")} (type ${typeof criteria.resultCount})`);
return searchAgent.formatResult(true, rankedNotes, criteria.resultCount);
} else if (warrantsNextAttempt) {
return searchAgent.nextSearchAttempt(userQuery, criteria, rankedNotes);
} else if (searchAgent.retryCount === searchAgent.maxRetries) {
searchAgent.emitProgress(`Search completed with ${pluralize(criteria.resultCount, "final result")}`);
return searchAgent.formatResult(!!rankedNotes.length, rankedNotes, criteria.resultCount);
}
const sanityPrompt = `
Original query: "${userQuery}"
Top recommended note:
- Title: "${topResult.name}"
- Score: ${topResult.finalScore}/10
- Tags: ${topResult.tags.join(", ") || "none"}${topResult.checks ? `
- Criteria checks: ${JSON.stringify(topResult.checks)}` : ""}
- Reasoning: ${topResult.scoreBreakdown.reasoning}
Does this genuinely seem like what the user is looking for?
Consider:
1. Does the title make sense given the query?
2. Is the score reasonable (>6.0 suggests good match)?
3. Are there obvious mismatches?
Return ONLY valid JSON:
{
"confident": true,
"concerns": null,
"suggestAction": "accept"
}
Or if not confident:
{
"confident": false,
"concerns": "Explanation of concern",
"suggestAction": "retry_broader" | "retry_narrower" | "insufficient_data"
}
`;
const sanityCheck = await searchAgent.llm(sanityPrompt, { jsonResponse: true });
if (sanityCheck.confident || searchAgent.retryCount >= searchAgent.maxRetries) {
searchAgent.emitProgress(`Search completed with ${pluralize(criteria.resultCount, "final result")}`);
return searchAgent.formatResult(true, rankedNotes, criteria.resultCount);
}
console.log(`Sanity check failed: ${sanityCheck.concerns}`);
if (sanityCheck.suggestAction === "retry_broader") {
return searchAgent.nextSearchAttempt(userQuery, criteria, rankedNotes);
} else {
searchAgent.retryCount++;
}
return searchAgent.formatResult(false, rankedNotes, criteria.resultCount);
}
function isTimeoutError(error) {
if (!error || !error.message)
return false;
const message = error.message.toLowerCase();
return message.includes("timeout");
}
function rankedNotesAfterRemovingPoorMatches(rankedNotes) {
const poorMatchRegex = /poor match/i;
const filteredRankedNotes = rankedNotes.filter((r) => {
const reasoning = r.scoreBreakdown && r.scoreBreakdown.reasoning ? r.scoreBreakdown.reasoning : "";
const hasPoorMatchLanguage = poorMatchRegex.test(reasoning);
return r.finalScore >= MIN_KEEP_RESULT_SCORE && !hasPoorMatchLanguage;
});
if (filteredRankedNotes.length && filteredRankedNotes.length < rankedNotes.length) {
return { rankedNotes: filteredRankedNotes, removedCount: rankedNotes.length - filteredRankedNotes.length };
}
return { rankedNotes, removedCount: 0 };
}
async function scoreCandidateBatch(searchAgent, candidates, criteria, userQuery) {
const now = new Date();
const scoringPrompt = `
You are scoring note search results. Original query: "${userQuery}"
Extracted criteria:
${JSON.stringify(criteria, null, 2)}
Score each candidate note 0-10 on these dimensions:
1. COHERENCE: Does the title & content of this note seem to generally match the user's query?
2. TITLE_RELEVANCE: How well does the note title match the search intent?
3. KEYWORD_DENSITY: How concentrated are the keywords in the content?
4. TAG_ALIGNMENT: Does it have relevant or preferred tags?
5. RECENCY: If the user specified recency requirement, does it meet that? If no user-specified requirement, score 10 for recency within a month of today (${now.toDateString()}), and scale down to 0 for candidates from 12+ months earlier.
Candidates to score:
${candidates.map((candidate) => `
UUID: ${candidate.uuid}
Title: "${candidate.name}"
Tags: ${candidate.tags?.join(", ") || "none"}
Updated: ${candidate.updated}
Body Content (ending with $END$): ${candidate.bodyContent.slice(0, LLM_SCORE_BODY_CONTENT_LENGTH)}
$END$
`).join("\n\n")}
Return ONLY valid JSON array with one entry per candidate, using the UUID to identify each:
[
{
"uuid": "the-candidate-uuid",
"coherence": 7,
"titleRelevance": 8,
"keywordDensity": 7,
"tagAlignment": 6,
"recency": 5,
"reasoning": "Brief explanation of why this note does or doesn't match. If it does not match, label it 'poor match'."
}
]
`;
const scores = await searchAgent.llm(scoringPrompt, { jsonResponse: true, timeoutSeconds: PHASE4_TIMEOUT_SECONDS });
const scoresArray = Array.isArray(scores) ? scores : [scores];
const candidatesByUuid = new Map(candidates.map((candidate) => [candidate.uuid, candidate]));
const weights = {
coherence: 0.25,
keywordDensity: 0.25,
recency: 0.1,
tagAlignment: 0.15,
titleRelevance: 0.25
};
return scoresArray.map((score) => {
const weightedLlmScore = Object.entries(weights).reduce((sum, [key, weight]) => {
const rawValue = score[key];
const value = Number(rawValue === void 0 || rawValue === null ? 0 : rawValue);
return sum + value * weight;
}, 0);
const note = candidatesByUuid.get(score.uuid);
if (note) {
score.keywordDensitySignal = Math.round(Math.min(RANK_MATCH_COUNT_CAP, note.keywordDensityEstimate || 1) * 0.2 * 10) / 10;
const finalScore = weightedLlmScore + score.keywordDensitySignal;
note.finalScore = Math.round(finalScore * 10) / 10;
note.scoreBreakdown = score;
note.reasoning = score.reasoning;
}
return note;
}).filter(Boolean);
}
async function scoreCandidateBatchWithRetry(searchAgent, candidates, criteria, userQuery) {
let lastError = null;
let lastElapsedSeconds = 0;
for (let attempt = 1; attempt <= MAX_PHASE4_TIMEOUT_RETRIES; attempt++) {
const attemptStartTime = Date.now();
try {
return await scoreCandidateBatch(searchAgent, candidates, criteria, userQuery);
} catch (error) {
lastError = error;
lastElapsedSeconds = Math.round((Date.now() - attemptStartTime) / 100) / 10;
if (isTimeoutError(error)) {
searchAgent.emitProgress(`Batch scoring timed out after ${lastElapsedSeconds}s (attempt ${attempt}/${MAX_PHASE4_TIMEOUT_RETRIES})`);
if (attempt < MAX_PHASE4_TIMEOUT_RETRIES) {
continue;
}
} else {
searchAgent.emitProgress(`Batch scoring failed, retrying once...`);
const retryStartTime = Date.now();
try {
return await scoreCandidateBatch(searchAgent, candidates, criteria, userQuery);
} catch (retryError) {
const retryElapsedSeconds = Math.round((Date.now() - retryStartTime) / 100) / 10;
searchAgent.emitProgress(`Batch scoring failed after ${retryElapsedSeconds}s retry ("${retryError}")`);
return [];
}
}
}
}
searchAgent.emitProgress(`Batch scoring failed after ${MAX_PHASE4_TIMEOUT_RETRIES} timeout retries, last timeout at ${lastElapsedSeconds}s ("${lastError}")`);
return [];
}
var MAX_SCORE_DISPLAY = 10;
async function createSearchSummaryNote(searchAgent, searchResult, searchCriteria, userQuery) {
const { notes } = searchResult;
try {
const modelUsed = preferredModel(searchAgent.app, searchAgent.lastModelUsed) || "unknown model";
const titlePrompt = `Create a brief, descriptive title (max 40 chars) for a search results note.
Search query: "${userQuery}"
Found: ${searchResult.found ? "Yes" : "No"}
Return ONLY the title text, nothing else.`;
const titleBase = await searchAgent.llm(titlePrompt);
const now = new Date();
const noteTitle = `${titleBase.trim()} (${modelUsed} queried at ${now.toLocaleDateString()})`;
let noteContent = "";
if (notes?.length) {
noteContent += `# Matched Notes (${notes.length === searchResult.maxResultCount ? "top " : ""}${pluralize(notes.length, "result")})
`;
noteContent += `| ***Note*** | ***Score (1-10)*** | ***Reasoning*** | ***Tags*** |
`;
noteContent += `| --- | --- | --- | --- |
`;
notes.forEach((note) => {
noteContent += `| [${note.name}](${note.url}) | ${Math.min(note.finalScore?.toFixed(1), MAX_SCORE_DISPLAY)} | ${note.reasoning || "N/A"} | ${note.tags && note.tags.length > 0 ? note.tags.join(", ") : "Not found"} |
`;
});
} else {
noteContent += `## No Results Found
No notes matched the search criteria.
`;
}
noteContent += `
# Search Inputs
**Query:** "${userQuery}"
`;
noteContent += `**Result summary:** ${searchResult.resultSummary}
`;
noteContent += `**Search pass count:** ${searchAgent.retryCount + 1}
`;
noteContent += `**Search criteria:**
`;
noteContent += "```json\n";
noteContent += JSON.stringify(searchCriteria, null, 2);
noteContent += "\n```\n";
const searchResultTag = searchAgent.summaryNoteTag();
const localUuid = await searchAgent.app.createNote(noteTitle.trim(), [searchResultTag].filter(Boolean));
const summaryNoteHandle = await searchAgent.app.findNote(localUuid);
console.log(`Created ${JSON.stringify(localUuid)} which translates to ${debugData(summaryNoteHandle)}`);
await searchAgent.app.replaceNoteContent(summaryNoteHandle, noteContent);
return {
uuid: summaryNoteHandle.uuid,
name: noteTitle.trim(),
url: noteUrlFromUUID(summaryNoteHandle.uuid)
};
} catch (error) {
console.error("Failed to create search summary note:", error);
return null;
}
}
var MAX_LENGTH_REDUCTION = 10;
var SearchCandidateNote = class {
#uuid;
constructor(uuid, name, tags, created, updated, { bodyContent = null } = {}) {
this.#uuid = uuid;
this.created = created;
this.name = name;
this.tags = tags || [];
this.updated = updated;
this.bodyContent = bodyContent?.slice(0, MAX_CHARACTERS_TO_SEARCH_BODY) || "";
this.originalContentLength = bodyContent ? bodyContent.length : 0;
this.keywordDensityEstimate = 0;
this.keywordDensityIncludesTagBoost = false;
this.preContentMatchScore = 0;
this.scorePerKeyword = {};
this.tagBoost = 0;
this.checks = {};
this.finalScore = 0;
this.reasoning = null;
this.scoreBreakdown = {};
}
get uuid() {
return this.#uuid;
}
get url() {
return `https://www.amplenote.com/notes/${this.#uuid}`;
}
static create(noteHandle) {
return new SearchCandidateNote(
noteHandle.uuid,
noteHandle.name,
noteHandle.tags,
noteHandle.created,
noteHandle.updated
);
}
calculateKeywordDensityEstimate(primaryKeywords, secondaryKeywords) {
let totalPoints = 0;
const titleLower = (this.name || "").toLowerCase();
const bodyLower = (this.bodyContent || "").toLowerCase();
const tagParts = tagHierarchyPartsFromTags(this.tags);
for (const keyword of primaryKeywords || []) {
const keywordLower = keyword.toLowerCase();
totalPoints += countMatches(titleLower, keywordLower) * KEYWORD_TITLE_PRIMARY_WEIGHT;
totalPoints += countMatches(bodyLower, keywordLower) * KEYWORD_BODY_PRIMARY_WEIGHT;
if (keywordContainsTagPart(keywordLower, tagParts)) {
totalPoints += KEYWORD_TAG_PRIMARY_WEIGHT;
}
}
for (const keyword of secondaryKeywords || []) {
const keywordLower = keyword.toLowerCase();
totalPoints += countMatches(titleLower, keywordLower) * KEYWORD_TITLE_SECONDARY_WEIGHT;
totalPoints += countMatches(bodyLower, keywordLower) * KEYWORD_BODY_SECONDARY_WEIGHT;
if (keywordContainsTagPart(keywordLower, tagParts)) {
totalPoints += KEYWORD_TAG_SECONDARY_WEIGHT;
}
}
const lengthReduction = Math.min(this.originalContentLength / KEYWORD_DENSITY_DIVISOR, MAX_LENGTH_REDUCTION);
this.keywordDensityIncludesTagBoost = Number.isFinite(this.tagBoost) && this.tagBoost > 0;
this.keywordDensityEstimate = this.tagBoost + totalPoints - lengthReduction;
}
setBodyContent(content) {
this.originalContentLength = content ? content.length : 0;
this.bodyContent = content ? content.slice(0, MAX_CHARACTERS_TO_SEARCH_BODY) : "";
}
ensureKeywordPreContentScore(isPrimary, keyword) {
const keywordLower = keyword.toLowerCase();
if (Number.isFinite(this.scorePerKeyword[keywordLower]))
return;
const nameLower = (this.name || "").toLowerCase();
const nameMatchScore = scoreFromNameMatch(keywordLower, nameLower, isPrimary);
const tagMatchScore = scoreFromTagMatches(keywordLower, this.tags, isPrimary);
const totalScore = Math.min(nameMatchScore + tagMatchScore, PRE_CONTENT_MAX_SCORE_PER_KEYWORD);
this.scorePerKeyword[keywordLower] = totalScore;
this.preContentMatchScore = Object.values(this.scorePerKeyword).reduce((sum, s) => sum + s, 0);
}
setTagBoost(boost) {
this.tagBoost = boost;
if (Number.isFinite(boost) && boost > 0 && !this.keywordDensityIncludesTagBoost) {
this.keywordDensityEstimate += boost;
this.keywordDensityIncludesTagBoost = true;
}
}
};
function scoreFromNameMatch(keywordLower, nameLower, isPrimary) {
if (!keywordLower || !nameLower)
return 0;
const matchIndex = nameLower.indexOf(keywordLower);
if (matchIndex === -1)
return 0;
const matchLength = keywordLower.length;
let rawScore = matchLength * (0.02 * matchLength + 0.1);
const minScore = isPrimary ? PRE_CONTENT_MIN_PRIMARY_SCORE : PRE_CONTENT_MIN_SECONDARY_SCORE;
rawScore = Math.max(rawScore, minScore);
rawScore = Math.min(rawScore, PRE_CONTENT_MAX_SCORE_PER_KEYWORD);
if (!isPrimary) {
rawScore = rawScore * PRE_CONTENT_SECONDARY_MULTIPLIER;
}
return rawScore;
}
function scoreFromTagMatches(keywordLower, tags, isPrimary) {
if (!keywordLower || !tags || !tags.length)
return 0;
let totalScore = 0;
const minMatchLengthForWordScore = 4;
for (const hierarchicalTagString of tags) {
const normalizedTag = hierarchicalTagString.replace(/[/\-]/g, " ");
if (normalizedTag.includes(keywordLower)) {
const matchLength = keywordLower.length;
let rawScore = matchLength * (0.02 * matchLength + 0.1);
const minScore = isPrimary ? PRE_CONTENT_MIN_PRIMARY_SCORE : PRE_CONTENT_MIN_SECONDARY_SCORE;
rawScore = Math.max(rawScore, minScore);
rawScore = Math.min(rawScore, PRE_CONTENT_MAX_SCORE_PER_KEYWORD);
if (!isPrimary) {
rawScore = rawScore * PRE_CONTENT_SECONDARY_MULTIPLIER;
}
totalScore += rawScore;
continue;
}
const tagHierarchyParts = hierarchicalTagString.split("/");
for (const tagHierarchyPart of tagHierarchyParts) {
if (tagHierarchyPart.startsWith(keywordLower)) {
const wordScore = isPrimary ? PRE_CONTENT_TAG_WORD_PRIMARY_SCORE : PRE_CONTENT_TAG_WORD_SECONDARY_SCORE;
totalScore += wordScore;
}
}
}
return totalScore;
}
function countMatches(text, keyword) {
if (!text || !keyword)
return 0;
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?:^|\\b|\\s)${escapedKeyword}`, "gi");
const matches = text.match(pattern);
return matches ? matches.length : 0;
}
function keywordContainsTagPart(keywordLower, tagParts) {
for (const tagPart of tagParts) {
if (keywordLower.includes(tagPart)) {
return true;
}
}
return false;
}
function tagHierarchyPartsFromTags(tags) {
const parts = new Set();
for (const tag of tags || []) {
const segments = tag.split("/");
for (const segment of segments) {
const trimmed = segment.trim().toLowerCase();
if (trimmed) {
parts.add(trimmed);
}
}
}
return Array.from(parts);
}
function normalizedTagFromTagName(tagName) {
if (!tagName)
return null;
if (typeof tagName !== "string")
return null;
return tagName.toLowerCase().replace(/[^a-z0-9/]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
}
function requiredTagsFromTagRequirement(tagRequirement) {
if (!tagRequirement)
return [];
const mustHave = tagRequirement.mustHave;
if (!mustHave)
return [];
if (Array.isArray(mustHave)) {
return mustHave.map((t) => normalizedTagFromTagName(t)).filter(Boolean);
}
const normalizedTag = normalizedTagFromTagName(mustHave);
return normalizedTag ? [normalizedTag] : [];
}
async function phase2_collectCandidates(searchAgent, criteria) {
const startAt = new Date();
const { dateFilter, primaryKeywords, resultCount, secondaryKeywords, tagRequirement } = criteria;
searchAgent.emitProgress(`Phase 2: Now gathering result candidates from ${pluralize(primaryKeywords?.length || 0, "primary keyword")} and ${pluralize(secondaryKeywords?.length || 0, "secondary keyword")}...`);
let candidates;
if (primaryKeywords?.length) {
candidates = await executeSearchStrategy(primaryKeywords, resultCount, searchAgent, secondaryKeywords, tagRequirement);
} else {
return [];
}
if (dateFilter) {
const dateField = dateFilter.type === "created" ? "created" : "updated";
const afterDate = new Date(dateFilter.after);
candidates = candidates.filter((note) => {
const noteDate = new Date(note[dateField]);
return noteDate >= afterDate;
});
console.log(`After date filter: ${candidates.length} candidates`);
}
if (candidates.length) {
candidates.forEach((note) => {
let tagBoost = 1;
if (tagRequirement.preferred && note.tags) {
const hasPreferredTag = note.tags.some(
(tag) => tag === tagRequirement.preferred || tag.startsWith(tagRequirement.preferred + "/")
);
if (hasPreferredTag)
tagBoost = 1.5;
}
note.setTagBoost(tagBoost);
});
}
searchAgent.emitProgress(`Found ${candidates.length} candidate notes in ${Math.round(( new Date() - startAt) / 100) / 10}s`);
return candidates;
}
async function addMatchesFromSearchNotes(candidatesByUuid, isPrimary, keywords, searchAgent) {
let notesFound = [];
for (let i = 0; i < keywords.length; i += MAX_SEARCH_CONCURRENCY) {
const batch = keywords.slice(i, i + MAX_SEARCH_CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (keyword) => {
let results = await searchAgent.app.searchNotes(keyword);
if (!results.length)
return { keyword, notes: [] };
results = await filterNotesWithBody(keyword, results);
results = eligibleNotesFromResults(results, searchAgent);
if (results.length)
notesFound = notesFound.concat(results);
return { keyword, notes: uniqueUuidNoteCandidatesFromNotes(results) };
})
);
for (const { keyword, notes: perKeywordNotes } of batchResults) {
const cappedNotes = perKeywordNotes.slice(0, MAX_CANDIDATES_PER_KEYWORD);
if (perKeywordNotes.length > MAX_CANDIDATES_PER_KEYWORD) {
console.log(`[searchNotes] Query "${keyword}" returned ${perKeywordNotes.length} notes, capping to ${MAX_CANDIDATES_PER_KEYWORD}`);
}
for (const noteHandle of cappedNotes) {
upsertCandidate(candidatesByUuid, isPrimary, noteHandle, keyword);
}
}
}
const uniqueNotes = notesFound.filter((n, i) => notesFound.indexOf(n) === i);
console.log(`[searchNotes] Searching note bodies with`, keywords, `found ${uniqueNotes.length} unique note(s)`, uniqueNotes.map((n) => debugData(n)));
}
async function collectCandidatesWithKeywordLimits(candidatesByUuid, isPrimary, keywords, searchAgent, tagRequirement) {
const maxedOutKeywords = [];
const requiredTags = requiredTagsFromTagRequirement(tagRequirement);
const tagFilter = requiredTags.length === 1 ? requiredTags[0] : null;
let notesFound = [];
for (const keyword of keywords) {
let keywordNoteMatches;
if (tagFilter) {
keywordNoteMatches = await searchAgent.app.filterNotes({ query: keyword, tag: tagFilter });
} else {
keywordNoteMatches = await searchAgent.app.filterNotes({ query: keyword });
}
keywordNoteMatches = eligibleNotesFromResults(keywordNoteMatches, searchAgent);
if (keywordNoteMatches.length >= MAX_CANDIDATES_PER_KEYWORD) {
console.log(`Keyword "${keyword}" returned ${keywordNoteMatches.length} notes (>= ${MAX_CANDIDATES_PER_KEYWORD}), capping contribution and marking as maxed out`);
keywordNoteMatches = keywordNoteMatches.slice(0, MAX_CANDIDATES_PER_KEYWORD);
maxedOutKeywords.push(keyword);
}
notesFound = notesFound.concat(keywordNoteMatches);
for (const note of uniqueUuidNoteCandidatesFromNotes(keywordNoteMatches)) {
upsertCandidate(candidatesByUuid, isPrimary, note, keyword);
}
}
const uniqueNotes = notesFound.filter((n, i) => notesFound.indexOf(n) === i);
console.log(`[filterNotes with limits] Collected ${pluralize(uniqueNotes.length, "unique note")} from ${pluralize(keywords.length, "keyword")}, candidatesByUuid size: ${candidatesByUuid.size}`);
return maxedOutKeywords;
}
function eligibleNotesFromResults(notes, searchAgent) {
let filtered = notes || [];
const summaryNoteTagToExclude = searchAgent.summaryNoteTag();
if (summaryNoteTagToExclude) {
filtered = filtered.filter((note) => {
if (!note.tags)
return true;
return !note.tags.some(
(tag) => tag === summaryNoteTagToExclude || tag.startsWith(summaryNoteTagToExclude + "/")
);
});
}
if (filtered.length > MAX_NOTES_PER_QUERY) {
filtered = filtered.slice(0, MAX_NOTES_PER_QUERY);
}
return filtered;
}
function activeKeywordsForStrategy(primaryKeywords, searchAgent, secondaryKeywords) {
const secondaryKeywordsForQuerying = (secondaryKeywords || []).slice(0, MAX_SECONDARY_KEYWORDS_TO_QUERY);
const previouslyMaxedKeywords = searchAgent.maxedOutKeywords || new Set();
let activePrimaryKeywords;
let activeSecondaryKeywords;
if (searchAgent.searchAttempt === ATTEMPT_FIRST_PASS) {
activePrimaryKeywords = [...primaryKeywords];
activeSecondaryKeywords = [...secondaryKeywordsForQuerying];
} else {
activePrimaryKeywords = wordsFromMultiWordKeywords(primaryKeywords);
activeSecondaryKeywords = wordsFromMultiWordKeywords(secondaryKeywordsForQuerying);
}
if (previouslyMaxedKeywords.size) {
const originalPrimaryCount = activePrimaryKeywords.length;
const originalSecondaryCount = activeSecondaryKeywords.length;
activePrimaryKeywords = activePrimaryKeywords.filter((keyword) => !previouslyMaxedKeywords.has(keyword.toLowerCase()));
activeSecondaryKeywords = activeSecondaryKeywords.filter((keyword) => !previouslyMaxedKeywords.has(keyword.toLowerCase()));
const excludedPrimaryCount = originalPrimaryCount - activePrimaryKeywords.length;
const excludedSecondaryCount = originalSecondaryCount - activeSecondaryKeywords.length;
if (excludedPrimaryCount > 0 || excludedSecondaryCount > 0) {
console.log(`[activeKeywordsForStrategy] Excluding ${excludedPrimaryCount} primary and ${excludedSecondaryCount} secondary keywords that maxed out in previous attempts`);
}
}
return { activePrimaryKeywords, activeSecondaryKeywords };
}
async function collectSearchCandidates(candidatesByUuid, minTargetResults, primaryKeywords, searchAgent, secondaryKeywords, tagRequirement) {
const strategyName = searchAgent.searchAttempt;
const maxedOutPrimaryKeywords = await collectCandidatesWithKeywordLimits(candidatesByUuid, true, primaryKeywords, searchAgent, tagRequirement);
let activeSecondaryKeywords = filterKeywordsContainingMaxedOut(secondaryKeywords, maxedOutPrimaryKeywords);
let maxedOutSecondaryKeywords = [];
if (candidatesByUuid.size < minTargetResults && activeSecondaryKeywords.length) {
console.log(`[${strategyName}] Below minimum target (${minTargetResults}), broadening filterNotes with ${activeSecondaryKeywords.length} secondary keywords`);
maxedOutSecondaryKeywords = await collectCandidatesWithKeywordLimits(
candidatesByUuid,
false,
activeSecondaryKeywords,
searchAgent,
tagRequirement
);
} else if (activeSecondaryKeywords.length) {
console.log(`[${strategyName}] Skipping ${activeSecondaryKeywords.length} secondary keyword filterNotes since primary keywords yielded ${candidatesByUuid.size} candidates`);
}
const maxedOutKeywords = [...maxedOutPrimaryKeywords, ...maxedOutSecondaryKeywords];
if (maxedOutKeywords.length) {
console.log(`[${strategyName}] Keywords that hit contribution limit: ${maxedOutKeywords.join(", ")}`);
searchAgent.recordMaxedOutKeywords(maxedOutKeywords);
}
if (candidatesByUuid.size < minTargetResults) {
console.log(`[${strategyName}] Too few candidates (${candidatesByUuid.size}) below minimum (${minTargetResults}), supplementing with app.searchNotes`);
const remainingPrimaryKeywords = filterKeywordsContainingMaxedOut(primaryKeywords, maxedOutKeywords);
if (remainingPrimaryKeywords.length) {
await addMatchesFromSearchNotes(candidatesByUuid, true, remainingPrimaryKeywords, searchAgent);
}
if (candidatesByUuid.size < minTargetResults) {
const remainingSecondaryKeywords = filterKeywordsContainingMaxedOut(activeSecondaryKeywords, maxedOutKeywords);
if (remainingSecondaryKeywords.length) {
console.log(`[${strategyName}] Searching ${remainingSecondaryKeywords.length} secondary keywords with app.searchNotes since we only located ${candidatesByUuid.size} candidates so far`);
await addMatchesFromSearchNotes(candidatesByUuid, false, remainingSecondaryKeywords, searchAgent);
}
}
} else {
console.log(`[${strategyName}] Searched ${primaryKeywords.length} primary filterNotes queries: ${candidatesByUuid.size} unique candidates`);
}
}
async function densitySortedCandidates(candidates, primaryKeywords, searchAgent, secondaryKeywords) {
const strategyName = searchAgent.searchAttempt;
const sortedByPreContentScore = candidates.sort((a, b) => b.preContentMatchScore - a.preContentMatchScore).slice(0, MAX_CANDIDATES_FOR_DENSITY_CALCULATION);
if (candidates.length > sortedByPreContentScore.length) {
console.log(`[${strategyName}] Limiting from ${candidates.length} to ${sortedByPreContentScore.length} candidates for density calculation (top preContentMatchScore). Cutoff note was`, sortedByPreContentScore[sortedByPreContentScore.length - 1]);
}
const densityPromises = sortedByPreContentScore.map(async (candidate) => {
try {
const content = await searchAgent.app.getNoteContent({ uuid: candidate.uuid });
candidate.setBodyContent(content);
candidate.calculateKeywordDensityEstimate(primaryKeywords, secondaryKeywords);
} catch (error) {
candidate.calculateKeywordDensityEstimate(primaryKeywords, secondaryKeywords);
}
});
await Promise.all(densityPromises);
const finalResults = sortedByPreContentScore.sort((a, b) => {
const densityDiff = (b.keywordDensityEstimate || 0) - (a.keywordDensityEstimate || 0);
if (densityDiff !== 0)
return densityDiff;
const bUpdated = b.updated ? new Date(b.updated).getTime() : 0;
const aUpdated = a.updated ? new Date(a.updated).getTime() : 0;
return bUpdated - aUpdated;
});
console.log(`Calculated keyword density estimates for ${finalResults.length} candidates`, finalResults.map((n) => debugData(n)));
return finalResults;
}
async function executeSearchStrategy(primaryKeywords, resultCount, searchAgent, secondaryKeywords, tagRequirement) {
if (!primaryKeywords?.length)
return [];
const effectiveResultCount = resultCount || DEFAULT_SEARCH_NOTES_RETURNED;
const minTargetResults = Math.max(MIN_PHASE2_TARGET_CANDIDATES, effectiveResultCount);
const { activePrimaryKeywords, activeSecondaryKeywords } = activeKeywordsForStrategy(primaryKeywords, searchAgent, secondaryKeywords);
if (!activePrimaryKeywords.length) {
console.log(`[executeSearchStrategy] No active primary keywords after filtering, returning empty`);
return [];
}
const candidatesByUuid = new Map();
await collectSearchCandidates(
candidatesByUuid,
minTargetResults,
activePrimaryKeywords,
searchAgent,
activeSecondaryKeywords,
tagRequirement
);
const allCandidates = Array.from(candidatesByUuid.values());
return densitySortedCandidates(allCandidates, primaryKeywords, searchAgent, secondaryKeywords);
}
function filterKeywordsContainingMaxedOut(keywords, maxedOutKeywords) {
if (!maxedOutKeywords || !maxedOutKeywords.length)
return keywords;
return keywords.filter((keyword) => {
const lowerKeyword = keyword.toLowerCase();
return !maxedOutKeywords.some((maxed) => lowerKeyword.includes(maxed.toLowerCase()));
});
}
async function filterNotesWithBody(keyword, resultNotes) {
const pattern = new RegExp(`(?:^|\\b|\\s)${keyword}`, "i");
const eligibleNotes = await resultNotes.filter(async (note) => {
if (pattern.test(note.name))
return true;
return pattern.test(note.bodyContent);
});
if (eligibleNotes.length !== resultNotes.length) {
console.log(`Enforcing presence of "${keyword}" yields ${eligibleNotes.length} eligible notes from ${resultNotes.length} input notes`);
}
return eligibleNotes;
}
function uniqueUuidNoteCandidatesFromNotes(notes) {
const seen = new Set();
const unique = [];
for (const note of notes || []) {
if (!note?.uuid)
continue;
if (seen.has(note.uuid))
continue;
seen.add(note.uuid);
unique.push(note);
}
return unique;
}
function upsertCandidate(candidatesByUuid, isPrimary, noteHandle, queryKeyword) {
let candidate = candidatesByUuid.get(noteHandle.uuid);
if (!candidate) {
candidate = SearchCandidateNote.create(noteHandle);
candidatesByUuid.set(noteHandle.uuid, candidate);
}
candidate.ensureKeywordPreContentScore(isPrimary, queryKeyword);
}
function wordsFromMultiWordKeywords(keywords) {
const allWords = [];
for (const keyword of keywords) {
const words = keyword.split(/\s+/).filter((word) => word.length > 0);
for (const word of words) {
if (!allWords.includes(word)) {
allWords.push(word);
}
}
}
return allWords;
}
async function phase3_criteriaConfirm(searchAgent, candidates, criteria) {
searchAgent.emitProgress("Phase 3: Analyzing top candidates...");
const preliminaryRanked = rankPreliminary(candidates);
const topCandidates = preliminaryRanked.slice(0, MAX_DEEP_ANALYZED_NOTES);
if (!hasDeepAnalysisCriteria(criteria)) {
console.log("No exclusionary criteria specified, skipping criteria confirmation phase");
return { validCandidates: topCandidates, allAnalyzed: topCandidates };
}
console.log(`Criteria analyzing top ${candidates.length} candidates`);
const criteriaAnalyzedNotes = await searchAgent.parallelLimit(
topCandidates.map((note) => () => analyzeNoteCriteriaMatch(note, searchAgent, criteria)),
MAX_SEARCH_CONCURRENCY
);
if (criteriaAnalyzedNotes.length !== topCandidates.length) {
if (searchAgent.plugin.constants.isTestEnvironment) {
throw new Error("Deep analyzed notes count mismatch in test environment");
} else {
console.warn("Warning: Deep analyzed notes count mismatch:", { expected: topCandidates.length, actual: criteriaAnalyzedNotes.length });
}
}
const validCandidates = criteriaAnalyzedNotes.filter((note) => {
const { checks } = note;
const requiredTags = requiredTagsFromTagRequirement(criteria.tagRequirement);
if (criteria.booleanRequirements.containsPDF && !checks.hasPDF)
return false;
if (criteria.booleanRequirements.containsImage && !checks.hasImage)
return false;
if (criteria.booleanRequirements.containsURL && !checks.hasURL)
return false;
if (criteria.exactPhrase && !checks.hasExactPhrase)
return false;
if (requiredTags.length && !checks.hasRequiredTags)
return false;
return true;
});
console.log(`${validCandidates.length} candidates passed criteria checks among ${candidates.length} candidates`);
searchAgent.emitProgress(`${validCandidates.length} notes match all criteria`);
return { validCandidates, allAnalyzed: criteriaAnalyzedNotes };
}
async function analyzeNoteCriteriaMatch(noteCandidate, searchAgent, searchParams) {
const checks = {};
const needAttachments = searchParams.booleanRequirements.containsPDF;
const needImages = searchParams.booleanRequirements.containsImage;
const requiredTags = requiredTagsFromTagRequirement(searchParams.tagRequirement);
const fetches = [];
if (needAttachments) {
fetches.push(
searchAgent.app.notes.find(noteCandidate.uuid).then((n) => n.attachments()).then((attachments) => {
checks.hasPDF = attachments.some((a) => a.type === "application/pdf" || a.name.endsWith(".pdf"));
checks.attachmentCount = attachments.length;
})
);
}
if (needImages) {
fetches.push(
searchAgent.app.notes.find(noteCandidate.uuid).then((n) => n.images()).then((images) => {
checks.hasImage = images.length > 0;
checks.imageCount = images.length;
})
);
}
if (searchParams.exactPhrase) {
checks.hasExactPhrase = noteCandidate.bodyContent.includes(searchParams.exactPhrase);
}
if (searchParams.criteria.containsURL) {
checks.hasURL = /https?:\/\/[^\s]+/.test(noteCandidate.bodyContent);
const urls = noteCandidate.bodyContent.match(/https?:\/\/[^\s]+/g);
checks.urlCount = urls ? urls.length : 0;
}
if (requiredTags.length) {
const tagCheck = requiredTagCheckFromNoteTags(noteCandidate.tags, requiredTags);
checks.hasRequiredTags = tagCheck.hasAllRequiredTags;
checks.missingRequiredTags = tagCheck.missingRequiredTags;
}
await Promise.all(fetches);
noteCandidate.checks = checks;
return noteCandidate;
}
function hasDeepAnalysisCriteria(criteria) {
const requiredTags = requiredTagsFromTagRequirement(criteria.tagRequirement);
return criteria.booleanRequirements.containsPDF || criteria.booleanRequirements.containsImage || criteria.booleanRequirements.containsURL || criteria.exactPhrase || requiredTags.length;
}
function rankPreliminary(noteCandidates) {
return [...noteCandidates].sort((a, b) => {
const densityDiff = (b.keywordDensityEstimate || 0) - (a.keywordDensityEstimate || 0);
if (densityDiff !== 0)
return densityDiff;
const bUpdated = b.updated ? new Date(b.updated).getTime() : 0;
const aUpdated = a.updated ? new Date(a.updated).getTime() : 0;
return bUpdated - aUpdated;
});
}
function requiredTagCheckFromNoteTags(noteTags, requiredTags) {
const normalizedNoteTags = Array.isArray(noteTags) ? noteTags : [];
const normalizedRequiredTags = (Array.isArray(requiredTags) ? requiredTags : []).map((t) => normalizedTagFromTagName(t)).filter(Boolean);
const missingRequiredTags = [];
for (const requiredTag of normalizedRequiredTags) {
const hasTag = normalizedNoteTags.some((noteTag) => {
return noteTag === requiredTag || noteTag.startsWith(requiredTag + "/");
});
if (!hasTag)
missingRequiredTags.push(requiredTag);
}
return { hasAllRequiredTags: missingRequiredTags.length === 0, missingRequiredTags };
}
var UserCriteria = class {
constructor(options = {}) {
this.primaryKeywords = options.primaryKeywords || [];
this.secondaryKeywords = options.secondaryKeywords || [];
this.exactPhrase = options.exactPhrase || null;
this.booleanRequirements = {
containsPDF: options.criteria?.containsPDF || false,
containsImage: options.criteria?.containsImage || false,
containsURL: options.criteria?.containsURL || false
};
this.dateFilter = options.dateFilter || null;
this.tagRequirement = {
mustHave: options.tagRequirement?.mustHave || null,
preferred: options.tagRequirement?.preferred || null
};
this.resultCount = options.resultCount || DEFAULT_SEARCH_NOTES_RETURNED;
}
get criteria() {
return this.booleanRequirements;
}
set criteria(value) {
this.booleanRequirements = value;
}
withOverrides(overrides = {}) {
return new UserCriteria({
primaryKeywords: overrides.primaryKeywords || this.primaryKeywords,
secondaryKeywords: overrides.secondaryKeywords || this.secondaryKeywords,
exactPhrase: overrides.exactPhrase !== void 0 ? overrides.exactPhrase : this.exactPhrase,
criteria: overrides.criteria || this.booleanRequirements,
dateFilter: overrides.dateFilter !== void 0 ? overrides.dateFilter : this.dateFilter,
tagRequirement: overrides.tagRequirement || this.tagRequirement,
resultCount: overrides.resultCount || this.resultCount
});
}
static normalizeTag(tag) {
if (!tag)
return tag;
if (Array.isArray(tag)) {
return tag.map((t) => UserCriteria.normalizeTag(t)).filter(Boolean);
}
return normalizedTagFromTagName(tag);
}
static fromExtracted(extracted, options = {}) {
const extractedTagReq = {
mustHave: UserCriteria.normalizeTag(extracted.tagRequirement?.mustHave),
preferred: UserCriteria.normalizeTag(extracted.tagRequirement?.preferred)
};
const optionsTagReq = {
mustHave: UserCriteria.normalizeTag(options.tagRequirement?.mustHave),
preferred: UserCriteria.normalizeTag(options.tagRequirement?.preferred)
};
return new UserCriteria({
primaryKeywords: options.primaryKeywords || extracted.primaryKeywords || [],
secondaryKeywords: options.secondaryKeywords || extracted.secondaryKeywords || [],
exactPhrase: options.exactPhrase !== void 0 ? options.exactPhrase : extracted.exactPhrase,
criteria: options.criteria ? { ...extracted.criteria, ...options.criteria } : extracted.criteria,
dateFilter: options.dateFilter !== void 0 ? options.dateFilter : extracted.dateFilter,
tagRequirement: { ...extractedTagReq, ...optionsTagReq },
resultCount: options.resultCount || extracted.resultCount || DEFAULT_SEARCH_NOTES_RETURNED
});
}
toJSON() {
return {
primaryKeywords: this.primaryKeywords,
secondaryKeywords: this.secondaryKeywords,
exactPhrase: this.exactPhrase,
booleanRequirements: this.booleanRequirements,
dateFilter: this.dateFilter,
tagRequirement: this.tagRequirement,
resultCount: this.resultCount
};
}
};
async function phase1_analyzeQuery(searchAgent, userQuery, options) {
searchAgent.emitProgress("Phase 1: Analyzing query...");
const analysisPrompt = `
Analyze this note search query and extract structured search criteria.
User Query: "${userQuery}"
Extract:
1. PRIMARY_KEYWORDS: 3-5 keywords most likely to appear in the note TITLE
- PREFER two-word pairs or long single words that refer to a distinct concept (e.g., "credit card", "New York", "machine learning", "blood pressure", "chicken soup", "outreach")
- Use single words only when they are uniquely specific on their own (e.g., "cryptocurrency")
- Return all keywords in singular form (e.g., "recipe" not "recipes")
- Examples of GOOD primary keywords: ["credit card", "payment", "annual fee"]
- Examples of BAD primary keywords: ["credit", "card", "note"] (should be "credit card" and not generic words)
2. SECONDARY_KEYWORDS: 5-10 additional keywords likely in note content
- PREFER distinct single words, or two-word pairs that refer to a concept
- Include category terms (e.g., "financial" for credit card topics, "outreach" or "distribution" for marketing topics)
- Include synonyms or abbreviations (e.g., "NY" for "New York", "ML" for "machine learning")
- Include single-word fallbacks from primary keyword phrases to catch partial matches (e.g., if "gift ideas" is user query, include "gift" to catch any notes like "2019 gifts")
- Return all keywords in singular form (e.g., "document" not "documents")
- Examples: ["interest rate", "billing", "cash back", "reward"]
- Example for "gift ideas" query: ["gift", "birthday", "holiday", "christmas", "shopping", "wishlist", "wish list"]
3. EXACT_PHRASE: If user wants exact text match, extract it (or null)
4. CRITERIA:
- containsPDF: Did the user request notes with PDF attachments?
- containsImage: Did user request notes with images?
- containsURL: Did the user request notes with web links?
5. DATE_FILTER:
- type: "created" or "updated" (or null if no date mentioned)
- after: ISO date string (YYYY-MM-DD) for earliest date
6. TAG_REQUIREMENT:
- mustHave: Tag that MUST be present (null if none required)
- preferred: Tag that's PREFERRED but not required (null if none)
7. RESULT_COUNT:
- null (default): If user didn't specify quantity or implies general search
- 1: If user requests a specific single note (e.g. "the note that...", "find the receipt")
- N: If user requests specific number (e.g. "top 3 notes", "5 recipes")
Return ONLY valid JSON:
{
"primaryKeywords": ["two word", "keyword pair", "single"],
"secondaryKeywords": ["related phrase", "synonym pair", "category term"],
"exactPhrase": null,
"criteria": {
"containsPDF": false,
"containsImage": false,
"containsURL": false
},
"dateFilter": null,
"tagRequirement": {
"mustHave": null,
"preferred": null
},
"resultCount": null
}
`;
const validateCriteria = (result) => {
return result?.primaryKeywords && Array.isArray(result.primaryKeywords) && result.primaryKeywords.length;
};
const extracted = await searchAgent.llmWithRetry(analysisPrompt, validateCriteria, { jsonResponse: true });
console.log("Extracted criteria:", extracted);
if (!extracted.secondaryKeywords)
extracted.secondaryKeywords = [];
if (!extracted.criteria)
extracted.criteria = { containsPDF: false, containsImage: false, containsURL: false };
if (!extracted.tagRequirement)
extracted.tagRequirement = { mustHave: null, preferred: null };
return UserCriteria.fromExtracted(extracted, options);
}
var MAX_SEARCH_AGENT_LLM_QUERY_RETRIES = 3;
var PROGRESS_RECEIVED_INPUT = 5;
var PROGRESS_PHASE1_COMPLETE = 10;
var PROGRESS_PHASE2_COMPLETE = 30;
var PROGRESS_PHASE3_COMPLETE = 40;
var PROGRESS_PHASE4_COMPLETE = 55;
var PROGRESS_PHASE5_COMPLETE = 100;
var PROGRESS_RETRY_RECEIVED_INPUT = 60;
var PROGRESS_RETRY_PHASE1_COMPLETE = 65;
var PROGRESS_RETRY_PHASE2_COMPLETE = 70;
var PROGRESS_RETRY_PHASE3_COMPLETE = 75;
var PROGRESS_RETRY_PHASE4_COMPLETE = 85;
var SearchAgent = class {
constructor(app, plugin2) {
this.app = app;
this.lastModelUsed = null;
this.llm = this._llmWithSearchPreference;
this.maxedOutKeywords = new Set();
this.maxRetries = ATTEMPT_STRATEGIES.length - 1;
this.preferredAiModel = null;
this.plugin = plugin2;
this.progressCallback = null;
this.progressPercentage = 0;
this.ratedNoteUuids = new Set();
this.retryCount = 0;
this.searchAttempt = ATTEMPT_FIRST_PASS;
}
async search(userQuery, { criteria = {}, options = {} } = {}) {
try {
const isRetry = this.retryCount > 0;
const receivedInputPercentage = isRetry ? PROGRESS_RETRY_RECEIVED_INPUT : PROGRESS_RECEIVED_INPUT;
this.emitProgress("Starting search analysis...", receivedInputPercentage);
const searchCriteria = Object.keys(criteria).length ? criteria : await phase1_analyzeQuery(this, userQuery, options);
const phase1Percentage = isRetry ? PROGRESS_RETRY_PHASE1_COMPLETE : PROGRESS_PHASE1_COMPLETE;
this.emitProgress("Query analysis complete", phase1Percentage);
const candidates = await phase2_collectCandidates(this, searchCriteria);
const phase2Percentage = isRetry ? PROGRESS_RETRY_PHASE2_COMPLETE : PROGRESS_PHASE2_COMPLETE;
this.emitProgress(`Candidate collection complete`, phase2Percentage);
if (candidates.length === 0) {
this.emitProgress("No candidates found", PROGRESS_PHASE5_COMPLETE);
return this.handleNoResults(searchCriteria);
}
const { validCandidates, allAnalyzed } = await phase3_criteriaConfirm(this, candidates, searchCriteria);
const phase3Percentage = isRetry ? PROGRESS_RETRY_PHASE3_COMPLETE : PROGRESS_PHASE3_COMPLETE;
this.emitProgress(`Criteria confirmation complete`, phase3Percentage);
let rankedNotes;
if (validCandidates.length === 0 && this.retryCount < this.maxRetries) {
return this.nextSearchAttempt(userQuery, searchCriteria);
} else if (validCandidates.length === 0 && allAnalyzed.length) {
console.log("No perfect matches found, using partial matches");
rankedNotes = await phase4_scoreAndRank(this, allAnalyzed, searchCriteria, userQuery);
} else if (validCandidates.length) {
rankedNotes = await phase4_scoreAndRank(this, validCandidates, searchCriteria, userQuery);
} else {
rankedNotes = [];
}
const phase4Percentage = isRetry ? PROGRESS_RETRY_PHASE4_COMPLETE : PROGRESS_PHASE4_COMPLETE;
this.emitProgress(`Scoring complete`, phase4Percentage);
const finalResult = await phase5_sanityCheck(this, rankedNotes, searchCriteria, userQuery);
if (!finalResult.summaryNote && !isRetry) {
this.emitProgress(`Creating search summary note for ${pluralize(finalResult.notes.length, "result")}...`);
const summaryNote = await createSearchSummaryNote(this, finalResult, searchCriteria, userQuery);
if (summaryNote) {
finalResult.summaryNote = summaryNote;
this.emitProgress(`Created search summary note: <a href="${summaryNote.url}">${summaryNote.name}</a>`, PROGRESS_PHASE5_COMPLETE);
} else {
this.emitProgress(`Search complete`, PROGRESS_PHASE5_COMPLETE);
}
} else {
this.emitProgress(`Search complete`, PROGRESS_PHASE5_COMPLETE);
}
return finalResult;
} catch (error) {
console.error("Caught error during search:", error);
this.emitProgress(`Error attempting to retrieve AI search results: "${error}"`, PROGRESS_PHASE5_COMPLETE);
return {
found: false,
error: error.message,
suggestions: []
};
}
}
emitProgress(message, progressPercentage) {
if (progressPercentage) {
this.progressPercentage = progressPercentage;
}
if (this.progressCallback) {
this.progressCallback(message);
}
console.log(`[SearchAgent#emitProgress] ${progressPercentage ? `[${progressPercentage}%] ` : ""}${message}`);
}
formatResult(found, rankedNotes, resultCount) {
const bestMatch = rankedNotes[0];
const noteResults = rankedNotes.slice(0, resultCount);
if (bestMatch) {
return {
confidence: bestMatch.finalScore,
found,
maxResultCount: resultCount,
notes: noteResults,
resultSummary: `Found ${pluralize(rankedNotes.length, "note")}${found ? " matching" : ", none that quite match"} your criteria${rankedNotes.length > resultCount ? ` (showing top ${resultCount}, given your input criteria)` : ""}.`
};
} else {
return {
confidence: 0,
found,
maxResultCount: resultCount,
notes: [],
resultSummary: "Could not find any notes matching your criteria"
};
}
}
handleNoResults(criteria) {
return {
criteria,
found: false,
maxResultCount: criteria.resultCount,
notes: [],
resultSummary: "No notes found matching your criteria",
suggestion: "Try removing some filters or using broader search terms"
};
}
async llmWithRetry(prompt, validateCallback, options = {}) {
const models = this._modelsToTryFromPreference();
const maxAttempts = Math.min(models.length, MAX_SEARCH_AGENT_LLM_QUERY_RETRIES);
for (let i = 0; i < maxAttempts; i++) {
const aiModel = models[i];
console.log(`LLM attempt ${i + 1} with model ${aiModel}`);
try {
this.lastModelUsed = aiModel;
if (this.plugin)
this.plugin.lastModelUsed = aiModel;
const result = await this.llm(prompt, { ...options, aiModel });
if (validateCallback(result)) {
console.log("LLM response successfully passes validation callback");
return result;
} else {
this.emitProgress(`Response from "${aiModel}" failed validation, ${i + 1 < maxAttempts ? "retrying" : "no other available models to try"}...`);
}
} catch (error) {
console.error(`LLM attempt ${i + 1} failed:`, error);
}
}
throw new Error("Failed to get valid response from LLM after multiple attempts");
}
async _llmWithSearchPreference(prompt, options = {}) {
const mergedOptions = { ...options };
const aiModelExplicitlyProvided = Object.prototype.hasOwnProperty.call(mergedOptions, "aiModel");
if ((!aiModelExplicitlyProvided || !mergedOptions.aiModel) && this.preferredAiModel) {
mergedOptions.aiModel = this.preferredAiModel;
}
const result = await llmPrompt(this.app, this.plugin, prompt, mergedOptions);
if (mergedOptions.aiModel) {
this.lastModelUsed = mergedOptions.aiModel;
if (this.plugin)
this.plugin.lastModelUsed = mergedOptions.aiModel;
}
return result;
}
_mergeRankedNotes(previousPassNotes, retryPassNotes) {
const notesByUuid = new Map();
for (const note of previousPassNotes) {
notesByUuid.set(note.uuid, note);
}
for (const note of retryPassNotes) {
if (!notesByUuid.has(note.uuid)) {
notesByUuid.set(note.uuid, note);
}
}
const mergedNotes = Array.from(notesByUuid.values());
mergedNotes.sort((a, b) => b.finalScore - a.finalScore);
console.log(`[_mergeRankedNotes] Merged ${previousPassNotes.length} previous pass + ${retryPassNotes.length} retry pass = ${mergedNotes.length} total notes`);
return mergedNotes;
}
_modelsToTryFromPreference() {
const models = preferredModels(this.app) || [];
if (!this.preferredAiModel)
return models;
const withoutPreferred = models.filter((model) => model !== this.preferredAiModel);
return [this.preferredAiModel, ...withoutPreferred];
}
async nextSearchAttempt(userQuery, criteria, previousPassRankedNotes = []) {
this.retryCount++;
if (this.retryCount === 1) {
this.searchAttempt = ATTEMPT_INDIVIDUAL;
console.log("Retrying with individual keyword strategy...");
this.emitProgress("Retrying with individual keywords...");
}
const retryResult = await this.search(userQuery, { criteria });
if (previousPassRankedNotes.length && retryResult.notes) {
retryResult.notes = this._mergeRankedNotes(previousPassRankedNotes, retryResult.notes);
}
return retryResult;
}
onProgress(callback) {
this.progressCallback = callback;
}
async parallelLimit(tasks, limit) {
const results = [];
const executing = [];
for (const task of tasks) {
const promise = task().then((result) => {
executing.splice(executing.indexOf(promise), 1);
return result;
});
results.push(promise);
executing.push(promise);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
setPreferredAiModel(aiModel) {
this.preferredAiModel = aiModel;
}
recordMaxedOutKeywords(keywords) {
for (const keyword of keywords || []) {
this.maxedOutKeywords.add(keyword.toLowerCase());
}
}
recordRatedNoteUuids(uuids) {
for (const uuid of uuids || []) {
this.ratedNoteUuids.add(uuid);
}
}
summaryNoteTag() {
const userSpecifiedTag = this.app.settings[SEARCH_AGENT_RESULT_TAG_LABEL]?.length;
if (userSpecifiedTag) {
const normalizedTag = normalizedTagFromTagName(userSpecifiedTag);
if (userSpecifiedTag !== normalizedTag) {
this.emitProgress(`Adjusting search summary tag input from "${userSpecifiedTag}" to "${normalizedTag}"`);
this.app.setSetting(SEARCH_AGENT_RESULT_TAG_LABEL, normalizedTag);
}
if (["no", "none", "null"].includes(normalizedTag)) {
this.emitProgress(`User requested no tag on search summary note ("${normalizedTag}")`);
return null;
} else {
return normalizedTag;
}
} else {
return RESULT_TAG_DEFAULT;
}
}
};
async function userSearchCriteria(app) {
const configuredProviderEms = configuredProvidersSorted(app.settings || {});
const configuredProviderNames = configuredProviderEms.map((providerEm2) => providerNameFromProviderEm(providerEm2));
const actions = actionsForConfiguredProviders(configuredProviderEms);
const promptText = configuredProviderNames.length ? `Enter your search criteria
Configured LLM providers: ${configuredProviderNames.join(", ")}
Tip: use a button below to run the search with a specific provider.` : "Enter your search criteria";
const promptOptions = {
inputs: [
{ type: "text", label: "Describe any identifying details of the note(s) you wish to locate" },
{ type: "date", label: "Only notes created or changed since (optional)" },
{ type: "tags", label: "Only return notes with this tag (optional)" },
{ type: "string", label: "Max notes to return (optional)" }
]
};
if (actions.length) {
promptOptions.actions = actions;
}
const result = await app.prompt(promptText, promptOptions);
if (!result)
return null;
const [userQuery, changedSince, onlyTags, maxNotesCount, actionResult] = Array.isArray(result) ? result : [result];
const providerEm = typeof actionResult === "string" ? actionResult : null;
const preferredAiModel = providerEm ? modelForProvider(app.settings?.[AI_MODEL_LABEL], providerEm) : null;
const maxNotesCountNumber = positiveIntegerFromValue(maxNotesCount);
return [userQuery, changedSince, onlyTags, maxNotesCountNumber, preferredAiModel];
}
function actionsForConfiguredProviders(configuredProviderEms) {
return configuredProviderEms.map((providerEm) => ({
icon: "search",
label: `Search with ${providerNameFromProviderEm(providerEm)}`,
value: providerEm
}));
}
function positiveIntegerFromValue(value) {
if (value === void 0 || value === null)
return null;
const parsed = parseInt(String(value).trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
async function taskArrayFromSuggestions(plugin2, app, contentIndexText) {
const allowResponse = (response2) => {
const validJson = typeof response2 === "object" && (response2.result || response2.response?.result || response2.input?.response?.result || response2.input?.result);
const validString = typeof response2 === "string" && arrayFromResponseString(response2)?.length;
return validJson || validString;
};
const chosenTasks = [];
const response = await notePromptResponse(
plugin2,
app,
app.context.noteUUID,
"suggestTasks",
{},
{
allowResponse,
contentIndexText
}
);
if (response) {
let unchosenTasks = taskArrayFromResponse(response);
while (true) {
const promptOptions = unchosenTasks.map((t) => ({ label: t, value: t }));
if (!promptOptions.length)
break;
promptOptions.push({ label: "Add more tasks", value: "more" });
promptOptions.push({ label: "Done picking tasks", value: "done" });
const promptString = `Which tasks would you like to add to your note?` + (chosenTasks.length ? `
${chosenTasks.length} task${chosenTasks.length === 1 ? "" : "s"} will be inserted when you choose the "Done picking tasks" option` : "");
const insertTask = await app.prompt(promptString, {
inputs: [
{
label: "Choose tasks",
options: promptOptions,
type: "radio",
value: promptOptions[0].value
}
]
});
if (insertTask) {
if (insertTask === "done") {
break;
} else if (insertTask === "more") {
await addMoreTasks(plugin2, app, allowResponse, contentIndexText, chosenTasks, unchosenTasks);
} else {
chosenTasks.push(insertTask);
unchosenTasks = unchosenTasks.filter((task) => !chosenTasks.includes(task));
}
} else {
break;
}
}
} else {
app.alert("Could not determine any tasks to suggest from the existing note content");
return null;
}
if (chosenTasks.length) {
const taskArray = chosenTasks.map((task) => `- [ ] ${task}
`);
console.debug("Replacing with tasks", taskArray);
await app.context.replaceSelection(`
${taskArray.join("\n")}`);
}
return null;
}
async function addMoreTasks(plugin2, app, allowResponse, contentIndexText, chosenTasks, unchosenTasks) {
const rejectedResponses = unchosenTasks;
const moreTaskResponse = await notePromptResponse(
plugin2,
app,
app.context.noteUUID,
"suggestTasks",
{ chosenTasks },
{ allowResponse, contentIndexText, rejectedResponses }
);
const newTasks = moreTaskResponse && taskArrayFromResponse(moreTaskResponse);
if (newTasks) {
newTasks.forEach((t) => !unchosenTasks.includes(t) && !chosenTasks.includes(t) ? unchosenTasks.push(t) : null);
}
}
function taskArrayFromResponse(response) {
if (typeof response === "string") {
return arrayFromResponseString(response);
} else {
let tasks = response.result || response.response?.result || response.input?.response?.result || response.input?.result;
if (typeof tasks === "object" && !Array.isArray(tasks)) {
tasks = Object.values(tasks);
if (Array.isArray(tasks) && Array.isArray(tasks[0])) {
tasks = tasks[0];
}
}
if (!Array.isArray(tasks)) {
console.error("Could not determine tasks from response", response);
return [];
}
if (tasks.find((t) => typeof t !== "string")) {
tasks = tasks.map((task) => {
if (typeof task === "string") {
return task;
} else if (Array.isArray(task)) {
return task[0];
} else {
const objectValues = Object.values(task);
return objectValues[0];
}
});
}
if (tasks.length === 1 && tasks[0].includes("\n")) {
tasks = tasks[0].split("\n");
}
const tasksWithoutPrefix = tasks.map((taskText) => optionWithoutPrefix(taskText));
console.debug("Received tasks", tasksWithoutPrefix);
return tasksWithoutPrefix;
}
}
var INITIAL_POLLING_DELAY = 100;
var PROGRESS_BAR_HEIGHT_PIXELS = 60;
function renderPluginEmbed(app, plugin2, renderArguments) {
const initialContent = plugin2.progressText || "Loading...";
const initialPercentage = plugin2.progressPercentage() || 0;
return `
<html lang="en">
<head>
<style>
body {
background-color: #fff;
color: #333;
padding: 10px 15px;
margin: 0;
}
.progress-bar-container {
width: 100%;
max-width: 600px;
height: ${PROGRESS_BAR_HEIGHT_PIXELS}px;
margin: 0 auto 20px auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.progress-bar-wrapper {
width: 100%;
height: 12px;
background: linear-gradient(to right, #e8eaed, #f1f3f4);
border-radius: 6px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #4285f4, #34a853);
border-radius: 6px;
transition: width 0.4s ease-out;
box-shadow: 0 1px 2px rgba(66, 133, 244, 0.3);
}
.progress-percentage-text {
margin-top: 8px;
font-family: "Roboto Mono", monospace;
font-size: 14px;
font-weight: 500;
color: #5f6368;
}
.plugin-embed-container {
font-family: "Roboto", sans-serif;
line-height: 1.5;
}
.plugin-embed-container a {
color: #1a73e8;
text-decoration: none;
}
.plugin-embed-container a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="progress-bar-container">
<div class="progress-bar-wrapper">
<div class="progress-bar-fill" id="progress-bar-fill" style="width: ${initialPercentage}%"></div>
</div>
<div class="progress-percentage-text" id="progress-percentage-text">${initialPercentage}% complete</div>
</div>
<div class="plugin-embed-container"
id="progress-content"
data-args="${typeof renderArguments === "object" ? JSON.stringify(renderArguments) : renderArguments}"
data-rendered-at="${( new Date()).toISOString()}"
>
${initialContent}
</div>
<script type="text/javascript">
(function() {
let lastContent = "";
let lastPercentage = ${initialPercentage};
let pollingActive = true;
function updateProgressBar(percentage) {
if (percentage !== lastPercentage) {
lastPercentage = percentage;
document.getElementById("progress-bar-fill").style.width = percentage + "%";
document.getElementById("progress-percentage-text").textContent = percentage + "% complete";
}
}
function pollForUpdates() {
if (!pollingActive) return;
window.callAmplenotePlugin("getProgress").then(function(result) {
if (result) {
// Handle both new object format and legacy string format
var text = typeof result === "object" ? result.text : result;
var percentage = typeof result === "object" ? (result.percentage || 0) : 0;
if (text && text !== lastContent) {
lastContent = text;
document.getElementById("progress-content").innerHTML = text;
}
updateProgressBar(percentage);
}
if (pollingActive) {
setTimeout(pollForUpdates, ${POLLING_INTERVAL_EMBED_MILLISECONDS});
}
}).catch(function(error) {
console.error("Error polling for progress:", error);
if (pollingActive) {
setTimeout(pollForUpdates, ${POLLING_INTERVAL_EMBED_MILLISECONDS});
}
});
}
// Start polling after a short delay to ensure embed is fully loaded
setTimeout(pollForUpdates, ${INITIAL_POLLING_DELAY});
// Stop polling when page is hidden/closed
document.addEventListener("visibilitychange", function() {
pollingActive = !document.hidden;
if (pollingActive) {
pollForUpdates();
}
});
})();
</script>
</body>
</html>
`;
}
var plugin = {
constants: {
labelApiKey: null,
labelAiModel: AI_MODEL_LABEL,
pluginName: PLUGIN_NAME,
requestTimeoutSeconds: 30
},
callCountByModel: {},
errorCountByModel: {},
lastModelUsed: null,
noFallbackModels: false,
ollamaModelsFound: null,
progressText: "",
searchAgent: null,
appOption: {
[ADD_PROVIDER_API_KEY_LABEL]: async function(app) {
const preferredModels2 = await aiModelFromUserIntervention(this, app, { defaultProvider: null });
if (preferredModels2?.length) {
app.alert(`\u2705 Successfully added API key!${preferredModels2?.length > 1 ? `
Preferred models are now set to "${preferredModels2.join(`", "`)}".` : ""}`);
}
},
[LOOK_UP_OLLAMA_MODEL_ACTION_LABEL]: async function(app) {
const noOllamaString = `Unable to connect to Ollama. Ensure you stop the process if it is currently running, then start it with "OLLAMA_ORIGINS=https://plugins.amplenote.com ollama serve"`;
try {
const ollamaModels = await ollamaAvailableModels(this);
if (ollamaModels?.length) {
this.ollamaModelsFound = ollamaModels;
app.alert(`Successfully connected to Ollama! Available models include:
* ${this.ollamaModelsFound.join("\n* ")}`);
} else {
const json = await fetchJson(`${OLLAMA_URL}/api/tags`);
if (Array.isArray(json?.models)) {
app.alert("Successfully connected to Ollama, but could not find any running models. Try running 'ollama run mistral' in a terminal window?");
} else {
app.alert(noOllamaString);
}
}
} catch (error) {
app.alert(noOllamaString);
}
},
[SEARCH_USING_AGENT_LABEL]: async function(app) {
this.searchAgent = new SearchAgent(app, this);
const result = await userSearchCriteria(app);
if (!result)
return;
const [userQuery, changedSince, onlyTags, maxNotesCount, preferredAiModel] = result;
console.log(`Search criteria received: query="${userQuery}", changedSince=${changedSince}, onlyTags=${onlyTags}, maxNotesCount=${maxNotesCount || `(unspecified, so ${DEFAULT_SEARCH_NOTES_RETURNED})`}, preferredAiModel=${preferredAiModel}`);
if (!userQuery?.length)
return;
this.progressText = "";
this.searchAgent.onProgress((progressText) => {
this.progressText += `${progressText}<br /><br />`;
});
if (preferredAiModel) {
this.searchAgent.setPreferredAiModel(preferredAiModel);
}
this.searchAgent.emitProgress(`Starting search for user query: "${userQuery}" with ${preferredAiModel ? `preferred AI model "${preferredAiModel}"` : "no preferred AI model"}. ` + (changedSince ? `Filtering to notes changed since ${changedSince}. ` : "") + (onlyTags?.length ? `Filtering to notes with tags: ${onlyTags.join(", ")}. ` : "") + (maxNotesCount ? `Limiting to ${maxNotesCount} notes. ` : `Returning up to ${DEFAULT_SEARCH_NOTES_RETURNED} notes. `));
await app.openEmbed();
await this.searchAgent.search(userQuery, { options: {
dateFilter: { after: changedSince },
resultCount: maxNotesCount,
tagRequirement: { mustHave: onlyTags }
} });
},
"Show AI Usage by Model": async function(app) {
const callCountByModel = this.callCountByModel;
const callCountByModelText = Object.keys(callCountByModel).map((model) => `${model}: ${callCountByModel[model]}`).join("\n");
const errorCountByModel = this.errorCountByModel;
const errorCountByModelText = Object.keys(errorCountByModel).map((model) => `${model}: ${errorCountByModel[model]}`).join("\n");
let alertText = `Since the app was last started on this platform:
${callCountByModelText}
`;
if (errorCountByModelText.length) {
alertText += `Errors:
` + errorCountByModelText;
} else {
alertText += `No errors reported.`;
}
await app.alert(alertText);
},
"Answer": async function(app) {
let aiModels = await recommendedAiModels(this, app, "answer");
const options = aiModels.map((model) => ({ label: model, value: model }));
const [instruction, userPickedModel] = await app.prompt(QUESTION_ANSWER_PROMPT, {
inputs: [
{ type: "text", label: "Question", placeholder: "What's the meaning of life in 500 characters or less?" },
{
type: "radio",
label: `AI Model${this.lastModelUsed ? `. Defaults to last used` : ""}`,
options,
value: preferredModel(app, this.lastModelUsed)
}
]
});
console.debug("Instruction", instruction, "preferredModel", userPickedModel);
if (!instruction)
return;
if (userPickedModel)
aiModels = [userPickedModel].concat(aiModels.filter((model) => model !== userPickedModel));
return await this._noteOptionResultPrompt(
app,
null,
"answer",
{ instruction },
{ preferredModels: aiModels }
);
},
"Converse (chat) with AI": async function(app) {
await initiateChat(this, app);
}
},
insertText: {
"Complete": async function(app) {
return await this._completeText(app, "insertTextComplete");
},
"Continue": async function(app) {
return await this._completeText(app, "continue");
},
[IMAGE_FROM_PRECEDING_LABEL]: async function(app) {
const apiKey = await apiKeyFromAppOrUser(app, "openai");
if (apiKey) {
await imageFromPreceding(this, app, apiKey);
}
},
[IMAGE_FROM_PROMPT_LABEL]: async function(app) {
const apiKey = await apiKeyFromAppOrUser(app, "openai");
if (apiKey) {
await imageFromPrompt(this, app, apiKey);
}
},
[SUGGEST_TASKS_LABEL]: async function(app) {
const contentIndexText = `${PLUGIN_NAME}: ${SUGGEST_TASKS_LABEL}`;
return await taskArrayFromSuggestions(this, app, contentIndexText);
}
},
noteOption: {
"Revise": async function(app, noteUUID) {
const instruction = await app.prompt("How should this note be revised?");
if (!instruction)
return;
await this._noteOptionResultPrompt(app, noteUUID, "reviseContent", { instruction });
},
"Sort Grocery List": {
check: async function(app, noteUUID) {
const noteContent = await app.getNoteContent({ uuid: noteUUID });
return /grocer|bread|milk|meat|produce|banana|chicken|apple|cream|pepper|salt|sugar/.test(noteContent.toLowerCase());
},
run: async function(app, noteUUID) {
const startContent = await app.getNoteContent({ uuid: noteUUID });
const groceryArray = groceryArrayFromContent(startContent);
const sortedGroceryContent = await groceryContentFromJsonOrText(this, app, noteUUID, groceryArray);
if (sortedGroceryContent) {
app.replaceNoteContent({ uuid: noteUUID }, sortedGroceryContent);
}
}
},
"Summarize": async function(app, noteUUID) {
await this._noteOptionResultPrompt(app, noteUUID, "summarize", {});
}
},
replaceText: {
"Answer": {
check(app, text) {
return /(who|what|when|where|why|how)|\?/i.test(text);
},
async run(app, text) {
const answerPicked = await notePromptResponse(
this,
app,
app.context.noteUUID,
"answerSelection",
{ text },
{ confirmInsert: true, contentIndexText: text }
);
if (answerPicked) {
return `${text} ${answerPicked}`;
}
}
},
"Complete": async function(app, text) {
const { response } = await sendQuery(this, app, app.context.noteUUID, "replaceTextComplete", { text: `${text}<token>` });
if (response) {
return `${text} ${response}`;
}
},
"Revise": async function(app, text) {
const instruction = await app.prompt("How should this text be revised?");
if (!instruction)
return null;
return await notePromptResponse(
this,
app,
app.context.noteUUID,
"reviseText",
{ instruction, text }
);
},
"Rhymes": {
check(app, text) {
return text.split(" ").length <= MAX_WORDS_TO_SHOW_RHYME;
},
async run(app, text) {
return await this._wordReplacer(app, text, "rhyming");
}
},
"Thesaurus": {
check(app, text) {
return text.split(" ").length <= MAX_WORDS_TO_SHOW_THESAURUS;
},
async run(app, text) {
return await this._wordReplacer(app, text, "thesaurus");
}
}
},
onEmbedCall(app, action) {
if (action === "getProgress") {
return {
percentage: this.progressPercentage(),
text: this.progressText || ""
};
}
return null;
},
progressPercentage() {
if (this.searchAgent) {
return this.searchAgent.progressPercentage;
}
return null;
},
async renderEmbed(app, ...args) {
return renderPluginEmbed(app, this, args);
},
async _noteOptionResultPrompt(app, noteUUID, promptKey, promptKeyParams, { preferredModels: preferredModels2 = null } = {}) {
let aiResponse = await notePromptResponse(
this,
app,
noteUUID,
promptKey,
promptKeyParams,
{ preferredModels: preferredModels2, confirmInsert: false }
);
if (aiResponse?.length) {
const trimmedResponse = cleanTextFromAnswer(aiResponse);
const options = [];
if (noteUUID) {
options.push(
{ label: "Insert at start (prepend)", value: "prepend" },
{ label: "Insert at end (append)", value: "append" },
{ label: "Replace", value: "replace" }
);
}
options.push({ label: "Ask follow up question", value: "followup" });
let valueSelected;
if (options.length > 1) {
valueSelected = await app.prompt(`${APP_OPTION_VALUE_USE_PROMPT}
${trimmedResponse || aiResponse}`, {
inputs: [{ type: "radio", label: "Choose an action", options, value: options[0] }]
});
} else {
valueSelected = await app.alert(trimmedResponse || aiResponse, { actions: [{ label: "Ask follow up questions" }] });
if (valueSelected === 0)
valueSelected = "followup";
}
console.debug("User picked", valueSelected, "for response", aiResponse);
switch (valueSelected) {
case "prepend":
app.insertNoteContent({ uuid: noteUUID }, aiResponse);
break;
case "append":
app.insertNoteContent({ uuid: noteUUID }, aiResponse, { atEnd: true });
break;
case "replace":
app.replaceNoteContent({ uuid: noteUUID }, aiResponse);
break;
case "followup":
const aiModel = preferredModel(app, this.lastModelUsed);
const promptParams = await contentfulPromptParams(app, noteUUID, promptKey, promptKeyParams, aiModel);
const systemUserMessages = promptsFromPromptKey(promptKey, promptParams, [], aiModel);
const messages = systemUserMessages.concat({ role: "assistant", content: trimmedResponse });
return await initiateChat(this, app, preferredModels2?.length ? preferredModels2 : [aiModel], messages);
}
return aiResponse;
}
},
async _wordReplacer(app, text, promptKey) {
const { noteUUID } = app.context;
const note = await app.notes.find(noteUUID);
const noteContent = await note.content();
let contentIndex = noteContent.indexOf(text);
if (contentIndex === -1)
contentIndex = null;
const allowResponse = (jsonResponse) => {
return typeof jsonResponse === "object" && jsonResponse.result;
};
const response = await notePromptResponse(
this,
app,
noteUUID,
promptKey,
{ text },
{ allowResponse, contentIndex }
);
let options;
if (response?.result) {
options = arrayFromJumbleResponse(response.result);
options = options.filter((option) => option !== text);
} else {
return null;
}
const optionList = options.map((word) => optionWithoutPrefix(word))?.map((word) => word.trim())?.filter((n) => n.length && n.split(" ").length <= MAX_REALISTIC_THESAURUS_RHYME_WORDS);
if (optionList?.length) {
console.debug("Presenting option list", optionList);
const selectedValue = await app.prompt(`Choose a replacement for "${text}"`, {
inputs: [{
type: "radio",
label: `${optionList.length} candidate${optionList.length === 1 ? "" : "s"} found`,
options: optionList.map((option) => ({ label: option, value: option }))
}]
});
if (selectedValue) {
return selectedValue;
}
} else {
const model = preferredModel(app, this.lastModelUsed);
const providerEm = providerFromModel(model);
const followUp = apiKeyFromApp(app, providerEm)?.length ? `Consider adding ${providerEm} API key to your plugin settings?` : "Try again?";
app.alert(`Unable to get a usable response from available AI models. ${followUp}`);
}
return null;
},
async _completeText(app, promptKey) {
const replaceToken = promptKey === "continue" ? `${PLUGIN_NAME}: Continue` : `${PLUGIN_NAME}: Complete`;
const answer = await notePromptResponse(
this,
app,
app.context.noteUUID,
promptKey,
{},
{ contentIndexText: replaceToken }
);
if (answer) {
const trimmedAnswer = await trimNoteContentFromAnswer(app, answer, { replaceToken });
console.debug("Inserting trimmed response text:", trimmedAnswer);
return trimmedAnswer;
} else {
return null;
}
}
};
var plugin_default = plugin;
return plugin;
})()