Add SystemOne classification support and release version 4.0.0

This commit is contained in:
Jordan Wages 2026-10-07 23:57:49 -05:00
commit 9a5404cb62
16 changed files with 923 additions and 579 deletions

View file

@ -8,7 +8,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
- `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`,
`defaultParams.js` and `themeUtils.js`.
- `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3).
- `details.html` and `details.js`: View AI reasoning and clear cache for a message.
- `details.html` and `details.js`: View classification evidence and clear cache for a message.
- `resources/`: Images and other static files.
- `build-xpi.ps1`: PowerShell script to package the extension.
- `build-xpi.sh`: Bash script to package the extension.
@ -30,14 +30,42 @@ This file provides guidelines for codex agents contributing to the Sortana proje
## Testing
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script.
Run the dependency-free suite with Node.js 22 or newer: `node --test tests/*.test.mjs`.
The tests mock extension storage, HTTP responses, and Thunderbird rule execution.
Manual verification in Thunderbird is still required for UI and real-server integration.
Do **not** run the `ps1` build script or the SVG processing script.
## Endpoint Notes
Sortana targets the OpenAI-compatible `/v1/chat/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/chat/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided.
The options page can query `/v1/models` from the same base URL to populate the Model dropdown; selecting **None** omits the `model` field from the request payload.
Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided.
Requests use `messages` with system and user roles. Responses are expected to include text in `choices[0].message.content` containing a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in that content and ignores any surrounding commentary.
Sortana supports `apiType: "chat-completions"` (default) and `"systemone"`.
Plain adapter modules under `modules/backends/` own request construction and response
validation; `AiClassifier` owns shared transport, configuration snapshots, threshold
policy, and caching. The stored endpoint is a base URL. Normalization accepts `/v1`
and recognized full API paths, preserves proxy prefixes, and defaults to HTTPS
when no scheme is supplied. Model discovery uses `/v1/models`; **None** omits `model`.
Bearer authentication is optional for both APIs. Organization/project headers are
sent only for Chat Completions.
Chat Completions sends system/user messages and extracts the last JSON object from
`choices[0].message.content`, requiring a `match` or `matched` boolean. SystemOne
sends prepared email text as `state` and a single `questions.match` of type `noul`.
It requires `answers.match.type === "noul"` and a finite numeric `noul` in `[0, 1]`.
No chat generation parameters or system prompt are sent to SystemOne.
The global `decisionThreshold` defaults to 0.5; optional rule-level
`decisionThreshold` overrides it. UI percentages are stored as numbers in `[0, 1]`.
Matching uses `probability >= threshold` before display rounding. Details displays
probabilities in a progress bar with the percentage inside; threshold and model
appear in its tooltip. Results use the current threshold, never fabricated reasoning or action
history. No secondary model calls are made.
`classify()` returns structured evidence; `classifyText()` remains a boolean wrapper.
Failures throw and are never cached. HTTP errors include the request URL and
JSON server error message when available; SystemOne 404 errors suggest checking
the running server build and decision-model support. The rule runner stops that email, reports the
error, and continues the next queued email. Snapshot settings and rules per email.
`inputCharacterLimit` replaces coupling input truncation to output `max_tokens`;
it applies with aggressive reduction and migrates from the old effective limit.
## Documentation
@ -71,10 +99,21 @@ text extracted from all text parts.
### Cache Strategy
`aiCache` persists classification results. Each key is the SHA‑256 hex of
`"<message Message-ID>|<criterion>"` and maps to an object with `matched` and `reason`
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first
time the add-on loads after an update.
`aiCache` persists version 2 inference evidence. Keys are
`v2:<SHA-256 Message-ID>:<SHA-256 stable configuration/criterion fingerprint>`.
Include backend, endpoint, model, effective request parameters and preprocessing;
exclude secrets, actions and thresholds. Recompute SystemOne matches from cached
probability and current thresholds without automatically executing actions.
Entries retain a hashed message identifier for clearing across configurations.
Version 4.0.0 intentionally breaks legacy cache compatibility: legacy caches
(including `aiReasonCache`) are invalidated because their inference
configuration is unknown. Import/export validates versioned evidence; storage
changes refresh the running cache. Maintenance uses `sortana:getCacheSize` to
count validated entries in the background classifier, rather than raw storage keys.
Full restoration requires matching settings and rules as well as cache evidence;
`tests/cacheRestore.test.mjs` exercises export/reinstall/import through the actual
storage and Details handlers, including changed numeric Thunderbird message IDs. Clearing/importing during inference prevents
that in-flight result from repopulating the replaced cache.
### Icon Set Usage

104
README.md
View file

@ -4,13 +4,11 @@
Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule.
It allows you to classify email messages by sending their contents to a configurable
HTTP endpoint. Sortana uses the OpenAI-compatible `/v1/chat/completions` API; the options page stores a base
URL and appends `/v1/chat/completions` when sending requests. Requests contain a system
message and a user message. The endpoint should respond
with text in `choices[0].message.content` containing JSON indicating whether the
message meets a specified criterion, including a short reasoning summary.
Responses are parsed by extracting the last JSON object in that content and
expecting a `match` (or `matched`) boolean plus a `reason` string.
HTTP endpoint. Choose **Chat Completions** for an OpenAI-compatible
`/v1/chat/completions` service or **SystemOne** for decision models served through
`/v1/systemone`. Chat Completions returns a match boolean and textual explanation;
SystemOne returns a match probability, compared against a configurable threshold.
The options page stores a base URL and shows the resolved request URL.
## Features
@ -19,7 +17,7 @@ expecting a `match` (or `matched`) boolean plus a `reason` string.
- **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed.
- **Custom system prompts** – tailor the instructions sent as the system message for more precise results.
- **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
- **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page.
- **Advanced parameters (Chat Completions)** – tune generation settings like temperature, top‑p and more from the options page.
Provider-specific advanced fields are forwarded as configured and may be rejected by strict OpenAI endpoints.
- **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service.
- **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service.
@ -33,7 +31,7 @@ expecting a `match` (or `matched`) boolean plus a `reason` string.
- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal.
- **Error notification** – failed classification displays a notification in Thunderbird.
- **Session error log** – the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start.
- **View reasoning** – inspect why rules matched via the Details popup.
- **View results** – inspect chat explanations or SystemOne probabilities and thresholds in the Details popup.
- **Cache management** – clear cached results from the context menu or options page.
- **Queue & timing stats** – monitor processing time on the Maintenance tab.
- **Packaging scripts** – `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation.
@ -42,19 +40,91 @@ expecting a `match` (or `matched`) boolean plus a `reason` string.
### Cache Storage
Classification results are stored under the `aiCache` key in extension storage.
Each entry maps a SHA‑256 hash of `"<message Message-ID>|<criterion>"` to an object
containing `matched` and `reason` fields. Older installations with a separate
`aiReasonCache` will be migrated automatically on startup.
Classification results are stored under `aiCache` using version 2 entries. Keys
combine a hashed Message-ID with a fingerprint of the criterion, backend,
endpoint, selected model, effective request settings, and preprocessing settings.
Credentials and thresholds are excluded. SystemOne probabilities are retained at
full precision; changing a threshold reuses the evidence without another request.
Changing inference settings requires a fresh classification. If the model behind
an unchanged server/model alias changes, clear the cache manually.
**Version 4.0.0 upgrade note:** legacy cache entries and `aiReasonCache` are discarded because
they do not identify the inference configuration. Settings and rules are retained.
Imported legacy caches are also discarded. To restore cached Details after reinstalling,
export and import **Settings, Rules, and Cache** together: lookups require the
original criterion and matching inference settings. Import refreshes the running
classifier immediately; Maintenance counts its validated, loaded entries. A cache-only
import will not recreate rules or settings. Clearing a message's cache removes its
entries across rules and configurations.
### SystemOne with llama-server
Use a llama.cpp build supporting `/v1/systemone` and a compatible decision GGUF;
older builds and ordinary chat models cannot serve this API. A 404 with
`File Not Found` from `/v1/systemone` indicates the route is unavailable: check
the running server build and any reverse-proxy routing. Loading a decision GGUF
alone does not add this route to an older server. For example:
```sh
llama-server -hf ggml-org/Laya-GGUF
```
1. Select **SystemOne** in Settings and set the endpoint to `http://127.0.0.1:8080`
(or your server's address). Refresh models and select one, or choose **None**
for a single loaded model. Router deployments require the appropriate model ID.
2. Set the default match probability threshold (initially **50%**). Each rule has
an optional percentage override; leave it blank to inherit the default.
3. Apply rules normally. One `noul` question is sent per evaluated rule. A rule
matches when the unrounded probability is **greater than or equal to** its
effective threshold. For example, 80% matches an 80% threshold.
4. Open Details to see a probability bar with the percentage inside. Hover over
the bar to see the current threshold and model. This is the
result under current settings, not an action history. Changing thresholds does
not automatically rerun actions. Decision models provide no textual explanation;
no second model is called to invent one. Use Chat Completions for explanations.
Probabilities are model estimates, not guaranteed accuracy. Evaluate thresholds
against representative labeled emails, including long messages and attachments.
SystemOne uses the existing text extraction and attachment summaries; it does not
send attachment binaries or images. Chat prompts and generation settings remain
saved but are inactive for SystemOne. Optional Bearer authentication works with
both APIs; organization/project headers apply only to Chat Completions.
**Input character limit** applies when aggressive reduction is enabled, independently
of maximum output tokens. Existing installations inherit their previous effective
character limit; the default is 3686. Character counts do not guarantee that input
fits a model's token context or llama-server batch limits. Leave room for question
instructions and configure the server for the chosen model.
A request or response-parsing failure stops the remaining rules for that email,
records an error, and allows the queue to continue with the next email. Failed
results are never cached. Actions already completed are not rolled back.
Upstream: [llama-server SystemOne documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md#typesafe-compatible-api-endpoints).
### Verification
Run the dependency-free tests with Node.js 22 or newer:
```sh
node --test tests/*.test.mjs
```
Tests cover adapters, thresholds, URLs, settings validation, cache migration and
reuse, request failures, per-email snapshots, and queue behavior with mocked
Thunderbird services. Manual acceptance testing in Thunderbird should verify both
API types, model discovery, rule overrides and ordering, import/export, cache
clearing, Details, and stop-after-match against a running server.
## Architecture Overview
Sortana is implemented entirely with documented MailExtension/WebExtension APIs. No custom experiment code or Thunderbird-internal fallback paths are required:
- `background.js` loads saved settings, manages the classification queue and listens for new messages.
- `modules/AiClassifier.js` implements the classification logic and cache handling.
- `modules/AiClassifier.js` shares transport, threshold policy, and cache handling;
`modules/backends/` contains the Chat Completions and SystemOne adapters.
- `options/` contains the HTML and JavaScript for configuring the endpoint and rules.
- `details.html` / `details.js` present cached reasoning for a message.
- `details.html` / `details.js` present cached classification evidence for a message.
- `_locales/` holds localized strings used throughout the UI.
### Key Files
@ -65,7 +135,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs.
| `background.js` | Startup tasks and classification queue management. |
| `modules/AiClassifier.js` | Core classification logic and cache handling. |
| `options/options.html` and `options.js` | Endpoint and rule configuration UI. |
| `details.html` and `details.js` | View stored reasoning for a message. |
| `details.html` and `details.js` | View stored classification evidence for a message. |
| `logger.js` | Colorized logging with optional debug mode. |
## Building
@ -84,7 +154,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs.
## Usage
1. Open the add-on's options and set the base URL of your classification service
(Sortana will append `/v1/chat/completions`). Use the Model dropdown to load
(Sortana appends the path for the selected API type). Use the Model dropdown to load
`/v1/models` and select a model or choose **None** to omit the `model` field.
Advanced settings include optional API key, organization, and project headers
for OpenAI-hosted endpoints.

View file

@ -28,7 +28,6 @@ let stripUrlParams = false;
let altTextImages = false;
let collapseWhitespace = false;
let tokenReduction = false;
let maxTokens = 4096;
let TurndownService = null;
let userTheme = 'auto';
let currentTheme = 'light';
@ -53,6 +52,7 @@ function normalizeRules(rules) {
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
const rule = { criterion: r.criterion, actions };
if (r.decisionThreshold !== undefined) rule.decisionThreshold = r.decisionThreshold;
if (r.stopProcessing) rule.stopProcessing = true;
if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
@ -318,10 +318,12 @@ async function processMessage(id) {
updateActionIcon();
try {
const full = await messenger.messages.getFull(id);
const snapshot = AiClassifier.getConfig();
const rules = structuredClone(aiRules);
const originalText = buildEmailText(full, false);
let text = buildEmailText(full);
if (tokenReduction && maxTokens > 0) {
const limit = Math.floor(maxTokens * 0.9);
if (tokenReduction) {
const limit = snapshot.inputCharacterLimit;
if (text.length > limit) {
text = text.slice(0, limit);
}
@ -345,7 +347,7 @@ async function processMessage(id) {
identityId = null;
}
for (const rule of aiRules) {
for (const rule of rules) {
if (rule.enabled === false) {
continue;
}
@ -372,8 +374,8 @@ async function processMessage(id) {
}
}
}
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion);
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion, snapshot);
const { matched } = await AiClassifier.classify(text, rule.criterion, { cacheKey, snapshot, threshold: rule.decisionThreshold ?? snapshot.decisionThreshold });
if (matched) {
for (const act of (rule.actions || [])) {
if (act.type === 'tag' && act.tagKey) {
@ -456,19 +458,9 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(stored);
}
const keys = [];
for (const msg of ids) {
const id = msg?.id ?? msg;
for (const rule of aiRules) {
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
keys.push(key);
}
}
if (keys.length) {
await AiClassifier.removeCacheEntries(keys);
for (const msg of ids) await AiClassifier.clearCacheForMessage(msg?.id ?? msg);
showTransientIcon(ICONS.circle);
}
}
(async () => {
logger = await import(browser.runtime.getURL("logger.js"));
@ -484,8 +476,13 @@ async function clearCacheForMessages(idsInput) {
}
try {
const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]);
const store = await storage.local.get(["apiType", "decisionThreshold", "inputCharacterLimit", "endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]);
logger.setDebug(store.debugLogging);
const { migratedInputLimit } = await import(browser.runtime.getURL('modules/classificationConfig.js'));
if (store.inputCharacterLimit === undefined) {
store.inputCharacterLimit = migratedInputLimit(store);
await storage.local.set({ inputCharacterLimit: store.inputCharacterLimit });
}
await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
@ -495,9 +492,6 @@ async function clearCacheForMessages(idsInput) {
altTextImages = store.altTextImages === true;
collapseWhitespace = store.collapseWhitespace === true;
tokenReduction = store.tokenReduction === true;
if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
}
showDebugTab = store.showDebugTab === true;
const savedStats = await storage.local.get('classifyStats');
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
@ -514,27 +508,9 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(newRules);
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
}
if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
const config = {};
if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
if (changes.model) config.model = changes.model.newValue;
if (changes.apiKey) config.apiKey = changes.apiKey.newValue;
if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue;
if (changes.openaiProject) config.openaiProject = changes.openaiProject.newValue;
if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
if (changes.aiParams) {
config.aiParams = changes.aiParams.newValue;
if (changes.aiParams.newValue && typeof changes.aiParams.newValue.max_tokens !== 'undefined') {
maxTokens = parseInt(changes.aiParams.newValue.max_tokens) || maxTokens;
}
}
if (changes.debugLogging) {
config.debugLogging = changes.debugLogging.newValue === true;
logger.setDebug(config.debugLogging);
}
await AiClassifier.setConfig(config);
logger.aiLog("AiClassifier config updated from storage change", { debug: true }, config);
}
const configChanges = Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue]));
AiClassifier.setConfig(configChanges);
if (changes.aiCache) AiClassifier.replaceCache(changes.aiCache.newValue);
if (changes.htmlToMarkdown) {
htmlToMarkdown = changes.htmlToMarkdown.newValue === true;
logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown);
@ -666,10 +642,10 @@ async function clearCacheForMessages(idsInput) {
logger.aiLog("sortana:test – criterion", { debug: true }, criterion);
try {
logger.aiLog("Calling AiClassifier.classifyText()", { debug: true });
const result = await AiClassifier.classifyText(text, criterion);
logger.aiLog("Calling AiClassifier.classify()", { debug: true });
const result = await AiClassifier.classify(text, criterion, { threshold: msg.decisionThreshold ?? AiClassifier.getConfig().decisionThreshold });
logger.aiLog("classify() returned", { debug: true }, result);
return { match: result };
return { ...result, match: result.matched };
}
catch (err) {
logger.aiLog("Error in classify()", { level: 'error' }, err);
@ -716,13 +692,11 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(stored);
}
const results = [];
for (const rule of aiRules) {
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
const matched = AiClassifier.getCachedResult(key);
const reason = AiClassifier.getReason(key);
if (matched !== null || reason) {
results.push({ criterion: rule.criterion, matched, reason });
}
const snapshot = AiClassifier.getConfig();
for (const rule of structuredClone(aiRules)) {
const key = await AiClassifier.buildCacheKey(id, rule.criterion, snapshot);
const detail = AiClassifier.getCachedDetails(key, rule.decisionThreshold ?? snapshot.decisionThreshold);
if (detail) results.push({ criterion: rule.criterion, ...detail });
}
return { subject, results };
} catch (e) {
@ -757,6 +731,8 @@ async function clearCacheForMessages(idsInput) {
timingStats.last = typeof last === 'number' ? last : -1;
await storage.local.set({ classifyStats: timingStats });
return { ok: true };
} else if (msg?.type === "sortana:getCacheSize") {
return { count: await AiClassifier.getCacheSize() };
} else if (msg?.type === "sortana:getQueueCount") {
return { count: queuedCount + (processing ? 1 : 0) };
} else if (msg?.type === "sortana:getErrorLog") {

View file

@ -4,6 +4,33 @@
<meta charset="UTF-8">
<title>AI Details</title>
<link rel="stylesheet" href="options/bulma.css">
<style>
.probability-bar {
position: relative;
height: 1.75rem;
overflow: hidden;
border: 1px solid var(--bulma-border);
border-radius: var(--bulma-radius-small);
background: var(--bulma-background);
}
.probability-fill {
height: 100%;
background: var(--bulma-success);
opacity: 0.35;
}
.probability-bar.is-danger .probability-fill {
background: var(--bulma-danger);
}
.probability-value {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--bulma-text-strong);
font-weight: 600;
}
</style>
</head>
<body>
<section class="section">

View file

@ -51,15 +51,39 @@ async function loadMessage(id) {
article.className = `message ${color} mb-4`;
const header = document.createElement('div');
header.className = 'message-header';
header.innerHTML = `<p>${r.criterion}</p>`;
const criterion = document.createElement('p');
criterion.textContent = r.criterion;
header.appendChild(criterion);
const body = document.createElement('div');
body.className = 'message-body';
const status = document.createElement('p');
status.textContent = r.matched ? 'Matched' : 'Did not match';
if (r.backend === 'systemone') {
status.textContent += ' under current threshold';
status.classList.add('mb-2');
body.appendChild(status);
const percentage = r.probability * 100;
const bar = document.createElement('div');
bar.className = `probability-bar ${color}`;
bar.setAttribute('role', 'progressbar');
bar.setAttribute('aria-label', 'Match probability');
bar.setAttribute('aria-valuemin', '0');
bar.setAttribute('aria-valuemax', '100');
bar.setAttribute('aria-valuenow', String(percentage));
bar.title = `Threshold: ${r.threshold * 100}%${r.model ? ` · Model: ${r.model}` : ''}`;
const fill = document.createElement('div');
fill.className = 'probability-fill';
fill.style.width = `${percentage}%`;
const value = document.createElement('span');
value.className = 'probability-value';
value.textContent = `${percentage.toFixed(2)}%`;
bar.append(fill, value);
body.appendChild(bar);
} else {
const pre = document.createElement('pre');
pre.textContent = r.reason || '';
body.appendChild(status);
body.appendChild(pre);
body.append(status, pre);
}
article.appendChild(header);
article.appendChild(body);
container.appendChild(article);

View file

@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Sortana",
"version": "3.0.0",
"version": "4.0.0",
"default_locale": "en-US",
"applications": {
"gecko": {

View file

@ -1,438 +1,151 @@
"use strict";
import { aiLog, setDebug } from "../logger.js";
import { DEFAULT_AI_PARAMS } from "./defaultParams.js";
import { aiLog, setDebug } from '../logger.js';
import { CONFIG_DEFAULTS, validateSettings, validateThreshold } from './classificationConfig.js';
import * as chat from './backends/chatCompletions.js';
import * as systemone from './backends/systemOne.js';
const storage = (globalThis.messenger ?? globalThis.browser).storage;
const adapters = { 'chat-completions': chat, systemone };
let config = structuredClone(CONFIG_DEFAULTS);
let cache = new Map();
let loaded = false;
let loading;
let generation = 0;
const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
const MODELS_PATH = "/v1/models";
const SYSTEM_PREFIX = `You are an email-classification assistant.
Read the email below and the classification criterion provided by the user.
`;
const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the user's criterion.";
const SYSTEM_SUFFIX = `
Return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.`;
let gEndpointBase = "http://127.0.0.1:5000";
let gEndpoint = buildEndpointUrl(gEndpointBase);
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
let gModel = "";
let gApiKey = "";
let gOpenaiOrganization = "";
let gOpenaiProject = "";
let gCache = new Map();
let gCacheLoaded = false;
function normalizeEndpointBase(endpoint) {
if (typeof endpoint !== "string") {
return "";
export function normalizeEndpointBase(endpoint) {
if (typeof endpoint !== 'string' || !endpoint.trim()) return '';
return endpoint.trim().replace(/\/+$/, '').replace(/\/v1(?:\/(?:chat\/completions|completions|models|systemone))?$/i, '');
}
let base = endpoint.trim();
if (!base) {
return "";
function urlFor(endpoint, path) {
const base = normalizeEndpointBase(endpoint);
return base ? `${/^https?:\/\//i.test(base) ? base : `https://${base}`}${path}` : '';
}
base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, "");
return base;
export function buildEndpointUrl(endpoint, apiType = 'chat-completions') {
if (!adapters[apiType]) throw new Error('Unknown API type.');
return urlFor(endpoint, adapters[apiType].path);
}
function buildEndpointUrl(endpointBase) {
const base = normalizeEndpointBase(endpointBase);
if (!base) {
return "";
export function buildModelsUrl(endpoint) { return urlFor(endpoint, '/v1/models'); }
export function getConfig() { return structuredClone(config); }
export function setConfig(update = {}) {
validateSettings(update);
for (const key of Object.keys(CONFIG_DEFAULTS)) {
if (update[key] !== undefined) config[key] = structuredClone(update[key]);
}
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
const needsSlash = withScheme.endsWith("/");
const path = CHAT_COMPLETIONS_PATH.replace(/^\//, "");
return `${withScheme}${needsSlash ? "" : "/"}${path}`;
}
function buildModelsUrl(endpointBase) {
const base = normalizeEndpointBase(endpointBase);
if (!base) {
return "";
}
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
const needsSlash = withScheme.endsWith("/");
const path = MODELS_PATH.replace(/^\//, "");
return `${withScheme}${needsSlash ? "" : "/"}${path}`;
}
async function sha256Hex(str) {
try {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, "0")).join("");
} catch (e) {
aiLog("sha256Hex failed", { level: "error" }, e);
return "";
}
}
async function resolveHeaderId(id) {
if (typeof id === "number" && typeof messenger?.messages?.get === "function") {
try {
const hdr = await messenger.messages.get(id);
if (hdr?.headerMessageId) {
return hdr.headerMessageId;
}
} catch (e) {
aiLog(`Failed to resolve headerMessageId for ${id}`, { level: 'warn' }, e);
}
}
return String(id);
}
async function buildCacheKey(id, criterion) {
const resolvedId = await resolveHeaderId(id);
return sha256Hex(`${resolvedId}|${criterion}`);
}
async function loadCache() {
if (gCacheLoaded) {
return;
}
aiLog(`[AiClassifier] Loading cache`, {debug: true});
try {
const { aiCache, aiReasonCache } = await storage.local.get(["aiCache", "aiReasonCache"]);
if (aiCache) {
for (let [k, v] of Object.entries(aiCache)) {
if (v && typeof v === "object") {
gCache.set(k, { matched: v.matched ?? null, reason: v.reason || "" });
} else {
gCache.set(k, { matched: v, reason: "" });
}
}
aiLog(`[AiClassifier] Loaded ${gCache.size} cache entries`, {debug: true});
} else {
aiLog(`[AiClassifier] Cache is empty`, {debug: true});
}
if (aiReasonCache) {
aiLog(`[AiClassifier] Migrating ${Object.keys(aiReasonCache).length} reason entries`, {debug: true});
for (let [k, reason] of Object.entries(aiReasonCache)) {
let entry = gCache.get(k) || { matched: null, reason: "" };
entry.reason = reason;
gCache.set(k, entry);
}
await storage.local.remove("aiReasonCache");
await storage.local.set({ aiCache: Object.fromEntries(gCache) });
}
} catch (e) {
aiLog(`Failed to load cache`, {level: 'error'}, e);
}
gCacheLoaded = true;
}
async function saveCache(updatedKey, updatedValue) {
if (typeof updatedKey !== "undefined") {
aiLog(`[AiClassifier] ⮡ Persisting entry '${updatedKey}' → ${updatedValue}`, {debug: true});
}
try {
await storage.local.set({ aiCache: Object.fromEntries(gCache) });
} catch (e) {
aiLog(`Failed to save cache`, {level: 'error'}, e);
}
}
async function setConfig(config = {}) {
if (typeof config.endpoint === "string") {
const base = normalizeEndpointBase(config.endpoint);
if (base) {
gEndpointBase = base;
}
gEndpoint = buildEndpointUrl(gEndpointBase);
}
if (typeof config.customSystemPrompt === "string") {
gCustomSystemPrompt = config.customSystemPrompt;
}
if (config.aiParams && typeof config.aiParams === "object") {
for (let [k, v] of Object.entries(config.aiParams)) {
if (k in gAiParams && typeof v !== "undefined") {
gAiParams[k] = v;
}
}
}
if (typeof config.model === "string") {
gModel = config.model.trim();
}
if (typeof config.apiKey === "string") {
gApiKey = config.apiKey.trim();
}
if (typeof config.openaiOrganization === "string") {
gOpenaiOrganization = config.openaiOrganization.trim();
}
if (typeof config.openaiProject === "string") {
gOpenaiProject = config.openaiProject.trim();
}
if (typeof config.debugLogging === "boolean") {
config.aiParams = { ...CONFIG_DEFAULTS.aiParams, ...config.aiParams };
for (const key of ['model', 'apiKey', 'openaiOrganization', 'openaiProject']) config[key] = String(config[key]).trim();
setDebug(config.debugLogging);
}
if (!gEndpoint) {
gEndpoint = buildEndpointUrl(gEndpointBase);
async function hash(value) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('');
}
aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true});
aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true});
async function messageHash(id) {
const api = globalThis.messenger ?? globalThis.browser;
if (typeof id === 'number' && api.messages?.get) {
const header = await api.messages.get(id);
id = header.headerMessageId || id;
}
function buildAuthHeaders() {
const headers = {};
if (gApiKey) {
headers.Authorization = `Bearer ${gApiKey}`;
return hash(String(id));
}
if (gOpenaiOrganization) {
headers["OpenAI-Organization"] = gOpenaiOrganization;
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])]));
return value;
}
if (gOpenaiProject) {
headers["OpenAI-Project"] = gOpenaiProject;
export async function buildCacheKey(id, criterion, snapshot = getConfig()) {
const fingerprint = {
backend: snapshot.apiType, endpoint: buildEndpointUrl(snapshot.endpoint, snapshot.apiType),
request: adapters[snapshot.apiType].buildRequest('', criterion, snapshot),
organization: snapshot.apiType === 'chat-completions' ? snapshot.openaiOrganization : '',
project: snapshot.apiType === 'chat-completions' ? snapshot.openaiProject : '',
preprocessing: Object.fromEntries(['htmlToMarkdown', 'stripUrlParams', 'altTextImages', 'collapseWhitespace', 'tokenReduction', 'inputCharacterLimit'].map(k => [k, snapshot[k]]))
};
return `v2:${await messageHash(id)}:${await hash(JSON.stringify(stable(fingerprint)))}`;
}
return headers;
export function validCacheEntry(key, entry) {
return /^v2:[a-f0-9]{64}:[a-f0-9]{64}$/.test(key) && entry?.version === 2 &&
entry.messageHash === key.split(':')[1] && typeof entry.model === 'string' &&
(entry.backend === 'systemone'
? typeof entry.probability === 'number' && Number.isFinite(entry.probability) && entry.probability >= 0 && entry.probability <= 1 && entry.reason === null
: entry.backend === 'chat-completions' && typeof entry.matched === 'boolean' && entry.probability === null && (entry.reason === null || typeof entry.reason === 'string'));
}
function buildSystemPrompt() {
return SYSTEM_PREFIX + (gCustomSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX;
export function replaceCache(data = {}) {
cache = new Map(Object.entries(data || {}).filter(([key, entry]) => validCacheEntry(key, entry)));
loaded = true;
generation++;
}
function buildPrompt(body, criterion) {
aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true});
return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`;
async function saveCache() { await storage.local.set({ aiCache: Object.fromEntries(cache) }); }
export async function init() {
if (loaded) return;
if (!loading) loading = (async () => {
const { aiCache = {}, aiReasonCache } = await storage.local.get(['aiCache', 'aiReasonCache']);
replaceCache(aiCache);
if (Object.keys(aiCache || {}).length !== cache.size) await saveCache();
if (aiReasonCache !== undefined) await storage.local.remove('aiReasonCache');
})().finally(() => { loading = null; });
await loading;
}
function getCachedResult(cacheKey) {
if (!gCacheLoaded) {
return null;
function withPolicy(entry, threshold) {
const isDecision = entry.backend === 'systemone';
if (isDecision) validateThreshold(threshold);
return { matched: isDecision ? entry.probability >= threshold : entry.matched,
reason: entry.reason, probability: entry.probability, threshold: isDecision ? threshold : null,
backend: entry.backend, model: entry.model };
}
if (cacheKey && gCache.has(cacheKey)) {
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true});
const entry = gCache.get(cacheKey);
return entry?.matched ?? null;
export function getCachedDetails(key, threshold = config.decisionThreshold) {
const entry = cache.get(key);
return entry ? withPolicy(entry, threshold) : null;
}
return null;
export function getCachedResult(key, threshold) { return getCachedDetails(key, threshold)?.matched ?? null; }
export function getReason(key) { return cache.get(key)?.reason ?? null; }
export async function classify(text, criterion, { cacheKey = null, snapshot = getConfig(), threshold = snapshot.decisionThreshold } = {}) {
await init();
if (snapshot.apiType === 'systemone') validateThreshold(threshold);
const cached = getCachedDetails(cacheKey, threshold);
if (cached) return cached;
const epoch = generation;
const adapter = adapters[snapshot.apiType];
const payload = adapter.buildRequest(text, criterion, snapshot);
try { await storage.local.set({ lastPayload: payload }); }
catch (error) { aiLog('Failed to save last payload', { level: 'warn' }, error); }
const headers = { 'Content-Type': 'application/json' };
if (snapshot.apiKey) headers.Authorization = `Bearer ${snapshot.apiKey}`;
if (snapshot.apiType === 'chat-completions') {
if (snapshot.openaiOrganization) headers['OpenAI-Organization'] = snapshot.openaiOrganization;
if (snapshot.openaiProject) headers['OpenAI-Project'] = snapshot.openaiProject;
}
function getReason(cacheKey) {
if (!gCacheLoaded) {
return null;
}
const entry = gCache.get(cacheKey);
return cacheKey && entry ? entry.reason || null : null;
}
function buildPayload(text, criterion) {
let payloadObj = Object.assign({
messages: [
{ role: "system", content: buildSystemPrompt() },
{ role: "user", content: buildPrompt(text, criterion) }
]
}, gAiParams);
if (gModel) {
payloadObj.model = gModel;
}
return JSON.stringify(payloadObj);
}
function reportParseError(message, detail) {
try {
const runtime = (globalThis.browser ?? globalThis.messenger)?.runtime;
if (!runtime?.sendMessage) {
return;
}
runtime.sendMessage({
type: "sortana:recordError",
context: "AI response parsing",
message,
detail
}).catch(() => {});
} catch (e) {
aiLog("Failed to report parse error", { level: "warn" }, e);
}
}
function extractLastJsonObject(text) {
let last = null;
let start = -1;
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
if (depth === 0) {
start = i;
}
depth += 1;
continue;
}
if (ch === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
last = text.slice(start, i + 1);
start = -1;
}
}
}
return last;
}
function parseMatch(result) {
const rawText = result.choices?.[0]?.message?.content;
if (typeof rawText !== "string") {
reportParseError("Chat response missing text content.", JSON.stringify(result).slice(0, 800));
return { matched: false, reason: "" };
}
const candidate = extractLastJsonObject(rawText);
if (!candidate) {
reportParseError("No JSON object found in AI response.", rawText.slice(0, 800));
return { matched: false, reason: "" };
}
let obj;
try {
obj = JSON.parse(candidate);
} catch (e) {
reportParseError("Failed to parse JSON from AI response.", candidate.slice(0, 800));
return { matched: false, reason: "" };
}
const matchValue = Object.prototype.hasOwnProperty.call(obj, "match") ? obj.match : obj.matched;
const matched = matchValue === true;
if (matchValue !== true && matchValue !== false) {
reportParseError("AI response missing valid match boolean.", candidate.slice(0, 800));
}
const reasonValue = obj.reason ?? obj.reasoning ?? obj.explaination;
const reason = typeof reasonValue === "string" ? reasonValue : "";
return { matched, reason };
}
function cacheEntry(cacheKey, matched, reason) {
if (!cacheKey) {
return;
}
aiLog(`[AiClassifier] Caching entry '${cacheKey}'`, {debug: true});
const entry = gCache.get(cacheKey) || { matched: null, reason: "" };
if (typeof matched === "boolean") {
entry.matched = matched;
}
if (typeof reason === "string") {
entry.reason = reason;
}
gCache.set(cacheKey, entry);
saveCache(cacheKey, entry);
}
async function removeCacheEntries(keys = []) {
if (!Array.isArray(keys)) {
keys = [keys];
}
if (!gCacheLoaded) {
await loadCache();
}
let removed = false;
for (let key of keys) {
if (gCache.delete(key)) {
removed = true;
aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: true});
}
}
if (removed) {
await saveCache();
}
}
async function clearCache() {
if (!gCacheLoaded) {
await loadCache();
}
if (gCache.size > 0) {
gCache.clear();
await saveCache();
aiLog(`[AiClassifier] Cleared cache`, {debug: true});
}
}
async function getCacheSize() {
if (!gCacheLoaded) {
await loadCache();
}
return gCache.size;
}
async function classifyText(text, criterion, cacheKey = null) {
if (!gCacheLoaded) {
await loadCache();
}
const cached = getCachedResult(cacheKey);
if (cached !== null) {
return cached;
}
const payload = buildPayload(text, criterion);
try {
await storage.local.set({ lastPayload: JSON.parse(payload) });
} catch (e) {
aiLog('failed to save last payload', { level: 'warn' }, e);
}
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
try {
const response = await fetch(gEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json", ...buildAuthHeaders() },
body: payload,
});
const endpoint = buildEndpointUrl(snapshot.endpoint, snapshot.apiType);
const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) });
if (!response.ok) {
aiLog(`HTTP status ${response.status}`, {level: 'warn'});
return false;
let detail = '';
try {
const body = await response.json();
const message = body?.error?.message;
if (typeof message === 'string') detail = ` Server response: ${message.slice(0, 300)}.`;
} catch { /* Non-JSON error responses still report the URL and status. */ }
const hint = response.status === 404 && snapshot.apiType === 'systemone'
? ' Verify that the running server build supports /v1/systemone and the selected decision model.' : '';
throw new Error(`Classification request failed: HTTP ${response.status} at ${endpoint}.${detail}${hint}`);
}
const result = await response.json();
aiLog(`[AiClassifier] Received response:`, {debug: true}, result);
const parsed = parseMatch(result);
cacheEntry(cacheKey, parsed.matched, parsed.reason);
return parsed.matched;
} catch (e) {
aiLog(`HTTP request failed`, {level: 'error'}, e);
return false;
const raw = await response.json();
const entry = { ...adapter.parseResponse(raw), backend: snapshot.apiType,
model: typeof raw.model === 'string' ? raw.model : snapshot.model, version: 2,
messageHash: cacheKey?.split(':')[1] };
if (cacheKey && epoch === generation) {
cache.set(cacheKey, entry);
await saveCache();
}
return withPolicy(entry, threshold);
}
async function init() {
await loadCache();
export async function classifyText(text, criterion, cacheKey = null) { return (await classify(text, criterion, { cacheKey })).matched; }
export async function removeCacheEntries(keys = []) {
await init(); generation++;
for (const key of Array.isArray(keys) ? keys : [keys]) cache.delete(key);
await saveCache();
}
export { buildEndpointUrl, buildModelsUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };
export async function clearCacheForMessage(id) {
await init();
const identifier = await messageHash(id);
await removeCacheEntries([...cache].filter(([, entry]) => entry.messageHash === identifier).map(([key]) => key));
}
export async function clearCache() { await init(); generation++; cache.clear(); await saveCache(); }
export async function getCacheSize() { await init(); return cache.size; }

View file

@ -0,0 +1,81 @@
const SYSTEM_PREFIX = `You are an email-classification assistant.
Read the email below and the classification criterion provided by the user.
`;
const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the user's criterion.";
const SYSTEM_SUFFIX = `
Return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.`;
function extractLastJsonObject(text) {
let last = null;
let start = -1;
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
if (depth === 0) {
start = i;
}
depth += 1;
continue;
}
if (ch === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
last = text.slice(start, i + 1);
start = -1;
}
}
}
return last;
}
export const path = '/v1/chat/completions';
export function buildRequest(text, criterion, config) {
const payload = {
messages: [
{ role: 'system', content: SYSTEM_PREFIX + (config.customSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX },
{ role: 'user', content: `**Email Contents**\n\`\`\`\n${text}\n\`\`\`\nClassification Criterion: ${criterion}` }
], ...config.aiParams
};
if (config.model) payload.model = config.model;
return payload;
}
export function parseResponse(result) {
const content = result?.choices?.[0]?.message?.content;
if (typeof content !== 'string') throw new Error('Chat response missing text content.');
const candidate = extractLastJsonObject(content);
if (!candidate) throw new Error('No JSON object found in AI response.');
const obj = JSON.parse(candidate);
const matched = Object.hasOwn(obj, 'match') ? obj.match : obj.matched;
if (typeof matched !== 'boolean') throw new Error('AI response missing valid match boolean.');
const reason = obj.reason ?? obj.reasoning ?? obj.explaination;
return { matched, reason: typeof reason === 'string' ? reason : null, probability: null };
}

View file

@ -0,0 +1,17 @@
export const path = '/v1/systemone';
export function buildRequest(text, criterion, config) {
const payload = {
state: text,
questions: { match: { type: 'noul', instructions: `Does this email satisfy the following criterion: ${criterion}` } }
};
if (config.model) payload.model = config.model;
return payload;
}
export function parseResponse(result) {
const answer = result?.answers?.match;
if (answer?.type !== 'noul' || typeof answer.noul !== 'number' ||
!Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) {
throw new Error('SystemOne response must contain answers.match with type noul and a probability from 0 to 1.');
}
return { matched: null, reason: null, probability: answer.noul };
}

View file

@ -0,0 +1,32 @@
import { DEFAULT_AI_PARAMS } from './defaultParams.js';
export const CONFIG_DEFAULTS = {
apiType: 'chat-completions', endpoint: 'http://127.0.0.1:5000', model: '',
apiKey: '', openaiOrganization: '', openaiProject: '', customSystemPrompt: '',
decisionThreshold: 0.5, inputCharacterLimit: 3686, aiParams: DEFAULT_AI_PARAMS,
htmlToMarkdown: false, stripUrlParams: false, altTextImages: false,
collapseWhitespace: false, tokenReduction: false, debugLogging: false
};
export function validateThreshold(value) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
throw new Error('Match threshold must be between 0% and 100%.');
}
return value;
}
export function validateSettings(data) {
if (data.apiType !== undefined && !['chat-completions', 'systemone'].includes(data.apiType)) throw new Error('Unknown API type.');
if (data.decisionThreshold !== undefined) validateThreshold(data.decisionThreshold);
if (data.inputCharacterLimit !== undefined && (!Number.isSafeInteger(data.inputCharacterLimit) || data.inputCharacterLimit < 1)) {
throw new Error('Input character limit must be a positive integer.');
}
if (data.aiRules !== undefined) {
if (!Array.isArray(data.aiRules)) throw new Error('Rules must be an array.');
for (const rule of data.aiRules) {
if (rule.decisionThreshold !== undefined) validateThreshold(rule.decisionThreshold);
}
}
}
export function migratedInputLimit(data) {
if (data.inputCharacterLimit !== undefined) return data.inputCharacterLimit;
const value = Math.floor(Number(data.aiParams?.max_tokens ?? 4096) * 0.9);
return Number.isSafeInteger(value) && value > 0 ? value : 3686;
}

