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`, - `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`,
`defaultParams.js` and `themeUtils.js`. `defaultParams.js` and `themeUtils.js`.
- `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3). - `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. - `resources/`: Images and other static files.
- `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.ps1`: PowerShell script to package the extension.
- `build-xpi.sh`: Bash 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 ## 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 ## 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. Sortana supports `apiType: "chat-completions"` (default) and `"systemone"`.
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. Plain adapter modules under `modules/backends/` own request construction and response
Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided. validation; `AiClassifier` owns shared transport, configuration snapshots, threshold
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. 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 ## Documentation
@ -71,10 +99,21 @@ text extracted from all text parts.
### Cache Strategy ### Cache Strategy
`aiCache` persists classification results. Each key is the SHA‑256 hex of `aiCache` persists version 2 inference evidence. Keys are
`"<message Message-ID>|<criterion>"` and maps to an object with `matched` and `reason` `v2:<SHA-256 Message-ID>:<SHA-256 stable configuration/criterion fingerprint>`.
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first Include backend, endpoint, model, effective request parameters and preprocessing;
time the add-on loads after an update. 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 ### 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. 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 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 HTTP endpoint. Choose **Chat Completions** for an OpenAI-compatible
URL and appends `/v1/chat/completions` when sending requests. Requests contain a system `/v1/chat/completions` service or **SystemOne** for decision models served through
message and a user message. The endpoint should respond `/v1/systemone`. Chat Completions returns a match boolean and textual explanation;
with text in `choices[0].message.content` containing JSON indicating whether the SystemOne returns a match probability, compared against a configurable threshold.
message meets a specified criterion, including a short reasoning summary. The options page stores a base URL and shows the resolved request URL.
Responses are parsed by extracting the last JSON object in that content and
expecting a `match` (or `matched`) boolean plus a `reason` string.
## Features ## 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. - **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. - **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. - **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. 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. - **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. - **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. - **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. - **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. - **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. - **Cache management** – clear cached results from the context menu or options page.
- **Queue & timing stats** – monitor processing time on the Maintenance tab. - **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. - **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 ### Cache Storage
Classification results are stored under the `aiCache` key in extension storage. Classification results are stored under `aiCache` using version 2 entries. Keys
Each entry maps a SHA‑256 hash of `"<message Message-ID>|<criterion>"` to an object combine a hashed Message-ID with a fingerprint of the criterion, backend,
containing `matched` and `reason` fields. Older installations with a separate endpoint, selected model, effective request settings, and preprocessing settings.
`aiReasonCache` will be migrated automatically on startup. 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 ## Architecture Overview
Sortana is implemented entirely with documented MailExtension/WebExtension APIs. No custom experiment code or Thunderbird-internal fallback paths are required: 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. - `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. - `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. - `_locales/` holds localized strings used throughout the UI.
### Key Files ### Key Files
@ -65,7 +135,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs.
| `background.js` | Startup tasks and classification queue management. | | `background.js` | Startup tasks and classification queue management. |
| `modules/AiClassifier.js` | Core classification logic and cache handling. | | `modules/AiClassifier.js` | Core classification logic and cache handling. |
| `options/options.html` and `options.js` | Endpoint and rule configuration UI. | | `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. | | `logger.js` | Colorized logging with optional debug mode. |
## Building ## Building
@ -84,7 +154,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs.
## Usage ## Usage
1. Open the add-on's options and set the base URL of your classification service 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. `/v1/models` and select a model or choose **None** to omit the `model` field.
Advanced settings include optional API key, organization, and project headers Advanced settings include optional API key, organization, and project headers
for OpenAI-hosted endpoints. for OpenAI-hosted endpoints.

View file

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

View file

@ -4,6 +4,33 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>AI Details</title> <title>AI Details</title>
<link rel="stylesheet" href="options/bulma.css"> <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> </head>
<body> <body>
<section class="section"> <section class="section">

View file

@ -51,15 +51,39 @@ async function loadMessage(id) {
article.className = `message ${color} mb-4`; article.className = `message ${color} mb-4`;
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'message-header'; 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'); const body = document.createElement('div');
body.className = 'message-body'; body.className = 'message-body';
const status = document.createElement('p'); const status = document.createElement('p');
status.textContent = r.matched ? 'Matched' : 'Did not match'; 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'); const pre = document.createElement('pre');
pre.textContent = r.reason || ''; pre.textContent = r.reason || '';
body.appendChild(status); body.append(status, pre);
body.appendChild(pre); }
article.appendChild(header); article.appendChild(header);
article.appendChild(body); article.appendChild(body);
container.appendChild(article); container.appendChild(article);

View file

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

View file

@ -1,438 +1,151 @@
"use strict"; "use strict";
import { aiLog, setDebug } from "../logger.js"; import { aiLog, setDebug } from '../logger.js';
import { DEFAULT_AI_PARAMS } from "./defaultParams.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 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"; export function normalizeEndpointBase(endpoint) {
const MODELS_PATH = "/v1/models"; if (typeof endpoint !== 'string' || !endpoint.trim()) return '';
return endpoint.trim().replace(/\/+$/, '').replace(/\/v1(?:\/(?:chat\/completions|completions|models|systemone))?$/i, '');
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 "";
} }
let base = endpoint.trim(); function urlFor(endpoint, path) {
if (!base) { const base = normalizeEndpointBase(endpoint);
return ""; return base ? `${/^https?:\/\//i.test(base) ? base : `https://${base}`}${path}` : '';
} }
base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, ""); export function buildEndpointUrl(endpoint, apiType = 'chat-completions') {
return base; if (!adapters[apiType]) throw new Error('Unknown API type.');
return urlFor(endpoint, adapters[apiType].path);
} }
export function buildModelsUrl(endpoint) { return urlFor(endpoint, '/v1/models'); }
function buildEndpointUrl(endpointBase) { export function getConfig() { return structuredClone(config); }
const base = normalizeEndpointBase(endpointBase); export function setConfig(update = {}) {
if (!base) { validateSettings(update);
return ""; 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}`; config.aiParams = { ...CONFIG_DEFAULTS.aiParams, ...config.aiParams };
const needsSlash = withScheme.endsWith("/"); for (const key of ['model', 'apiKey', 'openaiOrganization', 'openaiProject']) config[key] = String(config[key]).trim();
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") {
setDebug(config.debugLogging); setDebug(config.debugLogging);
} }
if (!gEndpoint) { async function hash(value) {
gEndpoint = buildEndpointUrl(gEndpointBase); 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}); async function messageHash(id) {
aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); const api = globalThis.messenger ?? globalThis.browser;
if (typeof id === 'number' && api.messages?.get) {
const header = await api.messages.get(id);
id = header.headerMessageId || id;
} }
return hash(String(id));
function buildAuthHeaders() {
const headers = {};
if (gApiKey) {
headers.Authorization = `Bearer ${gApiKey}`;
} }
if (gOpenaiOrganization) { function stable(value) {
headers["OpenAI-Organization"] = gOpenaiOrganization; 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) { export async function buildCacheKey(id, criterion, snapshot = getConfig()) {
headers["OpenAI-Project"] = gOpenaiProject; 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'));
} }
export function replaceCache(data = {}) {
function buildSystemPrompt() { cache = new Map(Object.entries(data || {}).filter(([key, entry]) => validCacheEntry(key, entry)));
return SYSTEM_PREFIX + (gCustomSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX; loaded = true;
generation++;
} }
async function saveCache() { await storage.local.set({ aiCache: Object.fromEntries(cache) }); }
function buildPrompt(body, criterion) { export async function init() {
aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true}); if (loaded) return;
return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`; 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 withPolicy(entry, threshold) {
function getCachedResult(cacheKey) { const isDecision = entry.backend === 'systemone';
if (!gCacheLoaded) { if (isDecision) validateThreshold(threshold);
return null; 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)) { export function getCachedDetails(key, threshold = config.decisionThreshold) {
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true}); const entry = cache.get(key);
const entry = gCache.get(cacheKey); return entry ? withPolicy(entry, threshold) : null;
return entry?.matched ?? 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;
} }
const endpoint = buildEndpointUrl(snapshot.endpoint, snapshot.apiType);
function getReason(cacheKey) { const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload) });
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,
});
if (!response.ok) { if (!response.ok) {
aiLog(`HTTP status ${response.status}`, {level: 'warn'}); let detail = '';
return false; 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 raw = await response.json();
const result = await response.json(); const entry = { ...adapter.parseResponse(raw), backend: snapshot.apiType,
aiLog(`[AiClassifier] Received response:`, {debug: true}, result); model: typeof raw.model === 'string' ? raw.model : snapshot.model, version: 2,
const parsed = parseMatch(result); messageHash: cacheKey?.split(':')[1] };
cacheEntry(cacheKey, parsed.matched, parsed.reason); if (cacheKey && epoch === generation) {
return parsed.matched; cache.set(cacheKey, entry);
} catch (e) { await saveCache();
aiLog(`HTTP request failed`, {level: 'error'}, e);
return false;
} }
return withPolicy(entry, threshold);
} }
export async function classifyText(text, criterion, cacheKey = null) { return (await classify(text, criterion, { cacheKey })).matched; }
async function init() { export async function removeCacheEntries(keys = []) {
await loadCache(); await init(); generation++;
for (const key of Array.isArray(keys) ? keys : [keys]) cache.delete(key);
await saveCache();
} }
export async function clearCacheForMessage(id) {
export { buildEndpointUrl, buildModelsUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; 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"; "use strict";
import { validateSettings, migratedInputLimit } from '../modules/classificationConfig.js';
import { validCacheEntry } from '../modules/AiClassifier.js';
const storage = (globalThis.messenger ?? browser).storage; const storage = (globalThis.messenger ?? browser).storage;
const KEY_GROUPS = { const KEY_GROUPS = {
settings: [ settings: [
'apiType', 'decisionThreshold', 'inputCharacterLimit', 'tokenReduction',
'endpoint', 'endpoint',
'model', 'model',
'apiKey', 'apiKey',
@ -36,12 +39,15 @@ export async function exportData(categories) {
URL.revokeObjectURL(url); 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 text = await file.text();
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
const data = {}; const data = {};
for (const key of collectKeys(categories)) { for (const key of collectKeys(categories)) {
if (key in parsed) data[key] = parsed[key]; 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); await storage.local.set(data);
} }

View file

@ -64,11 +64,25 @@
</div> </div>
</div> </div>
<p class="help is-danger mb-3" id="settings-error" role="alert"></p>
<div id="settings-tab" class="tab-content"> <div id="settings-tab" class="tab-content">
<h2 class="title is-4"> <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 class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
<span>Settings</span> <span>Settings</span>
</h2> </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"> <div class="field">
<label class="label" for="endpoint">Endpoint</label> <label class="label" for="endpoint">Endpoint</label>
<div class="control"> <div class="control">
@ -186,7 +200,12 @@
</label> </label>
</div> </div>
<div class="field"> <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"> <div class="control">
<input class="input" type="number" id="max_tokens"> <input class="input" type="number" id="max_tokens">
</div> </div>

View file

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