View file

@ -1,7 +1,10 @@
"use strict";
import { validateSettings, migratedInputLimit } from '../modules/classificationConfig.js';
import { validCacheEntry } from '../modules/AiClassifier.js';
const storage = (globalThis.messenger ?? browser).storage;
const KEY_GROUPS = {
settings: [
'apiType', 'decisionThreshold', 'inputCharacterLimit', 'tokenReduction',
'endpoint',
'model',
'apiKey',
@ -36,12 +39,15 @@ export async function exportData(categories) {
URL.revokeObjectURL(url);
}
export async function importData(file, categories) {
export async function importData(file, categories = Object.keys(KEY_GROUPS)) {
const text = await file.text();
const parsed = JSON.parse(text);
const data = {};
for (const key of collectKeys(categories)) {
if (key in parsed) data[key] = parsed[key];
}
if (categories.includes('settings') && data.inputCharacterLimit === undefined) data.inputCharacterLimit = migratedInputLimit(data);
validateSettings(data);
if (data.aiCache !== undefined) data.aiCache = Object.fromEntries(Object.entries(data.aiCache || {}).filter(([key, entry]) => validCacheEntry(key, entry)));
await storage.local.set(data);
}

View file

@ -64,11 +64,25 @@
</div>
</div>
<p class="help is-danger mb-3" id="settings-error" role="alert"></p>
<div id="settings-tab" class="tab-content">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
<span>Settings</span>
</h2>
<div class="field">
<label class="label" for="api-type">API type</label>
<div class="select"><select id="api-type">
<option value="chat-completions">Chat Completions</option>
<option value="systemone">SystemOne</option>
</select></div>
</div>
<div class="field systemone-only">
<label class="label" for="decision-threshold">Default match probability threshold (%)</label>
<input class="input" id="decision-threshold" type="number" min="0" max="100" step="any">
<p class="help">Match when probability is at least this value. Rules may override it. Evaluate thresholds on your own email examples.</p>
</div>
<div class="field">
<label class="label" for="endpoint">Endpoint</label>
<div class="control">
@ -186,7 +200,12 @@
</label>
</div>
<div class="field">
<label class="label" for="max_tokens">Max tokens</label>
<label class="label" for="input-character-limit">Input character limit</label>
<input class="input" id="input-character-limit" type="number" min="1" step="1">
<p class="help">Applied with aggressive reduction. Characters are not tokens; leave room for instructions within your model's context limit.</p>
</div>
<div class="field">
<label class="label" for="max_tokens">Maximum output tokens</label>
<div class="control">
<input class="input" type="number" id="max_tokens">
</div>

View file

@ -5,7 +5,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js'));
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js'));
const { validateSettings, migratedInputLimit } = await import(browser.runtime.getURL('modules/classificationConfig.js'));
const defaults = await storage.local.get([
'apiType', 'decisionThreshold', 'inputCharacterLimit',
'endpoint',
'customSystemPrompt',
'model',
@ -86,6 +88,23 @@ document.addEventListener('DOMContentLoaded', async () => {
markDirty();
await applyTheme(themeSelect.value);
});
const apiTypeInput = document.getElementById('api-type');
apiTypeInput.value = defaults.apiType || 'chat-completions';
const thresholdInput = document.getElementById('decision-threshold');
thresholdInput.value = (defaults.decisionThreshold ?? 0.5) * 100;
const inputLimit = document.getElementById('input-character-limit');
inputLimit.value = migratedInputLimit(defaults);
const settingsError = document.getElementById('settings-error');
function updateApiControls() {
const decision = apiTypeInput.value === 'systemone';
document.querySelectorAll('.systemone-only').forEach(el => el.classList.toggle('is-hidden', !decision));
for (const id of ['system-instructions', 'openai-organization', 'openai-project', ...Object.keys(DEFAULT_AI_PARAMS)]) {
document.getElementById(id)?.closest('.field')?.classList.toggle('is-hidden', decision);
}
document.getElementById('reset-system').classList.toggle('is-hidden', decision);
updateEndpointPreview();
}
apiTypeInput.addEventListener('change', updateApiControls);
const endpointInput = document.getElementById('endpoint');
const endpointPreview = document.getElementById('endpoint-preview');
const fallbackEndpoint = 'http://127.0.0.1:5000';
@ -94,13 +113,13 @@ document.addEventListener('DOMContentLoaded', async () => {
endpointInput.value = endpointBase;
function updateEndpointPreview() {
const resolved = AiClassifier.buildEndpointUrl(endpointInput.value);
const resolved = AiClassifier.buildEndpointUrl(endpointInput.value, apiTypeInput.value);
endpointPreview.textContent = resolved
? `Resolved endpoint: ${resolved}`
: 'Resolved endpoint: (invalid)';
}
endpointInput.addEventListener('input', updateEndpointPreview);
updateEndpointPreview();
updateApiControls();
const modelSelect = document.getElementById('model-select');
const refreshModelsBtn = document.getElementById('refresh-models');
@ -148,11 +167,11 @@ document.addEventListener('DOMContentLoaded', async () => {
headers.Authorization = `Bearer ${apiKey}`;
}
const organization = openaiOrgInput?.value.trim();
if (organization) {
if (organization && apiTypeInput.value === 'chat-completions') {
headers["OpenAI-Organization"] = organization;
}
const project = openaiProjectInput?.value.trim();
if (project) {
if (project && apiTypeInput.value === 'chat-completions') {
headers["OpenAI-Project"] = project;
}
return headers;
@ -467,7 +486,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const perDayEl = document.getElementById('per-day');
let timingLogged = false;
ruleCountEl.textContent = (defaults.aiRules || []).length;
cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0;
cacheCountEl.textContent = '…';
function createActionRow(action = {type: 'tag'}) {
const row = document.createElement('div');
@ -861,14 +880,25 @@ document.addEventListener('DOMContentLoaded', async () => {
body.appendChild(folderBox);
article.appendChild(header);
const thresholdField = document.createElement('label');
thresholdField.className = 'field systemone-only';
thresholdField.classList.toggle('is-hidden', apiTypeInput.value !== 'systemone');
thresholdField.textContent = 'Match probability threshold (%) — blank uses default';
const override = document.createElement('input');
override.className = 'input decision-threshold';
override.type = 'number';
override.min = '0'; override.max = '100'; override.step = 'any';
override.value = rule.decisionThreshold === undefined ? '' : rule.decisionThreshold * 100;
thresholdField.appendChild(override);
body.appendChild(thresholdField);
article.appendChild(body);
rulesContainer.appendChild(article);
}
}
addRuleBtn.addEventListener('click', () => {
const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
function readRules() {
return [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
const criterion = ruleEl.querySelector('.criterion').value;
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
const type = row.querySelector('select').value;
@ -890,8 +920,11 @@ document.addEventListener('DOMContentLoaded', async () => {
if (type === 'flag') {
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
}
if (type === 'delete' || type === 'archive') {
return { type };
if (type === 'forward') {
return { type, address: row.querySelector('.forward-input').value.trim() };
}
if (type === 'reply') {
return { type, replyType: row.querySelector('.reply-select').value };
}
return { type };
});
@ -903,6 +936,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, enabled };
const override = ruleEl.querySelector('.decision-threshold').value;
if (override.trim() !== '') rule.decisionThreshold = Number(override) / 100;
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
@ -911,6 +946,9 @@ document.addEventListener('DOMContentLoaded', async () => {
if (folders.length) rule.folders = folders;
return rule;
});
}
addRuleBtn.addEventListener('click', () => {
const data = readRules();
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] });
renderRules(data);
});
@ -927,6 +965,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
const rule = { criterion: r.criterion, actions };
if (r.decisionThreshold !== undefined) rule.decisionThreshold = r.decisionThreshold;
if (r.stopProcessing) rule.stopProcessing = true;
if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
@ -996,8 +1035,8 @@ document.addEventListener('DOMContentLoaded', async () => {
}
try {
const { aiCache } = await storage.local.get('aiCache');
cacheCountEl.textContent = aiCache ? Object.keys(aiCache).length : 0;
const { count } = await browser.runtime.sendMessage({ type: 'sortana:getCacheSize' });
cacheCountEl.textContent = count;
} catch {
cacheCountEl.textContent = '?';
}
@ -1044,14 +1083,22 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('import-data').addEventListener('click', () => importInput.click());
importInput.addEventListener('change', async () => {
if (importInput.files.length) {
try {
await dataTransfer.importData(importInput.files[0], selectedCategories());
location.reload();
} catch (error) {
window.alert(`Import failed: ${error.message}`);
}
}
});
initialized = true;
document.getElementById('save').addEventListener('click', async () => {
settingsError.textContent = '';
const apiType = apiTypeInput.value;
const decisionThreshold = thresholdInput.value.trim() === '' ? NaN : Number(thresholdInput.value) / 100;
const inputCharacterLimit = Number(inputLimit.value);
const endpoint = endpointInput.value.trim();
const model = modelSelect?.value || '';
const apiKey = apiKeyInput?.value.trim() || '';
@ -1068,63 +1115,25 @@ document.addEventListener('DOMContentLoaded', async () => {
}
const debugLogging = debugToggle.checked;
const htmlToMarkdown = htmlToggle.checked;
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
const criterion = ruleEl.querySelector('.criterion').value;
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
const type = row.querySelector('select').value;
if (type === 'tag') {
return { type, tagKey: row.querySelector('.tag-select').value };
const invalidThreshold = [...document.querySelectorAll('.decision-threshold'), thresholdInput, inputLimit].find(el => !el.checkValidity());
if (invalidThreshold) {
settingsError.textContent = 'Enter thresholds from 0% to 100% and a positive whole-number character limit.';
return;
}
if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value };
}
if (type === 'copy') {
return { type, copyTarget: row.querySelector('.folder-select').value };
}
if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' };
}
if (type === 'read') {
return { type, read: row.querySelector('.read-select').value === 'true' };
}
if (type === 'flag') {
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
}
if (type === 'forward') {
return { type, address: row.querySelector('.forward-input').value.trim() };
}
if (type === 'reply') {
return { type, replyType: row.querySelector('.reply-select').value };
}
return { type };
});
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const enabled = ruleEl.dataset.enabled !== 'false';
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, enabled };
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
if (accounts.length) rule.accounts = accounts;
if (folders.length) rule.folders = folders;
return rule;
}).filter(r => r.criterion);
const rules = readRules().filter(r => r.criterion);
const stripUrlParams = stripUrlToggle.checked;
const altTextImages = altTextToggle.checked;
const collapseWhitespace = collapseWhitespaceToggle.checked;
const tokenReduction = tokenReductionToggle.checked;
const showDebugTab = debugTabToggle.checked;
const theme = themeSelect.value;
await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
try { validateSettings({ apiType, decisionThreshold, inputCharacterLimit, aiRules: rules }); }
catch (error) { settingsError.textContent = error.message; return; }
await storage.local.set({ apiType, decisionThreshold, inputCharacterLimit, endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
await storage.local.remove(['templateName', 'customTemplate']);
await applyTheme(theme);
try {
await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
await AiClassifier.setConfig({ apiType, decisionThreshold, inputCharacterLimit, endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
logger.setDebug(debugLogging);
} catch (e) {
logger.aiLog('[options] failed to apply config', {level: 'error'}, e);

View file

@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFile } from 'node:fs/promises';
import { CONFIG_DEFAULTS } from '../modules/classificationConfig.js';
const persisted = {};
const listeners = [];
const storage = {
local: {
async get(keys) {
return Object.fromEntries((Array.isArray(keys) ? keys : [keys])
.filter(key => key in persisted).map(key => [key, structuredClone(persisted[key])]));
},
async set(values) {
const changes = {};
for (const [key, value] of Object.entries(values)) {
changes[key] = { oldValue: persisted[key], newValue: structuredClone(value) };
persisted[key] = structuredClone(value);
}
for (const listener of listeners) await listener(changes, 'local');
},
async remove(keys) {
for (const key of Array.isArray(keys) ? keys : [keys]) delete persisted[key];
}
},
onChanged: { addListener(listener) { listeners.push(listener); } }
};
const messenger = { storage, messages: { async get(id) {
// Numeric Thunderbird IDs may change after reinstall; RFC Message-ID does not.
return { subject: 'Invoice', headerMessageId: 'persistent-message@example.com' };
} } };
globalThis.messenger = messenger;
const classifier = await import('../modules/AiClassifier.js');
const transfer = await import('../options/dataTransfer.js');
const source = await readFile(new URL('../background.js', import.meta.url), 'utf8');
let onMessage;
const context = vm.createContext({
storage, messenger, structuredClone, AiClassifier: classifier, aiRules: [],
logger: { aiLog() {} }, logGetTiming: false,
htmlToMarkdown: false, stripUrlParams: false, altTextImages: false,
collapseWhitespace: false, tokenReduction: false, showDebugTab: false,
browser: { runtime: { onMessage: { addListener(listener) { onMessage = listener; } } } }
});
// Exercise the actual background storage and Details handlers, not a manual cache reload.
const normalizeStart = source.indexOf('function normalizeRules(');
vm.runInContext(source.slice(normalizeStart, source.indexOf('function iconPaths(', normalizeStart)), context);
const changesStart = source.indexOf('storage.onChanged.addListener(');
vm.runInContext(source.slice(changesStart, source.indexOf('\n\n if (browser.theme', changesStart)), context);
const messageStart = source.indexOf('browser.runtime.onMessage.addListener(');
vm.runInContext(source.slice(messageStart, source.indexOf('\n\n // Automatically classify', messageStart)), context);
test('export/reinstall/import restores Details immediately with changed Thunderbird IDs', async () => {
const settings = { ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://decision-server:8080', tokenReduction: true, inputCharacterLimit: 5000 };
const rules = [{ criterion: 'invoice', decisionThreshold: 0.8, actions: [] }];
await storage.local.set({ ...settings, aiRules: rules });
globalThis.fetch = async () => ({ ok: true, json: async () => ({ answers: { match: { type: 'noul', noul: 0.9 } } }) });
const key = await classifier.buildCacheKey(1, 'invoice');
await classifier.classify('Invoice attached', 'invoice', { cacheKey: key, threshold: 0.8 });
let exported;
const originalDocument = globalThis.document;
const createURL = URL.createObjectURL;
const revokeURL = URL.revokeObjectURL;
URL.createObjectURL = blob => { exported = blob; return 'blob:export'; };
URL.revokeObjectURL = () => {};
globalThis.document = { body: { appendChild() {} }, createElement() { return { click() {}, remove() {} }; } };
try { await transfer.exportData(['settings', 'rules', 'cache']); }
finally { globalThis.document = originalDocument; URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
await classifier.clearCache();
for (const key of Object.keys(persisted)) delete persisted[key];
classifier.setConfig(CONFIG_DEFAULTS);
context.aiRules = [];
assert.equal((await onMessage({ type: 'sortana:getDetails', id: 99 })).results.length, 0);
await transfer.importData(exported, ['settings', 'rules', 'cache']);
assert.equal((await onMessage({ type: 'sortana:getCacheSize' })).count, 1);
const details = await onMessage({ type: 'sortana:getDetails', id: 99 });
assert.equal(details.results.length, 1);
assert.equal(details.results[0].criterion, 'invoice');
assert.equal(details.results[0].probability, 0.9);
assert.equal(details.results[0].threshold, 0.8);
assert.equal(details.results[0].matched, true);
globalThis.fetch = () => { throw new Error('Restored cache must avoid inference'); };
assert.equal((await classifier.classify('Invoice attached', 'invoice', { cacheKey: await classifier.buildCacheKey(99, 'invoice') })).matched, true);
});

View file

@ -0,0 +1,167 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as chat from '../modules/backends/chatCompletions.js';
import * as decision from '../modules/backends/systemOne.js';
import { CONFIG_DEFAULTS, migratedInputLimit, validateSettings } from '../modules/classificationConfig.js';
const data = { aiCache: { legacy: { matched: true, reason: 'old' } }, aiReasonCache: { legacy: 'old' } };
globalThis.messenger = { storage: { local: {
async get(keys) { return Object.fromEntries((Array.isArray(keys) ? keys : [keys]).map(key => [key, data[key]])); },
async set(values) { Object.assign(data, structuredClone(values)); },
async remove(keys) { for (const key of Array.isArray(keys) ? keys : [keys]) delete data[key]; }
} }, messages: { async get(id) { return { headerMessageId: `message-${id}` }; } } };
const classifier = await import('../modules/AiClassifier.js');
const transfer = await import('../options/dataTransfer.js');
const answer = probability => ({ model: 'served-model', answers: { match: { type: 'noul', noul: probability } } });
let calls = [];
function serve(body, status = 200) {
globalThis.fetch = async (url, request) => { calls.push({ url, ...request }); return { ok: status === 200, status, json: async () => body }; };
}
test('adapters preserve chat prompts and isolate SystemOne payloads', () => {
const config = { ...CONFIG_DEFAULTS, model: 'chosen', customSystemPrompt: 'Custom guidance' };
const payload = chat.buildRequest('email', 'invoice', config);
assert.match(payload.messages[0].content, /Custom guidance/);
assert.match(payload.messages[1].content, /Classification Criterion: invoice/);
assert.equal(payload.max_tokens, 4096);
assert.deepEqual(decision.buildRequest('email', 'invoice', config), {
state: 'email', model: 'chosen', questions: { match: { type: 'noul', instructions: 'Does this email satisfy the following criterion: invoice' } }
});
assert.equal('model' in decision.buildRequest('email', 'invoice', CONFIG_DEFAULTS), false);
assert.equal('model' in chat.buildRequest('email', 'invoice', CONFIG_DEFAULTS), false);
});
test('parsers validate evidence including zero, one, and malformed answers', () => {
for (const probability of [0, 0.5, 1]) assert.equal(decision.parseResponse(answer(probability)).probability, probability);
for (const probability of [null, '0.5', -0.1, 1.1, Infinity, NaN]) assert.throws(() => decision.parseResponse(answer(probability)));
for (const raw of [null, {}, { answers: { other: { type: 'noul', noul: 0.5 } } }, { answers: { match: { type: 'choice', noul: 0.5 } } }]) assert.throws(() => decision.parseResponse(raw));
assert.deepEqual(chat.parseResponse({ choices: [{ message: { content: 'text {"match":false} then {"matched":true,"reason":"brace } inside"}' } }] }), { matched: true, reason: 'brace } inside', probability: null });
for (const content of ['', '{broken}', '{"match":"true"}']) assert.throws(() => chat.parseResponse({ choices: [{ message: { content } }] }));
assert.throws(() => chat.parseResponse(null));
});
test('URLs preserve proxy prefixes and normalize API suffixes', () => {
for (const suffix of ['', '/', '/v1', '/v1/', '/v1/models', '/v1/chat/completions/', '/v1/systemone']) {
assert.equal(classifier.buildEndpointUrl(`http://localhost:8080/proxy${suffix}`, 'systemone'), 'http://localhost:8080/proxy/v1/systemone');
assert.equal(classifier.buildModelsUrl(`http://localhost:8080/proxy${suffix}`), 'http://localhost:8080/proxy/v1/models');
}
assert.equal(classifier.buildEndpointUrl('example.com'), 'https://example.com/v1/chat/completions');
assert.equal(classifier.buildEndpointUrl(''), '');
});
test('settings validation and independent character-limit migration', () => {
assert.equal(migratedInputLimit({}), 3686);
assert.equal(migratedInputLimit({ aiParams: { max_tokens: 1000 } }), 900);
assert.equal(migratedInputLimit({ inputCharacterLimit: 500, aiParams: { max_tokens: 1000 } }), 500);
validateSettings({ decisionThreshold: 0, aiRules: [{ decisionThreshold: 1 }] });
for (const value of [-1, 2, null, '0.5']) assert.throws(() => validateSettings({ aiRules: [{ decisionThreshold: value }] }));
for (const value of [0, 1.2, NaN]) assert.throws(() => validateSettings({ inputCharacterLimit: value }));
});
test('cache migration, inference reuse, fingerprints, clearing and import', async () => {
await classifier.init();
assert.equal(await classifier.getCacheSize(), 0);
assert.equal(data.aiReasonCache, undefined);
assert.deepEqual(data.aiCache, {});
classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://localhost:8080', apiKey: 'secret', openaiOrganization: 'org', openaiProject: 'project' });
const snapshot = classifier.getConfig();
const key = await classifier.buildCacheKey(1, 'invoice', snapshot);
serve(answer(0.75)); calls = [];
const first = await classifier.classify('email', 'invoice', { snapshot, cacheKey: key, threshold: 0.75 });
assert.equal(first.matched, true);
assert.equal(first.model, 'served-model');
assert.deepEqual(calls[0].headers, { 'Content-Type': 'application/json', Authorization: 'Bearer secret' });
const second = await classifier.classify('email', 'invoice', { snapshot, cacheKey: key, threshold: 0.8 });
assert.equal(second.matched, false);
assert.equal(second.probability, 0.75);
assert.equal(calls.length, 1);
classifier.replaceCache(data.aiCache);
assert.equal(classifier.getCachedDetails(key, 0.75).matched, true);
assert.equal(await classifier.buildCacheKey(1, 'invoice', { ...snapshot, decisionThreshold: 0.9, apiKey: 'new secret' }), key);
for (const change of [{ apiType: 'chat-completions' }, { endpoint: 'http://other' }, { model: 'other' }, { tokenReduction: true }, { inputCharacterLimit: 500 }]) {
assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...snapshot, ...change }), key);
}
const chatSnapshot = { ...snapshot, apiType: 'chat-completions' };
const chatKey = await classifier.buildCacheKey(1, 'invoice', chatSnapshot);
assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...chatSnapshot, customSystemPrompt: 'other' }), chatKey);
assert.notEqual(await classifier.buildCacheKey(1, 'invoice', { ...chatSnapshot, aiParams: { ...snapshot.aiParams, temperature: 1 } }), chatKey);
serve({ choices: [{ message: { content: '{"match":false,"reason":"not invoice"}' } }] });
await classifier.classify('email', 'invoice', { cacheKey: chatKey, snapshot: chatSnapshot });
assert.equal(calls.at(-1).headers['OpenAI-Organization'], 'org');
assert.equal(await classifier.getCacheSize(), 2);
const exported = structuredClone(data.aiCache);
await classifier.clearCacheForMessage(1);
assert.equal(await classifier.getCacheSize(), 0);
await transfer.importData({ text: async () => JSON.stringify({ aiCache: { ...exported, legacy: true } }) }, ['cache']);
classifier.replaceCache(data.aiCache); // background storage listener consumes imported replacement
assert.equal(await classifier.getCacheSize(), 2);
await assert.rejects(transfer.importData({ text: async () => JSON.stringify({ decisionThreshold: 2 }) }, ['settings']));
await assert.rejects(transfer.importData({ text: async () => JSON.stringify({ aiRules: [{ decisionThreshold: -1 }] }) }, ['rules']));
await classifier.clearCache();
});
test('failures remain uncached and snapshots survive configuration changes', async () => {
classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone' });
const snapshot = classifier.getConfig();
const key = await classifier.buildCacheKey(2, 'invoice', snapshot);
for (const raw of [null, answer('0.9'), {}]) {
serve(raw);
await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }));
assert.equal(classifier.getCachedDetails(key), null);
}
serve({}, 503);
await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /503/);
globalThis.fetch = async () => { throw new Error('network'); };
await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /network/);
globalThis.fetch = async () => ({ ok: true, json: async () => { throw new SyntaxError('bad JSON'); } });
await assert.rejects(classifier.classify('email', 'invoice', { cacheKey: key, snapshot }), /bad JSON/);
classifier.setConfig({ apiType: 'chat-completions', endpoint: 'http://changed' });
serve(answer(1)); calls = [];
const result = await classifier.classify('email', 'invoice', { cacheKey: key, snapshot });
assert.equal(result.backend, 'systemone');
assert.equal(calls[0].url, 'http://127.0.0.1:5000/v1/systemone');
assert.equal('Authorization' in calls[0].headers, false);
await classifier.clearCache();
});
test('clearing or replacing cache during inference prevents stale repopulation', async () => {
classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone' });
const snapshot = classifier.getConfig();
const key = await classifier.buildCacheKey(3, 'invoice', snapshot);
for (const replace of [() => classifier.clearCache(), () => classifier.replaceCache({})]) {
let release, started;
const ready = new Promise(resolve => { started = resolve; });
globalThis.fetch = async () => {
started();
await new Promise(resolve => { release = resolve; });
return { ok: true, json: async () => answer(0.9) };
};
const pending = classifier.classify('email', 'invoice', { cacheKey: key, snapshot });
await ready;
await replace();
release();
assert.equal((await pending).matched, true);
assert.equal(classifier.getCachedDetails(key), null);
}
});
test('settings and rule imports retain thresholds and reject invalid caches', async () => {
const settings = { apiType: 'systemone', decisionThreshold: 0.75, inputCharacterLimit: 5000, tokenReduction: true };
const rules = [{ criterion: 'invoice', decisionThreshold: 0, actions: [{ type: 'flag', flagged: true }] }, { criterion: 'urgent', actions: [] }];
await transfer.importData({ text: async () => JSON.stringify({ ...settings, aiRules: rules }) }, ['settings', 'rules']);
for (const [key, value] of Object.entries(settings)) assert.equal(data[key], value);
assert.deepEqual(data.aiRules, rules);
const key = await classifier.buildCacheKey(4, 'invoice');
assert.equal(classifier.validCacheEntry(key, { version: 2, backend: 'systemone', probability: '0.8', reason: null, messageHash: key.split(':')[1], model: '' }), false);
});
test('SystemOne 404 reports endpoint, server message, and compatibility guidance', async () => {
classifier.setConfig({ ...CONFIG_DEFAULTS, apiType: 'systemone', endpoint: 'http://seinei.wageshouse:5003' });
serve({ error: { message: 'File Not Found' } }, 404);
await assert.rejects(classifier.classify('synthetic email', 'invoice'), error => {
assert.match(error.message, /HTTP 404 at http:\/\/seinei\.wageshouse:5003\/v1\/systemone/);
assert.match(error.message, /Server response: File Not Found/);
assert.match(error.message, /running server build supports/);
return true;
});
});

View file

@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFile } from 'node:fs/promises';
// Run the actual background rule/queue functions with Thunderbird services mocked.
const source = await readFile(new URL('../background.js', import.meta.url), 'utf8');
const functions = source.slice(source.indexOf('async function processMessage('), source.indexOf('async function clearCacheForMessages('));
function runner({ fail = false, changeSettings = false } = {}) {
const actions = [], evaluated = [], errors = [];
const rules = [
{ criterion: 'first', decisionThreshold: 0.8, actions: [{ type: 'flag', flagged: true }], stopProcessing: true },
{ criterion: 'second', actions: [{ type: 'read', read: true }] }
];
let currentConfig = { apiType: 'systemone', inputCharacterLimit: 5, decisionThreshold: 0.5 };
const context = vm.createContext({
structuredClone, Date, Error,
aiRules: rules, queue: Promise.resolve(), queuedCount: 0, processing: false,
currentStart: 0, tokenReduction: true, showDebugTab: false, timingStats: {},
updateActionIcon() {}, updateTimingStats() {}, showTransientIcon() {},
buildEmailText() { return 'email contents'; }, ICONS: { circle: 'circle' },
logger: { aiLog() {} }, recordError(...args) { errors.push(args); },
ERROR_NOTIFICATION_ID: 'error',
storage: { local: { async set() {} } },
messenger: {
messages: {
async getFull() { return {}; },
async get() {
if (changeSettings) {
rules[0].decisionThreshold = 0.1;
currentConfig = { apiType: 'chat-completions', decisionThreshold: 0.1 };
}
return { tags: [], folder: { accountId: 'account' } };
},
async update(id, action) { actions.push({ id, ...action }); }
}, identities: { async list() { return []; } }
},
browser: { runtime: { getURL: x => x }, notifications: { async create() {} } },
AiClassifier: {
getConfig() { return structuredClone(currentConfig); },
async buildCacheKey(id, criterion) { return `${id}:${criterion}`; },
async classify(text, criterion, options) {
evaluated.push({ text, criterion, ...options });
if (fail && options.cacheKey.startsWith('1:')) throw new Error('inference failed');
return { matched: true };
}
}
});
vm.runInContext(functions, context);
return { context, actions, evaluated, errors };
}
test('failure stops remaining rules for one email, next queued email still acts', async () => {
const { context, actions, evaluated, errors } = runner({ fail: true });
await vm.runInContext('applyAiRules([1, 2])', context);
assert.deepEqual(evaluated.map(x => x.cacheKey), ['1:first', '2:first']);
assert.deepEqual(actions, [{ id: 2, flagged: true }]);
assert.equal(errors.length, 1);
assert.equal(context.processing, false);
assert.equal(context.queuedCount, 0);
});
test('settings and rules remain fixed during an email; reduction uses characters', async () => {
const { context, evaluated, actions } = runner({ changeSettings: true });
await vm.runInContext('applyAiRules([1])', context);
assert.equal(evaluated[0].text, 'email');
assert.equal(evaluated[0].threshold, 0.8);
assert.equal(evaluated[0].snapshot.apiType, 'systemone');
assert.equal(evaluated.length, 1); // stop after first match
assert.equal(actions.length, 1);
});
test('inherited thresholds and eligibility preserve ordered rule processing', async () => {
const { context, evaluated, actions } = runner();
context.aiRules[0].enabled = false;
await vm.runInContext('applyAiRules([1])', context);
assert.equal(evaluated[0].criterion, 'second');
assert.equal(evaluated[0].threshold, 0.5);
assert.deepEqual(actions, [{ id: 1, read: true }]);
});