diff --git a/docs/site/connections.md b/docs/site/connections.md index 0b02fc400..d528823e4 100644 --- a/docs/site/connections.md +++ b/docs/site/connections.md @@ -175,6 +175,16 @@ agents call the same search as a tool, and the reply drafter, the assistant and are handed the passages that bear on a thread. Passages are treated as facts to cite, never as instructions. +Search matches words by default, so "time off" misses a policy titled "Leave of absence". Switch on +**Semantic search** (Settings → Assistant) and each passage is also embedded by a small model +when it is indexed; a search then merges the passages nearest in meaning with the keyword hits, and +every caller gets results in the same shape. It is off by default, and off changes nothing. The +embedder is any server that answers `/v1/embeddings`; the default is Ollama on this machine +(`ollama pull nomic-embed-text`), so no document leaves it. Pointing the address at a remote server +sends your documents' text there. Passages indexed before you switched it on are embedded by the next +Reindex, a changed model re-embeds them the same way, and if the model is unreachable search simply +uses keywords. `pip install taskuary[semantic]` adds numpy for a faster scan on a large library. + :::warn Planned is not available The Connections page lists cards that are on the roadmap as well as ones that work — NetSuite, SAP, Workday, ADP, Epic, Cerner, PointClickCare among them. A planned card tells you so on its diff --git a/pyproject.toml b/pyproject.toml index 37d95d6d8..61d8d8ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ build = ["pyinstaller>=6"] # on arrival - `pip install .[desktop,build,bundle]` before pyinstaller, or they ship dead. bundle = ["boto3>=1.28", "pyodbc>=4", "sqlalchemy>=2", "psycopg2-binary", "pymysql", "openpyxl>=3", "pypdf", "paramiko>=2.11"] docs = ["pypdf"] # reading .pdf into the knowledge base +semantic = ["numpy>=1.24"] # optional: a faster scan for semantic knowledge search (it works without; the embedder is a plain HTTP call) dev = ["pytest>=7", "httpx>=0.24", "openpyxl>=3", "paramiko>=2.11"] # openpyxl READS xlsx in tests; the writer is ours. # paramiko is optional for USERS and required to TEST the SFTP card: its host-key pinning is the # security of that connector, and skipping those tests where the library is absent would mean CI diff --git a/site/docs/connections.html b/site/docs/connections.html index 6c0ef8e56..eec3aec88 100644 --- a/site/docs/connections.html +++ b/site/docs/connections.html @@ -357,6 +357,15 @@

Data and report sources

Planned is not available

The Connections page lists cards that are on the roadmap as well as ones that work — NetSuite, SAP, Workday, ADP, Epic, Cerner, PointClickCare among them. A planned card tells you so on its face. If the assistant suggests connecting one, saying yes is a vote for building it, not a diff --git a/site/docs/search.json b/site/docs/search.json index 25a48d758..35cb2b9f0 100644 --- a/site/docs/search.json +++ b/site/docs/search.json @@ -1 +1 @@ -[{"t":"Start here","c":"Start here","u":"./","x":"What Taskuary is, how to install it, and what the first hour looks like."},{"t":"What Taskuary is","c":"Start here → What Taskuary is","u":"./#what-taskuary-is","x":""},{"t":"Install on Windows","c":"Start here → Install on Windows","u":"./#install-on-windows","x":""},{"t":"Install with Python","c":"Start here → Install with Python","u":"./#install-with-python","x":""},{"t":"Command-line options","c":"Start here → Command-line options","u":"./#command-line-options","x":""},{"t":"Docker","c":"Start here → Docker","u":"./#docker","x":""},{"t":"Your first run","c":"Start here → Your first run","u":"./#your-first-run","x":""},{"t":"Choosing the AI setup","c":"Start here → Choosing the AI setup","u":"./#choosing-the-ai-setup","x":""},{"t":"Where your data lives","c":"Start here → Where your data lives","u":"./#where-your-data-lives","x":""},{"t":"What leaves your machine","c":"Start here → What leaves your machine","u":"./#what-leaves-your-machine","x":""},{"t":"Updating","c":"Start here → Updating","u":"./#updating","x":""},{"t":"Next","c":"Start here → Next","u":"./#next","x":""},{"t":"How it works","c":"How it works","u":"./how-it-works","x":"One message from arrival to approval: the Timeline, the five roads, and what triage actually decides."},{"t":"The Timeline","c":"How it works → The Timeline","u":"./how-it-works#the-timeline","x":""},{"t":"The five roads a message can take","c":"How it works → The five roads a message can take","u":"./how-it-works#the-five-roads-a-message-can-take","x":""},{"t":"What triage actually decides","c":"How it works → What triage actually decides","u":"./how-it-works#what-triage-actually-decides","x":""},{"t":"Nothing sends itself","c":"How it works → Nothing sends itself","u":"./how-it-works#nothing-sends-itself","x":""},{"t":"Correcting it teaches it","c":"How it works → Correcting it teaches it","u":"./how-it-works#correcting-it-teaches-it","x":""},{"t":"Three brains, one voice","c":"How it works → Three brains, one voice","u":"./how-it-works#three-brains-one-voice","x":""},{"t":"The documents that govern it","c":"How it works → The documents that govern it","u":"./how-it-works#the-documents-that-govern-it","x":""},{"t":"Where the rest of the workspace fits","c":"How it works → Where the rest of the workspace fits","u":"./how-it-works#where-the-rest-of-the-workspace-fits","x":""},{"t":"Connections","c":"Connections","u":"./connections","x":"The accounts Taskuary may read, and the role that says what it may do with each."},{"t":"What a connection is","c":"Connections → What a connection is","u":"./connections#what-a-connection-is","x":""},{"t":"The five roles","c":"Connections → The five roles","u":"./connections#the-five-roles","x":""},{"t":"Mail","c":"Connections → Mail","u":"./connections#mail","x":""},{"t":"Chat","c":"Connections → Chat","u":"./connections#chat","x":""},{"t":"Repositories and trackers","c":"Connections → Repositories and trackers","u":"./connections#repositories-and-trackers","x":""},{"t":"Data and report sources","c":"Connections → Data and report sources","u":"./connections#data-and-report-sources","x":""},{"t":"Coding CLIs and AI providers","c":"Connections → Coding CLIs and AI providers","u":"./connections#coding-clis-and-ai-providers","x":""},{"t":"Adding another of the same kind","c":"Connections → Adding another of the same kind","u":"./connections#adding-another-of-the-same-kind","x":""},{"t":"Pushing something in yourself","c":"Connections → Pushing something in yourself","u":"./connections#pushing-something-in-yourself","x":""},{"t":"Tasks and agents","c":"Tasks and agents","u":"./tasks-and-agents","x":"A task, the agent work on it and the reply to the sender are three separate lives."},{"t":"A task's three lives","c":"Tasks and agents → A task's three lives","u":"./tasks-and-agents#a-tasks-three-lives","x":""},{"t":"Sending work to a coding agent","c":"Tasks and agents → Sending work to a coding agent","u":"./tasks-and-agents#sending-work-to-a-coding-agent","x":""},{"t":"The general agent","c":"Tasks and agents → The general agent","u":"./tasks-and-agents#the-general-agent","x":""},{"t":"Sessions and transcripts","c":"Tasks and agents → Sessions and transcripts","u":"./tasks-and-agents#sessions-and-transcripts","x":""},{"t":"Several agents in one repository","c":"Tasks and agents → Several agents in one repository","u":"./tasks-and-agents#several-agents-in-one-repository","x":""},{"t":"Checklists and closing","c":"Tasks and agents → Checklists and closing","u":"./tasks-and-agents#checklists-and-closing","x":""},{"t":"Replies","c":"Tasks and agents → Replies","u":"./tasks-and-agents#replies","x":""},{"t":"Profiles and playbooks","c":"Tasks and agents → Profiles and playbooks","u":"./tasks-and-agents#profiles-and-playbooks","x":""},{"t":"The task view and the Board","c":"Tasks and agents → The task view and the Board","u":"./tasks-and-agents#the-task-view-and-the-board","x":""},{"t":"The work rail","c":"Tasks and agents → The work rail","u":"./tasks-and-agents#the-work-rail","x":""},{"t":"How work is ordered","c":"Tasks and agents → How work is ordered","u":"./tasks-and-agents#how-work-is-ordered","x":""},{"t":"Assistant buttons","c":"Assistant buttons","u":"./assistant","x":"Every button under an Assistant card, the one question two of them ask, Remind me, and the words that work typed."},{"t":"One screen: the sidebar and the canvas","c":"Assistant buttons → One screen: the sidebar and the canvas","u":"./assistant#one-screen-the-sidebar-and-the-canvas","x":""},{"t":"The buttons on each card","c":"Assistant buttons → The buttons on each card","u":"./assistant#the-buttons-on-each-card","x":""},{"t":"The two buttons that ask one question","c":"Assistant buttons → The two buttons that ask one question","u":"./assistant#the-two-buttons-that-ask-one-question","x":""},{"t":"Pictures, and your voice","c":"Assistant buttons → Pictures, and your voice","u":"./assistant#pictures-and-your-voice","x":""},{"t":"Remind me","c":"Assistant buttons → Remind me","u":"./assistant#remind-me","x":""},{"t":"Everything the task page can do","c":"Assistant buttons → Everything the task page can do","u":"./assistant#everything-the-task-page-can-do","x":""},{"t":"Words that work typed","c":"Assistant buttons → Words that work typed","u":"./assistant#words-that-work-typed","x":""},{"t":"What the Assistant sees","c":"What the Assistant sees","u":"./assistant-tools","x":"Every tool the Assistant has: the summary it gets on every turn, and each tool in detail. Written from the code."},{"t":"The summary, sent every turn","c":"What the Assistant sees → The summary, sent every turn","u":"./assistant-tools#the-summary-sent-every-turn","x":""},{"t":"Table","c":"What the Assistant sees → Table","u":"./assistant-tools#table","x":""},{"t":"Task","c":"What the Assistant sees → Task","u":"./assistant-tools#task","x":""},{"t":"Agents","c":"What the Assistant sees → Agents","u":"./assistant-tools#agents","x":""},{"t":"New","c":"What the Assistant sees → New","u":"./assistant-tools#new","x":""},{"t":"Pipe","c":"What the Assistant sees → Pipe","u":"./assistant-tools#pipe","x":""},{"t":"Reports","c":"What the Assistant sees → Reports","u":"./assistant-tools#reports","x":""},{"t":"App","c":"What the Assistant sees → App","u":"./assistant-tools#app","x":""},{"t":"Look","c":"What the Assistant sees → Look","u":"./assistant-tools#look","x":""},{"t":"Reports and the Advisor","c":"Reports and the Advisor","u":"./reports","x":"Reading the systems you work in on a schedule, and the report whose job is to judge."},{"t":"A report is a pipeline","c":"Reports and the Advisor → A report is a pipeline","u":"./reports#a-report-is-a-pipeline","x":""},{"t":"Sources","c":"Reports and the Advisor → Sources","u":"./reports#sources","x":""},{"t":"Letting the AI write the source cards","c":"Reports and the Advisor → Letting the AI write the source cards","u":"./reports#letting-the-ai-write-the-source-cards","x":""},{"t":"The AI pass","c":"Reports and the Advisor → The AI pass","u":"./reports#the-ai-pass","x":""},{"t":"Where a run goes","c":"Reports and the Advisor → Where a run goes","u":"./reports#where-a-run-goes","x":""},{"t":"Schedules","c":"Reports and the Advisor → Schedules","u":"./reports#schedules","x":""},{"t":"The Advisor","c":"Reports and the Advisor → The Advisor","u":"./reports#the-advisor","x":""},{"t":"Stateful workflows","c":"Reports and the Advisor → Stateful workflows","u":"./reports#stateful-workflows","x":""},{"t":"Sage Intacct, specifically","c":"Reports and the Advisor → Sage Intacct, specifically","u":"./reports#sage-intacct-specifically","x":""},{"t":"API","c":"Reports and the Advisor → API","u":"./reports#api","x":""},{"t":"The Advisor","c":"The Advisor","u":"./advisor","x":"What goes into the brain that says what should happen next, its two clocks, and the outcome ledger that teaches it what worked."},{"t":"What it reads","c":"The Advisor → What it reads","u":"./advisor#what-it-reads","x":""},{"t":"Two clocks","c":"The Advisor → Two clocks","u":"./advisor#two-clocks","x":""},{"t":"What was not followed up","c":"The Advisor → What was not followed up","u":"./advisor#what-was-not-followed-up","x":""},{"t":"A page per person","c":"The Advisor → A page per person","u":"./advisor#a-page-per-person","x":""},{"t":"The outcome ledger","c":"The Advisor → The outcome ledger","u":"./advisor#the-outcome-ledger","x":""},{"t":"The one rule","c":"The Advisor → The one rule","u":"./advisor#the-one-rule","x":""},{"t":"What is built, and what is next","c":"The Advisor → What is built, and what is next","u":"./advisor#what-is-built-and-what-is-next","x":""},{"t":"On your phone","c":"On your phone","u":"./phone","x":"The same assistant over WhatsApp or Telegram, and what it may do there."},{"t":"The doorways","c":"On your phone → The doorways","u":"./phone#the-doorways","x":""},{"t":"What it can do","c":"On your phone → What it can do","u":"./phone#what-it-can-do","x":""},{"t":"The morning line","c":"On your phone → The morning line","u":"./phone#the-morning-line","x":""},{"t":"Handing over","c":"On your phone → Handing over","u":"./phone#handing-over","x":""},{"t":"What it will not do","c":"On your phone → What it will not do","u":"./phone#what-it-will-not-do","x":""},{"t":"Sections, For later and browsing, on the phone","c":"On your phone → Sections, For later and browsing, on the phone","u":"./phone#sections-for-later-and-browsing-on-the-phone","x":""},{"t":"Settings reference","c":"Settings reference","u":"./settings","x":"Every setting, generated from the same file the app and the assistant read."},{"t":"How to read this","c":"Settings reference → How to read this","u":"./settings#how-to-read-this","x":""},{"t":"Triage & agents","c":"Settings reference → Triage & agents","u":"./settings#triage-agents","x":""},{"t":"Triage & routing","c":"Settings reference → Triage & routing","u":"./settings#triage-routing","x":""},{"t":"Replies","c":"Settings reference → Replies","u":"./settings#replies","x":""},{"t":"Assistant","c":"Settings reference → Assistant","u":"./settings#assistant","x":""},{"t":"Assistant on your phone","c":"Settings reference → Assistant on your phone","u":"./settings#assistant-on-your-phone","x":""},{"t":"Advisor","c":"Settings reference → Advisor","u":"./settings#advisor","x":""},{"t":"Coder agent","c":"Settings reference → Coder agent","u":"./settings#coder-agent","x":""},{"t":"Agent permissions","c":"Settings reference → Agent permissions","u":"./settings#agent-permissions","x":""},{"t":"Notifications","c":"Settings reference → Notifications","u":"./settings#notifications","x":""},{"t":"Attachments & images","c":"Settings reference → Attachments & images","u":"./settings#attachments-images","x":""},{"t":"Sync & startup","c":"Settings reference → Sync & startup","u":"./settings#sync-startup","x":""},{"t":"Display","c":"Settings reference → Display","u":"./settings#display","x":""},{"t":"Routing policies","c":"Settings reference → Routing policies","u":"./settings#routing-policies","x":""},{"t":"Verdicts & notes","c":"Settings reference → Verdicts & notes","u":"./settings#verdicts-notes","x":""},{"t":"Audit integrity","c":"Settings reference → Audit integrity","u":"./settings#audit-integrity","x":""},{"t":"When something is wrong","c":"When something is wrong","u":"./troubleshooting","x":"What to check, in the order worth checking it."},{"t":"It will not start","c":"When something is wrong → It will not start","u":"./troubleshooting#it-will-not-start","x":""},{"t":"A connection stopped","c":"When something is wrong → A connection stopped","u":"./troubleshooting#a-connection-stopped","x":""},{"t":"Triage got it wrong","c":"When something is wrong → Triage got it wrong","u":"./troubleshooting#triage-got-it-wrong","x":""},{"t":"A reply has no confirmed delivery","c":"When something is wrong → A reply has no confirmed delivery","u":"./troubleshooting#a-reply-has-no-confirmed-delivery","x":""},{"t":"An agent is stuck","c":"When something is wrong → An agent is stuck","u":"./troubleshooting#an-agent-is-stuck","x":""},{"t":"A report returned nothing","c":"When something is wrong → A report returned nothing","u":"./troubleshooting#a-report-returned-nothing","x":""},{"t":"Reading the audit log","c":"When something is wrong → Reading the audit log","u":"./troubleshooting#reading-the-audit-log","x":""},{"t":"Where the logs are","c":"When something is wrong → Where the logs are","u":"./troubleshooting#where-the-logs-are","x":""},{"t":"Still wrong","c":"When something is wrong → Still wrong","u":"./troubleshooting#still-wrong","x":""},{"t":"","c":"Start here","u":"./","x":"Taskuary is a work assistant that runs on your own machine. It reads the places work arrives — mail, chats, issue trackers, monitoring, your finance system — decides what each thing actually is, and brings you the few that need you. Coding jobs go to a coding agent in your repository. Answers come back as drafts you approve. Nothing is sent without you. It keeps its data in a SQLite file in your home directory and serves its interface at http://127.0.0.1:7787 . There is no account to create and no server to rent. What Taskuary is Three sentences, because the rest of this site is detail: 1. Everything arrives in one Timeline. A vendor's email, a GitHub pull request, a Teams message, a failed nightly export and this morning's spend report are one list, in the order they happened. 2. Each item gets one verdict. A small AI reads it and decides: nothing to do, a sentence settles it, an agent should work it, let's talk it through, or it's yours. That verdict is visible, and arguing with it teaches the next one. 3. Work that can be done is done, and comes back for approval. A coding agent works in your checkout; replies wait on the task. Approving is always a human action. :::rule The one thing to understand Taskuary never sends anything by itself. Replies, comments, invoices and posts all wait for you on the task. The only deliberate exception is an alert you configured, which fires the moment its rule trips — an alert that waits for approval is not an alert. ::: Install on Windows Download [Taskuary.exe](https://github.com/ldbumble/taskuary/releases/latest/download/Taskuary.exe) and open it. It is one file: the app and the desktop shell together, with no Python and no installer. The first run creates %USERPROFILE%\\.taskuary\\ and opens the interface. Install with Python Python 3.10 or newer: The browser opens by itself. For the same interface in a native desktop window: :::note Which platform gets the most use CI runs the full test matrix on Windows, Linux and macOS. Development happens mostly on Windows, so the terminal, the desktop shell and the agent presets are best exercised there; macOS and Linux may still have rough edges. ::: Command line options The everyday options for the Python install are: Option What it does port PORT Overrides the configured server port for this run host HOST Overrides the configured server host; use 0.0.0.0 to listen on all interfaces no browser Starts the server without opening a browser tab debug Enables verbose console logging for requests, report runs and errors Run taskuary help for the complete command reference. Docker Runs Taskuary without putting Python on the host: Open http://127.0.0.1:7787 . Data lives in the taskuary data volume. The container gives you the Timeline, Review, Reports and Connections; coding CLIs and the optional WhatsApp bridge stay host programs, because both need a machine of their own. The compose file binds to localhost. Set TASKUARY_TOKEN before publishing the port anywhere else. Your first run Open Set up Taskuary on the Assistant. The first four steps get you to one useful result: 1. Say who you are. Add the name that signs your drafts, and your email so Taskuary can recognise your own messages. Detailed preferences can wait. 2. Connect one AI. Use an API provider, Ollama, or a signed in coding CLI you already pay for. Test its connection. Keep the current model defaults to start; reviewing every model assignment is optional. 3. Connect one work source. Choose one mailbox, chat, or issue tracker in [Connections](connections). Enable it as an input and test it. You do not need a mailbox if your first source is a tracker such as GitHub. 4. Press Read first items. This starts a real source read and shows its progress. Once items arrive, open one of the first five recent results to check its verdict and any draft. Replies still wait for your approval. If nothing arrives, the step explains how to check the source scope or try a new incoming message. The five items are a review sample, not an import limit: each source keeps its normal read scope and history settings. Starting with one source makes that first review easier. Setup does not overwrite an existing user's model or connection configuration. After that first result, add another source, connect a coding CLI if you want code written, or add a report for numbers you check by hand. The optional models and agents step opens the existing settings whenever you want to adjust cost or use different models for different jobs. Reports and the Hub can wait until you have a workflow that needs them. Two reports exist from the start: the End of day checkup and the Advisor , which looks for useful follow ups and patterns in your work. You can review or remove either in Reports. The Morning digest is available as an optional report. Choosing the AI setup Triage reads many messages cheaply; coding changes a repository rarely and wants a strong model. Taskuary lets those be different brains, and the choice is mostly about what you already pay for. Setup Triage, drafts, summaries Coding sessions Fits you if Two brains (recommended) a small cloud model on an API key your coding CLI's full model you have a low cost API key One brain, two gears the CLI's light model the same CLI's main model you have one CLI subscription and no key One brain, one gear the CLI's full model the same model simplest, and expensive for routine mail Local brain Ollama or any OpenAI compatible local server a coding CLI, optionally the same local model no key, and no mail leaving the machine With no cloud key, set the triage brain to your configured CLI in [Settings](settings triage agents), and give that CLI a light model on its connector card so routine triage does not burn the coding model. Where your data lives The Python and desktop installs keep everything in ~/.taskuary/ : File What it is taskuary.db the SQLite database — messages, tasks, settings, the audit log config.toml local configuration taskuary.log the application log playbooks/ .md one page per kind of job an agent has learned to do context/ the context file each coding session is handed at startup TASKUARY_HOME moves all of it. Docker uses /data inside the container. TASKUARY_HOST , TASKUARY_PORT and TASKUARY_TOKEN override server settings at runtime without being written back to the config file. :::warn Before you expose it beyond localhost Set [server].token in the config or provide TASKUARY_TOKEN , and send that token in the X Taskuary Token header. An unauthenticated Taskuary reachable from the network gives whoever finds it your mail, your repositories and an agent that can run commands. ::: What leaves your machine Taskuary calls the services you configure and nothing else. Hosted AI providers and coding CLIs receive the prompts their work needs. With a model running on your machine, those prompts stay on it too; connected services such as mail still use their own connections either way. Before a prompt reaches a hosted model, a headless CLI run or the first turn of an agent pane, Taskuary replaces recognisable credentials with labelled placeholders. The original message is never altered. A reply that still contains one of those placeholders is refused rather than sent. These are deterministic shape rules — they catch API keys and passwords that look like API keys and passwords, and they cannot catch every secret. Updating Settings → Updates shows the running build and the latest release, and installs it in place: the exe is swapped or pip install runs, and the app reopens. Connections and settings are untouched. For the Python install you can also do it yourself: Next [How it works](how it works) — one message from arrival to approval. [Connections](connections) — what Taskuary may read, and what it may do with it. [Reports and the Advisor](reports) — before you build your first report. The live API reference is at /api/docs while Taskuary is running.","body":true},{"t":"","c":"How it works","u":"./how-it-works","x":"Taskuary's whole behaviour is one loop: something arrives, it gets a verdict, the verdict decides who does the work, and whatever comes out waits for you. This page follows one message the whole way, then explains the machinery underneath it. The Timeline The Timeline is the front door: every connection with a trigger or feed role lands here, in the order things happened. An email from a vendor, a pull request, a Teams message, this morning's spend report and a failed nightly export are one list. Each row shows where it came from above its original time, so mail, chat, GitHub, a SQL report and an Advisor post are told apart at a glance. Calendar entries show the meeting time. Open a row for the full message, its attachments, what triage decided and why, any drafted reply, and the actions available on it. Beside the Timeline, the work rail holds what is actually waiting on you, under Urgent, On you, Goals, Agents working, For later, Reports, Advisor ideas and FYI. A thread or a step gone quiet past that person's usual day is slipped , inside On you, with Chase, Let it go, They answered elsewhere and Remind me under it (see [The Advisor](advisor what was not followed up)). A finished or closed item gets no row: the rail is what still wants something from you, never a history. See [The work rail](tasks and agents the work rail) for what puts a row there and what takes it off. The five roads a message can take Every message gets exactly one verdict, and there are only five places it can go. Road What it means What happens fyi nothing to do filed; it stays readable and nothing is created reply a sentence settles it a draft goes to the task for you to approve coding an agent at a keyboard a CLI session starts in the checkout it picks general worth thinking about a conversation opens on the task; no system is touched task yours to do it lands on your list and nothing works it Three of those — coding , general , task — are kinds stored on the task, and the kind is what routes the work. reply is a kind too. fyi creates nothing at all. Only coding is ever dispatched automatically. A conversation you did not ask for is noise, and a task is yours by definition, so both land on the Board and wait for your click. :::note What \"general\" means It means the assistant's conversation: there is nothing to type at a system, but thinking, reading or research would help — weigh an option, make sense of a thread, work out what to ask. Work a person genuinely has to do in the world (sit a course, sign a form, attend, decide) is the task road instead. ::: What triage actually decides The verdict is the last thing that happens, not the first. Eleven checks run in a fixed order, and only two of them ask a model anything. A gate is deterministic: same input, same answer, no AI call, no cost. Knowing which is which is how you tell \"triage got this wrong\" from \"a rule caught it before triage ever saw it\". Step Gate or AI What it does 1 Dedupe gate A message whose id is already stored is dropped. Nothing below runs. 2 Feed connections gate A connection marked feed lands on the Timeline and stops — no verdict, no AI call, no task. 3 Policy rules gate Your rules, plus whether the sender is known. skip and ignore end it here. 4 Deferral gate Deferred triage lands the message as triaging so the Timeline shows it at once; the rest runs later. 5 Chat opener gate \"hi\", \"you there?\" — filed to wait for the ask it opens, so a greeting never becomes a task. 6 Threading gate (scored) Similarity against open tasks picks attach or create ; then a thread that is not yours is refused. 7 Still the same ask? AI Chats only, and only when step 6 attached: does this line continue that task or start a new one? 8 Your standing ruling gate If you already ruled on this conversation it is filed, quoting your own words. Your rulings outrank the classifier. 9 An agent is waiting gate If a run is live on the attached task, no verdict is asked for — the message goes to the agent as its answer. 10 The verdict gate, then AI Tracker items, obvious noise and calendar invites settle with no call. Everything else is one call reading TRIAGE.md , SOUL.md , JOB.md , LEARNED.md and your past verdicts. 11 Acting on it gate fyi files. A reply files instead of drafting when replies are off for that channel. Anything else becomes a task with its kind. Two rules hold wherever AI is involved. With no AI connector, a message is filed with \"awaiting AI triage\" rather than guessed at. A call that fails, or answers something unreadable, is filed too — never assumed to be work. Triage breaking is quiet and safe, not destructive. So a message that never reached the classifier was stopped by steps 1–6 or 8–9, and the fix is a rule, a feed or a ruling. A message the classifier did judge and got wrong is corrected on the Triage tab. Nothing sends itself Review is the outbound decision queue, and it is the only road out. A drafted reply sits there with the original request beside it; you edit the wording or the recipients, then Approve & send returns it through the channel the conversation started on. You can also ask for a redraft, reject it, or decide no reply is needed. The same gate covers more than mail: a GitHub comment, an invoice, a bill posted to your finance system and a file written to a share are all proposals until you approve them. A connector card ships at a read only authority, and raising it is a deliberate act. :::rule One way to finish A task ends with Mark done — on the task page, on the Assistant's card, or by saying \"done\" in the chat. Sending the reply marks it done too, unless an agent is still working on it or a new message arrived meanwhile. An agent that says it is finished closes its own task, and that result stays on your work rail until you have read it. See [Tasks and agents](tasks and agents who is allowed to close it). ::: Correcting it teaches it The roads on the Triage tab are not decoration. Correcting a verdict writes the reason into TRIAGE.md , the document the classifier reads on the next message — so the correction applies to the next message like this one, rather than becoming a rule about this one sender. Every verdict is evidence. Editing a draft teaches voice; rejecting one teaches what should not be drafted; Not our task teaches where your responsibility ends. The exact decision is kept with its date, sender and subject, so future triage can judge whether a new message is genuinely similar. Repeated patterns become lessons in LEARNED.md . Each machine written line carries its evidence: s is strength. A hypothesis starts at 2 and is promoted at 4 or more, supported by at least three episodes across two people or threads. Contradictions weaken it; stale hypotheses decay. ev names the verdicts that taught it, so you can go and read them. seen is the last date on which it held. Delete a learned line and it is gone. Lines you write yourself carry no machine tag and are never rewritten. An inferred rule that would hide work waits in Proposed rules until you adopt it. SOUL.md always outranks LEARNED.md , and one setting switches the whole loop off. Three brains, one voice Three always on models do the thinking, and you talk to one voice. They are split by what each is allowed to read and to do, not by where they run. What it does What it reads When Acts? Triage Judges each arrival: task, goal, reply, FYI or ignored. Attaches it to the thread's task, names the goal step a mail serves, opens a goal for the open ended ask, names the profile, playbook and repository, assigns the worker TRIAGE.md ; the first 2,500 characters of SOUL.md and of JOB.md ; LEARNED.md 's promoted lines; routing memory; the playbook menu; the recent open tasks; the open goals and their steps every poll makes rows; never sends The assistant The voice and the hands: walks the rail, runs set up, connects things, moves things (assign, route, close, later) through a button or by asking the model for the verb COUNSEL.md , SOUL.md and JOB.md whole; the one item on the table on a turn moves things; never does the work, never proposes The Advisor The brain on the clock: the Advisor report, the morning brief and reflection say what is late, what was not followed up, what worked, which system is worth connecting the head of SOUL.md and JOB.md , COUNSEL.md ; your work as the hub sees it; the calendar; the knowledge base; the sources it watches; its own notes; LEARNED.md 's promoted lines and the last month of the outcome ledger with the pages of the people it names. At reflection: LEARNED.md whole, hypotheses too, and the outcome ledger on its schedule and when the app opens; reflection at launch and after three corrections; and on the event an agent stop or ask (or an agent cut off by a close, heard at the next launch), a reply, an attached mail, a PR, a close, a missed day, a report one small prompt that leaves a line and a pill on that item never; it leaves a line, an idea row or a proposal The workers (the coding agent, the general agent) are the fourth kind and not always on: they do the work a task names, in a session you can watch. COUNSEL.md is the voice for the assistant and the Advisor both. The split is a budget: the heavy reader works off the request path, on the clock; the voice that answers on a turn reads the one item on the table, and never recomputes what the Advisor computed. The outcome ledger is the Advisor's newest input. Every other store is written at the moment you click: the documents say what they say, LEARNED.md is what you do when you correct, and the verdict and routing memories stop at the verdict. Nothing recorded what you did next, or what the world did after Taskuary acted. The ledger does: one row per action the app took or proposed a reply sent, a task closed, an agent stopping and your next move in your own words, a pull request merged, a promise made, a sender ignored and a once a day sweep fills in what came of it from facts already in the store: did more mail arrive on the thread, did the task reopen, did the person deliver and when, did the ignored sender's mail become work anyway. No model runs in the sweep. What the facts mean is reflection's: it reads the resolved rows as evidence ( out ids, beside the mem , rv and task ids above) and writes habits into LEARNED.md with the same grammar and the same decay \"green PR from the coder: you merge it, 8 of 8\". A derived line never acts on its own: it changes which button comes first and what a proposal says, and deleting it from LEARNED.md is the whole undo. The whole of it every input, the two clocks, the ledger's row kinds and verdicts is on [The Advisor](advisor). The documents that govern it Taskuary's behaviour is plain Markdown under Settings → Docs . These are not configuration files around the edges — they are what the models are actually told. ![The eight operator documents and who reads them: TRIAGE, STYLE and JOB can be generated from three months of your mail; SOUL, CODER and COUNSEL you write; LEARNED and DIGEST write themselves](img/operator docs.svg \"Three are read out of your mailbox, three you write, two write themselves.\") Document Purpose Read by SOUL.md The constitution: rules, voice, escalation lines, repository map triage, replies, the assistant JOB.md What your job is: mission, responsibilities, who you serve, the cadences, what to protect. It weighs importance, never intent triage, the assistant, the morning brief TRIAGE.md What makes something a task, a reply or an FYI triage STYLE.md Greeting, tone, length, phrasing reply drafts COUNSEL.md How the assistant speaks, and how readily it takes a position the assistant, the morning brief, replies CODER.md How coding agents work and close out coding agents LEARNED.md The profile learned from your verdicts; always below SOUL.md triage, replies, coding agents DIGEST.md The current morning brief you Playbooks One page per kind of job: when it starts, which connections it uses, the steps, what an agent may d","body":true},{"t":"","c":"Connections","u":"./connections","x":"A connection is one account Taskuary may read — a mailbox, a chat, a repository, a tracker, a database, a bank feed. Each card on the Connections page is one named connection, and its roles decide what Taskuary is allowed to do with what it finds there. Nothing is polled without an enabled role. A card with no role is a saved credential and nothing more. A card's settings its roles, what each repository or source does, processing, the agent's prompts wait for its Save bar, which lists every change before it is kept; Discard puts them back. Leaving the card with changes unsaved asks first. Test, sign in and a new credential still act at once. What a connection is One card is one account, not one kind of system. Rename it for the account or environment it represents — Ops mailbox rather than IMAP — and use Add another on the card to connect the same kind again. Two mailboxes, two GitHub organisations, or separate production and staging databases are all normal, and each keeps its own roles, its own schedule and its own name in every prompt. Secrets are write only in the interface: you can replace a password, never read one back. A database connection string may contain {password} so the saved password stays separate from the readable part of the configuration. The five roles A role is a permission, not a ranking. Role What Taskuary may do trigger Send inbound items through triage — they can become tasks, drafts or FYIs feed Show inbound items on the Timeline, unread by any AI report Make the source available to scheduled reports tool Let agents query it while working a task :::rule Feed is not a quieter trigger feed means no model ever reads those items — cheaper, quieter, and no verdict to argue with. ignore is a judgement about a message and happens further down, in your routing policies. If a source is noisy but you still want to see it, make it a feed. ::: A card can hold more than one role. A mailbox is usually trigger and tool ; a database is usually report and tool . Mail System Notes Outlook Mail and calendar, through Microsoft sign in or a tenant app registration Gmail and IMAP Any IMAP mailbox. Approved replies go back through the provider's SMTP, in thread Taskuary reads your Sent folder as well as your inbox. That is what makes \"has this been answered?\" a real question rather than a guess, and it is what STYLE.md 's Generate from history learns your writing voice from. :::note A shared mailbox needs saying so If the mailbox is shared, mail addressed to the team reads as mail addressed to you unless Taskuary knows otherwise. Name your own addresses in SOUL.md so triage can tell \"someone asked me\" from \"someone asked the team\". ::: Chat System Notes Teams , Slack Messages enter the Timeline through triage Telegram Bot based inbound, approved in chat replies, the Assistant walk on your phone; each chat is opt in WhatsApp A local bridge for inbound messages, approved replies and the Assistant walk on your phone. Unofficial protocol — use a number you can afford to lose Discord Watches selected bot channels and posts approved replies back into the originating channel Apple Messages macOS only. Reads the Mac's local Messages history and replies through Messages.app; needs Full Disk Access and Automation permissions Mattermost , Rocket.Chat A server you host. Each watched channel is its own source, and approved replies post back into it. Rocket.Chat needs two values — the token and the user id issued beside it Matrix Any homeserver, matrix.org or your own. Replies go back as your account rather than a bot, so the account has to have joined the room Google Chat Spaces on the Timeline and replies back into them. Reuses the Gmail card's OAuth client, so there is one registration in Google Cloud rather than two Chat is different from mail in one way that matters: a chat message often has no clock on it. \"Can you look at this when you get a chance\" is a real ask with no deadline, and Taskuary keeps those as their own kind of item rather than inventing urgency for them. A greeting on its own is held until the ask it opens arrives. Repositories and trackers System Notes GitHub Repository discovery, issues and pull requests as triggers, task specific standing prompts GitLab Assigned issues and merge requests, GitLab.com or self hosted Azure DevOps Work items assigned to the connected user, through WIQL @Me Jira, Asana, Monday.com, ClickUp, Todoist Assigned work items enter the Timeline through triage Linear, Trello Assigned issues and cards Notion Pages shared with the integration appear as a feed when they change Sentry, PagerDuty New unresolved errors and open incidents join the same funnel A tracker is the clearest case for trigger : an issue assigned to you is, by definition, work addressed to you, and triage settles most of them without an AI call at all. GitHub: what Close out does The GitHub connection has a Close out step: what finishing a task does on GitHub when you press Close out . These are your choices what each repository requires (branch protection, required checks and reviews) is read from GitHub for every pull request, and the card only offers what it allows. Setting Choices (default first) The token needs Close out on a pull request Merges it · Only closes the task Contents: write Merge method Squash · Merge commit · Rebase used when the repository allows it (same) Red checks the repository does not require Merge anyway, with a note · Stop and ask me Offer Update branch on when the pull request is behind and its author allows maintainer edits Pull requests: write Offer Re run checks on re runs the failed Actions jobs Actions: write Close out on an issue closes it on Issues: write Post your reply as the close out's comment on even with Reply to issue/PR authors off Issues / Pull requests: write Close out anyway, past the repository's own rules off admin tokens only, and only on your press Administration A repository you follow can override the pull request choice, the merge method and Close out anyway. Check the token asks GitHub, per repository, whether the token may do what these settings turn on. Data and report sources The full list is long and grows; these are the ones worth knowing about by shape. Databases and files — SQL Server, SQLite, SharePoint Lists, Google Sheets, a Windows/SMB share, SFTP, REST and RSS, and any MCP server tool. PostgreSQL , MySQL , ClickHouse , Snowflake and BigQuery each have their own card: the same executor with the dialect filled in, the default port known and the missing driver named in the error you actually get. The older Any database (connection string) card is still there for anything else with a SQLAlchemy URL, including raw ODBC through pyodbc. All of them are reads — these cards ship no write verb at all, and the engine's own user is the ceiling, so point them at a read only account. Cloud and monitoring — AWS (S3 buckets, CloudWatch log groups, arbitrary service calls), Azure (blob containers, Log Analytics, arbitrary ARM paths, and it can reuse the Outlook app registration), Microsoft Entra ID, Prometheus, Datadog, WinRM for PowerShell on a remote Windows machine. Finance — Sage Intacct, QuickBooks Online, Zoho Invoice, and bank and card feeds through SimpleFIN or Teller. These are the cards where the read/write distinction bites: each ships at read , so an agent proposes a bill or an invoice and you approve it on the task. Raising a card to write is what lets a routing policy pass small, known items through without you. Markets — Yahoo Finance, CoinGecko, Alchemy, ECB reference rates, SEC EDGAR filings, and a strategy screen that filters another card's rows. With a key: Twelve Data (quotes and computed indicators across stocks, FX and crypto), Alpha Vantage (slow limits, no card, no expiry), Finnhub (quotes plus company news, earnings dates and insider transactions), Polygon.io (bars and full market snapshots), Tiingo (properly adjusted end of day history), Financial Modeling Prep (fundamentals and ratios rather than prices) and Alpaca (a broker's own feed — read only; this card has no order executor at all). FRED needs no key or account whatsoever and is the one a fresh install can use immediately. Which to pick is a question of what you are watching, not of which is best: Finnhub if you follow companies, Tiingo or Polygon if you follow prices, FMP if you follow balance sheets, FRED if you follow the economy. The web — search it or read one page of it, each a single REST call with a key. Exa and Tavily read the web for an agent (Tavily can hand back a written answer with its sources beside it); Brave Search answers from its own index; SerpApi and Serper give back a search engine's page as it was ranked, which matters when the ORDER is the finding. To read one page: Jina Reader (no account at all), Firecrawl (clean markdown), ScrapingBee (for a page that fights back) and Apify (when the crawl is one somebody already wrote). They overlap on purpose — the bill and the terms differ far more than the results do. What is deliberately NOT here is anything that DRIVES a browser — logging in, clicking, filling a form. That runs over CDP through a real browser session and cannot be reached from an API, so a card promising it would be a card that never works. Social — LinkedIn , Bluesky and Mastodon . Nothing from these reaches the Timeline as work: a public timeline is not an inbox, so they are report sources and tools rather than triggers. Like every connection they start at full authority, so an agent can post. A public post cannot be recalled: set the card to read and a post an agent drafts becomes a proposal you approve on the task. Agent tools — treg puts thousands of endpoints across many providers behind one key, billed per call. Searching its catalogue is free and commits nothing, so an agent may do it unattended; calling an endpoint spends real money and can reach services that publish or order. The card starts at full authority like every connection; set it to read and every call becomes a proposal. Your own documents — the Knowledge base card indexes SharePoint library folders and folders on this machine (docx, pptx, xlsx, html, text, and pdf with pypdf ) into Taskuary's own SQLite. Once anything is indexed, a kb_search report answers questions from it on a schedule, agents call the same search as a tool, and the reply drafter, the assistant and coding sessions are handed the passages that bear on a thread. Passages are treated as facts to cite, never as instructions. :::warn Planned is not available The Connections page lists cards that are on the roadmap as well as ones that work — NetSuite, SAP, Workday, ADP, Epic, Cerner, PointClickCare among them. A planned card tells you so on its face. If the assistant suggests connecting one, saying yes is a vote for building it, not a connection. (This callout used to name \"a long tail of market data providers\" here. Eight of them were built and the sentence was not updated, so the docs called working cards planned — the market cards above all work.) ::: Coding CLIs and AI providers AI providers — Anthropic, OpenAI, Azure OpenAI, OpenRouter, Ollama (and any OpenAI compatible local server: LM Studio, llama.cpp, vLLM), and the Meta Model API. Coding CLIs — Claude Code, Codex, Qwen Code, OpenCode, Kimi Code, Gemini, Cursor, Copilot, Muse Code and Devin, plus any CLI that accepts a prompt on stdin. The connector defines its command, arguments, model argument and working directory behaviour. Two practical notes. A headless agent needs its noninteractive or auto approval flag, or it will hang waiting for a click that cannot happen — the presets set this for you. And the card's Test action runs a small prompt through the CLI before it ever receives real work, which is the fastest way to find out that a sign in expired. Every CLI card has a light model as well as a main one. Naming the same CLI as both your coder and your triage brain is normal and cheap, and it is the light ","body":true},{"t":"","c":"Tasks and agents","u":"./tasks-and-agents","x":"A task in Taskuary is three records, not one: the durable job, the agent work done on it, and how it is closed out the reply to whoever asked, and on GitHub the merge or the closed issue. They start and finish independently, and keeping them apart is what stops the word \"done\" meaning three different things. A task's three lives The task page shows them as one numbered workflow — 1 Task → 2 Agent work → 3 Close out — and each stage owns exactly one state badge. Part What it records Main controls What it never does by itself Task The durable job and who owns it owner, kind, priority, Reopen, Mark done Starting or ending an agent session does not complete it Agent work One or more attempts by a coding or non coding agent, plus the saved result harness, model, start, save and end session Ending a session does not mark the task done or send a reply Close out The reply to the person who asked, and the last act where the task lives (merge its pull request, close its issue) write, generate, edit; Close out , Decline , Not yet Sending it marks the task done — unless an agent is still working, a new message came in, or its merge still waits While a terminal or an assistant chat is live, the task collapses to a one line context strip and the workspace takes most of the page. Full controls, saved results and restart choices come back when the agent stops. Who is allowed to close it ![Every way a task ends: you press Mark done, send the reply, tick the last box, hand it to a person or say Not ours; or an agent says it is done or its pull request merges](img/how a task ends.svg \"Every road ends in Mark done, or says why the task stays open.\") There is one close, Mark done , and every road below leads to it. Mark done always does the same five things: the task is done, any unsent draft is kept but retired, a live agent is stopped, it leaves your work rail, and the \"yours to end\" mark comes off. You or an agent does What happens Stays open when Mark done — the task page, the Assistant's button or card, \"done\" or \"close\" in the chat, the phone Mark done never Close out with a reply Sends it, then Mark done an agent is still working on it, or a new message came in while you were answering — it says why Tick the last checklist box Mark done an agent is still working on it Hand it to a person Forwards it, then Mark done never Not ours / Not a task Deleted if nothing was done on it, otherwise Mark done never Mark done on a channel that cannot send (the draft stays unsent) Mark done never An agent says it is finished ( taskuary done ) The session is written up; the task waits for you with its close out (below) and the draft if a reply is owed, otherwise it is done its close out or a reply waits on you, or you opened the session yourself Close out on GitHub, or Decline Merges or closes the pull request, or closes the issue, posts your reply, then Mark done never The pull request or issue it came from is merged or closed Same as an agent saying it is finished, with nothing owed never A task an agent or a merged pull request closed stays on your work rail until you have read it. The close out: what finishes it where it lives Mail is finished by the reply. A pull request is finished by merging it, and an issue by closing it. Whatever the system, the card has the same three buttons only the line under them says what they do there: Button What it does Close out Finishes the task the normal way for its system, sends your reply with it, then Mark done Decline Closes it without doing it, where the system has that difference a pull request closed unmerged Not yet Nothing happens; the task stays open and on you (in the Assistant's walk this is Next ) The task's work is The rail says What Close out does there A pull request the agent opened, or the one the task came from ✉️ ready to close out Merges it the way you set on the GitHub connection (a draft is marked ready first), then posts your reply as its comment. The card reads the pull request from GitHub first: when the repository's rules say it cannot merge yet it says why and offers what fits Update branch , Re run checks , or Close out anyway where you allowed it An issue the task came from ✉️ ready to close out Posts your reply as the closing comment and closes the issue Mail, chat, anything else ✉️ ready to close out Sends your reply If the act is refused, nothing is sent. Merging or closing the pull request on GitHub yourself counts as answering it: the task closes by itself. The buttons are yours, so Agents may push / deploy does not have to be on that switch is about what an agent may do. A task an agent finished before this existed gets its card on the next sync. A session you started yourself is yours to complete: the agent is told so, and if it says it is finished anyway its sentence is filed on the task and the task stays open until you mark it done. These never end a task: You do What happens instead Save and end session The agent stops and its report is written; the task stays open Reject a draft The draft is rejected; the task is untouched Next Read and moved on; open work comes back after a few hours Remind me Put away until the day you pick, then back on your rail that morning A stopped or quiet session Nothing — only you, or the agent saying so, ends a task Reopening a completed task starts a fresh task lifecycle rather than pretending an old terminal is still alive. Sending work to a coding agent Open Agent work , choose the CLI and model, and start. The session is handed the task summary, the incoming messages, the attachments, any saved result from an earlier run and your optional new prompt — plus a context file under ~/.taskuary/context/ holding the thread, relevant sender and topic history, the learned profile, and reports from related closed tasks. Control What it does Start coding session Starts the selected harness and model Start new coding session Picks a different harness after an earlier run stopped or hit a limit; the checkout and history are kept Save and end session Ends the session and writes its result; the task stays open until you mark it done Mark done is stronger than all of them: it closes the task and ends any live session, because a finished task should not leave an orphan process running. What an agent is doing An agent is always in one of six states. Each has one word and one mark, and the work rail, the Timeline, the task page, the Board, the assistant's cards and the phone all use the same ones. Mark State What it means Your move ⏳ waiting to start Handed to an agent, not running yet Start now ⚙️ agent working Running, and needs nothing from you Leave a note for when it stops 👋 agent waiting on you Running, and it asked you something, needs your approval, or is stuck Answer it 💾 session saved You ended it with Save and end session ; its report is written Continue session , or Mark done ⏹ agent stopped It ended without finishing: a crash, a closed pane, Taskuary closing Continue session , or hand it on ✅ agent finished The agent closed the task, or the item it came from was merged or closed Read the result ![How an agent starts: handed off, auto start allowed or not, a free slot or next in line](img/agent start.svg \"Waiting to start always says why.\") ![While it runs: working, or waiting on you a question, an approval, stuck on a limit, quiet at its prompt](img/agent run.svg \"Your answer puts it back to work.\") ![How it ends: agent finished, session saved, the task closed, or agent stopped and Continue session](img/agent end.svg \"Only a session that ended by itself reads as stopped.\") It ended because What you see What brings it back The agent said it was done its Close out card if its work lives on GitHub, then ✅ agent finished ready to close out if a reply is owed a new message on it You pressed Save and end session 💾 session saved the report is written, the task stays open Continue session You pressed Mark done the task is closed and the agent stopped they write again The session crashed or was closed, or Taskuary closed ⏹ agent stopped a reply it had held is back for your yes Continue session , or Run another agent Continue session works for a coding agent (it reopens its own CLI session, or a fresh one seeded with the handover) and a regular agent (its saved conversation). It asks what to tell it as it picks up optional; on the phone the next thing you type is the note, or tap Continue as is . The saved result is deliberately short. Every finished coding session also writes a full Markdown artifact with that result and the complete transcript — Work details → Full artifact on the task page. When the agent opens a browser With the optional agent browser tool installed, the page the agent is on appears live beside the terminal. Take over hands you the mouse and keyboard for a password or a 2FA code the agent must never type; Snapshot keeps the frame on the task as an attachment. On the Wall, a narrow tile shows a \"browser\" chip that opens the page over the session. The general agent Not every job is code. A general task opens a conversation instead of a terminal: research, planning, writing, weighing an option, working out what to ask. No system is touched. You reach it from Talk it through on a Timeline row, from + New → Give an agent a job → Just talk it through , or from Prepare me for it on a calendar invite. General chats save their provider, model and native conversation id across restarts. Claude and Codex resume that exact conversation when it still exists and the provider configuration matches; otherwise the assistant continues from Taskuary's own saved history and says so. Sessions and transcripts Continue previous work on the Assistant page lists unfinished tasks that have saved agent work, each with an excerpt of the latest reply or handover. Resume opens the task and carries on; Review draft opens a pending result. Loading the page starts nothing, and a session already running is opened rather than duplicated. Reopening a pane seeds it from a render of the session, not from raw bytes, so a pane you come back to shows what the screen actually looked like. Several agents in one repository Taskuary can run more than one coding session against a shared checkout, and spends real effort on not letting them collide: Affinity routing asks whether a queued task is likely to touch the same files as a running one. Likely overlap waits, and starts by itself when the first session ends. Tell the agent queues your notes until the CLI is back at its prompt, so a note never lands mid turn or on top of a question waiting for you. Lists drip in one item per stop; pasted screenshots are saved locally and named in the note. The blackboard shows what each session has actually modified, computed from Git and the run trace rather than from what the agent said it would do. A new agent is handed that picture at startup. First in has control. The newcomer is told which files belong to another session and must not edit, revert, stash or commit them. Live handoffs on the Board shows what each agent is working on, what is blocked and what is ready for someone else, with read markers for which agents have seen each note. The Hub is the longer lived version of the same idea: discoveries and decisions by topic, so knowledge outlives the job that produced it. Checklists and closing A task can carry a checklist. Tick the boxes as you go, then press Mark done — ticking the last box does not close the task on its own. Closing a task ticks every remaining box: a closed task with open items is a lie about its own state. Replies The Reply section exists whenever the task has an incoming sender. It shows one of three things: no draft yet, a draft ready, or a reply sent. Open on the task is the only road out — type your own or generate one, edit it, approve it. One reply is special. A clarification stops the active agent when it is sent and moves the task to waiting, because an external answer is now required. An ordinary reply","body":true},{"t":"","c":"Assistant buttons","u":"./assistant","x":"The Assistant walks you through what is waiting: one item at a time, with a few buttons under it. There are eight buttons in all. Anything without a button still works when you ask for it in your own words, in the app or on your phone. One screen: the sidebar and the canvas Taskuary is one screen. The sidebar on the left is your work rail; the canvas on the right is the conversation, and whatever part of the app is in use opens inside it. Where What is there Top of the sidebar New (a task, a report or a workflow), then Reports , Connections , Hub and Settings Under the buttons Work \\ Timeline which rows the rail shows and the filter, then the day's count and Sync now The rail On you , Agents working , For later , Reports , Advisor ideas , FYI click a heading to walk that section The canvas The item on the table: a task opens as its full task view Task, Agent work, Close out with Next under it The top bar Board every agent and its session, full screen and back to the Assistant Reports, Connections, Hub and Settings are browsed in the canvas , by clicks: a button shows that part's sections, a section shows its cards, and a card opens with its own controls Back returns to the list. Ask the Assistant about the card that is open (\"why is this failing?\", \"turn this on\") and it knows which one you mean. An agent's session opens inside the task view, nearly the full height of the canvas; Expand gives it all of it. On a phone the task view is the screen, and Expand takes the session full screen with a back arrow to the chat. The buttons on each card ![Which buttons each kind of card shows](img/assistant buttons.svg \"Next is on every card. Nothing else is offered unless that card can actually do it.\") Button Shown on What it does Runs Next every card Marks it read and moves on. Open work you pass comes back after a few hours (Settings → Passed work comes back after) at once Mark done a draft reply, a finished agent, a task The one close: the task is done, an unsent draft is retired, a live agent is stopped, and it leaves your rail at once Close out a draft reply, a task's close out (a pull request to merge, an issue to close) Finishes the task the normal way for where it lives, then Mark done: sends the draft; on GitHub merges the pull request or closes the issue and posts the reply as its comment. The line under the card says which you confirm Run it an agent's proposal Runs the action the agent proposed you confirm Reply a person's ask, a finished agent with no draft Writes a draft reply. Nothing is sent at once Make a task an ask, an fyi, an idea A task on your own list. No agent starts you confirm Send to agent an ask, an fyi, an idea, a meeting, a report A task and an agent on it. The card asks which agent you confirm Not ours a draft reply, a proposal (not a close out), an ask, an fyi Files it. The card asks how far you confirm Send back to the agent a draft reply on a task (under More actions); a close out GitHub refuses for merge conflicts (the main button) Picks the task's own agent back up with your note or, on a conflict, GitHub's reason as the first thing it hears. The card comes back when it stops at once Save and end session an agent waiting on you Writes up what the session did and stops the agent. The task stays open at once Remind me any card with an open task behind it Asks for a day, then puts the task away until that morning: under For later on your rail, with its day. On the phone the days come as choices you pick the day A handful of fyi comes as one card with its own All read, next button. A close out card for a pull request also shows Decline (close it without merging) on the card only, never as a chat word or a phone choice and no separate Reject: the reply on it is edited or redrafted in place. The two buttons that ask one question ![Not ours asks how far; Send to agent asks which agent](img/assistant questions.svg \"The card asks; nothing runs until you confirm.\") Button Answer What happens Not ours Just this once This message is filed. Its task is deleted if nothing was done on it, otherwise marked done From now on Triage learns to file everything from this sender. Their mail still arrives and stays readable A rule in Settings An exclusion rule: their mail never reaches triage, and what already arrived leaves the Timeline. Reversible Send to agent A coding agent A task and a coding agent in a repository. The card asks which repository when it is not clear A non coding agent A task and an agent for reading, checking, drafting or research Send to agent starts on the agent triage would pick; the other is one click away on the card. Pictures, and your voice Paste a screenshot, drop an image, or use the paperclip in the chat line, in New , and in Continue session . It goes with your words: the Assistant looks at the picture itself, and a coding agent is handed the file to open. PNG, JPEG, GIF and WebP, up to eight at a time. The microphone beside each box dictates into it. Continue session opens as a card under the item, not a popup: type or say what to tell the agent as it picks up, add a picture, or continue as is. Moving on with Next puts the card away with the item. Remind me Every open task has Remind me on its page: tomorrow, next week, in 2 weeks, in a month, or any day from the calendar. Until that day the task waits under For later on your rail, saying when it comes back. That morning a note goes on the task and it is back under On you , however long ago it arrived. Ask the Assistant the same thing — \"bring TQ 0123 back in two weeks\" — and it does it at once, with an undo. Everything the task page can do Anything you can do on a task's page, you can ask the Assistant to do — about the task in front of you, or any task you name (TQ 0123). It shows you a card first, like every other action. It never guesses which task you mean. ![What you can ask about a task, and what each does](img/assistant task tools.svg \"One tool per action on the task page.\") Say, about a task What happens \"make this urgent\", \"rename it\", \"I'll take it\" Priority, title or owner changed \"this is a general job, not coding\" What kind of work it is changes, and a live session is closed if it no longer fits \"this belongs in the ledger repo\" The task moves to that repository \"tick off the first item\" That checklist box is ticked — the last box is Mark done \"add a note that Erin wants it by Friday\" A note on the task \"forward it to Erin\" A hand off is written for your yes — nothing is sent until you approve it \"ask the sender which date range they need\" The question is written for your yes \"start the coding agent on TQ 0123\" An agent starts on that task; it asks which repository when it is not clear \"continue the agent where it left off\" The agent's own last session picks up again \"stop the agent on TQ 0123\" Save and end session on that task's agent \"split this in two\" / \"it's the same job as TQ 0120\" Two tasks, or this one folded into the other \"this isn't a task\" Deleted, and triage learns it was not work \"it's done\" / \"reopen TQ 0123\" Mark done, or open again \"bring it back in two weeks\" Remind me — away until that morning \"send it\" / \"reject that draft\" / \"make it shorter\" The draft is sent, rejected, or written again Words that work typed These have no button. Say them, in the app or on your phone. Say What happens Runs \"reply and tell them…\" A draft with that gist, for your yes at once \"redraft it shorter\" The draft written again with the change at once \"yes, go ahead\" to a waiting agent Your answer goes to the agent you confirm \"run it again\" on a report The report runs again at once \"remember that…\" A fact kept in Settings → Verdicts & notes, used by triage and the Assistant you confirm \"clear all the fyi\" Clears that set from the walk, with the count on the card you confirm \"set up a report that…\" A walk through for a report, connection or workflow you confirm \"stop hiding auto replies\" The setting is changed, with an undo at once \"what's waiting on me?\" Looked up and answered — tasks, reports, mail, calendar, settings at once \"what's in For later?\" The rail read by its own sections On you, Agents working, For later (with when each comes back), Reports, FYI at once Later and Tomorrow are gone: Next is the \"not now\", and Remind me puts a task away until a day.","body":true},{"t":"","c":"What the Assistant sees","u":"./assistant-tools","x":"Check your calendar when

When the thread mentions a day, a time, a meeting or availability, the responder fetches your busy slots for the next 14 days and is told: never offer a busy time; if the asked time is busy, say so and offer the nearest free one; if the calendar could not be read, say you will confirm. The task gets a note that the calendar was checked.

Agents can read the same thing: POST /api/tools/run with type "calendar".

Assistant#

+

+

kb_semantic · type: switch · default: off

+

Find documents by meaning, not only by their words: "time off" finds the leave policy.

+

Off (the default): the knowledge base is searched by keywords, exactly as before, and nothing extra is installed or run.

+

On: each passage is turned into a vector by a small embedding model as it is indexed, and a search merges the nearest passages with the keyword hits. Needs an embedding model served at the address below (Ollama by default: ollama pull nomic-embed-text). Passages indexed before you switched this on are embedded by the next Reindex. If the model is unreachable, search quietly falls back to keywords.

+

The text of your documents goes to that address to be embedded. The default is this machine; point it at a remote server only if you are content for the documents to go there. pip install taskuary[semantic] (numpy) makes the scan faster on a large library.

+

Embedding server#

+

kb_embed_url · type: text · default: http://127.0.0.1:11434

+

Address of a server that answers /v1/embeddings (Ollama, LM Studio, llama.cpp, vLLM).

+

Default http://127.0.0.1:11434, Ollama on this machine. Any server speaking the OpenAI embeddings surface works.

+

Embedding model#

+

kb_embed_model · type: text · default: nomic-embed-text

+

The model that turns passages into vectors (default nomic-embed-text).

+

Changing it re-embeds the library on the next Reindex; passages embedded with the old model are ignored meanwhile, so results fall back to keywords until then.

+

Least similarity (0-1)#

+

kb_semantic_min · type: number · default: 0.5

+

How close a passage must be in meaning to count as a hit.

+

A nearest-neighbour search always has a nearest neighbour, even when nothing is related, so passages below this cosine similarity are dropped. Raise it if unrelated passages turn up; lower it if related ones are missed. Scales differ between models, and the default 0.5 is a starting point, not a measurement for yours.

The pipe reaches back (hours)#

funnel_hours · type: number · default: 12

How far back ordinary mail, reports and fyi enter the pipe the assistant walks you through. Drafts waiting for your yes and agents waiting on you always enter.

diff --git a/taskuary/kbembed.py b/taskuary/kbembed.py new file mode 100644 index 000000000..40650dcdd --- /dev/null +++ b/taskuary/kbembed.py @@ -0,0 +1,132 @@ +"""Optional semantic search for the knowledge base (knowledge.py). + +Off by default, and while it is off nothing here runs: no embedder is built and no vector is written. +Nothing it needs is a required dependency (numpy, when installed, is only imported to speed up the scan). Switched on (Settings -> Assistant) a passage is +embedded as it is indexed, and `knowledge.search` merges the nearest passages with the keyword hits. + +- The embedder speaks the OpenAI embeddings surface (`POST {url}/v1/embeddings`), the same standard + llm.py already uses for the Ollama brain. The default is a small model on Ollama at 127.0.0.1, so a + passage never leaves the machine; LM Studio, llama.cpp and vLLM answer the same call. Pointing the URL + at a remote server sends your documents' text there - that is the owner's call, and the help says so. + What is sent has been through redact.scrub, the same credential scrub every hosted model call gets. +- The vectors live in the knowledge index's own SQLite database (store.kb_vec), one unit-length + float32 BLOB per passage, written in the same transaction as the passage. Search is a brute-force dot + product over all of them: numpy when it is installed (`pip install taskuary[semantic]`), pure Python + when it is not. For one owner's few thousand documents that is milliseconds to a fraction of a second + (tools/bench_kb_semantic.py), and a vector database is infrastructure somebody else has to run. +- Keyword and vector hits are merged by reciprocal rank fusion: it needs no score calibration between + bm25 and cosine, which is why it is the simple, proven choice. +""" +import array, math, time +from loguru import logger + +DEFAULT_URL, DEFAULT_MODEL, DEFAULT_MIN = 'http://127.0.0.1:11434', 'nomic-embed-text', 0.5 +BATCH = 32 # passages per request: a local server answers a few dozen at once without a timeout +QUERY_CHARS = 2000 # a thread can be pages long; the start of it is what the question is about +RRF_K = 60 # the constant from the original paper; it only damps how fast the ranks fall off + +try: + import numpy as _np +except ImportError: + _np = None + + +def enabled(store) -> bool: + return store.get_setting('kb_semantic', '0') == '1' + +def floor(store) -> float: + """The least cosine similarity a passage needs to count as a hit. A nearest-neighbour search always has + NEAREST neighbours, even when nothing is close - without a floor `block()` would quote the least + unrelated paragraph into every prompt. It depends on the model, so it is a setting.""" + try: return max(0.0, min(1.0, float(store.get_setting('kb_semantic_min') or DEFAULT_MIN))) + except (TypeError, ValueError): return DEFAULT_MIN + + +# ── vectors ─────────────────────────────────────────────────────────────────────────────────── +def unit(v) -> list: + """The vector scaled to length 1, so a dot product IS the cosine. Refuses what is not a usable vector.""" + n = math.sqrt(sum(x * x for x in v)) + if not v or not n or not math.isfinite(n): raise ValueError('the embedder returned an empty or zero vector') + return [x / n for x in v] + +def pack(v) -> bytes: + return array.array('f', unit(v)).tobytes() + +def unpack(b: bytes) -> array.array: + a = array.array('f'); a.frombytes(b); return a + + +def nearest(qv, rows: list, k: int, least: float = 0.0) -> list: + """[(ChunkId, similarity)] best first, at most k, none below `least`. `rows` is store.kb_vectors: + [(ChunkId, Dim, blob)]; a row of another dimension cannot be compared and is skipped.""" + q = list(unit(qv)) + rows = [r for r in rows if r[1] == len(q)] + if not rows: return [] + if _np is not None: + m = _np.frombuffer(b''.join(r[2] for r in rows), dtype=_np.float32).reshape(len(rows), len(q)) + sims = m @ _np.asarray(q, dtype=_np.float32) + top = _np.argpartition(-sims, min(k, len(rows)) - 1)[:k] + scored = [(rows[i][0], float(sims[i])) for i in top] + else: + scored = [(cid, sum(map(float.__mul__, unpack(b), q))) for cid, _, b in rows] + return sorted((s for s in scored if s[1] >= least), key=lambda s: -s[1])[:k] + + +def fuse(*ranked: list, k: int = RRF_K) -> list: + """Reciprocal rank fusion: each list casts 1/(k+rank) for every key it holds, best first. + [['a','b'], ['b','c']] -> b (in both lists) first. [(key, score)], best first.""" + score = {} + for lst in ranked: + for rank, key in enumerate(lst, 1): score[key] = score.get(key, 0.0) + 1.0 / (k + rank) + return sorted(score.items(), key=lambda kv: -kv[1]) + + +# ── the embedder ────────────────────────────────────────────────────────────────────────────── +def make(store): + """The embedder the settings name - a callable `embed(texts) -> [packed vector bytes]` carrying `.model` - + or None when semantic search is off. It raises on a server that is down or answers badly; callers + (knowledge.reindex, knowledge.search) catch that and fall back to keyword search.""" + if not enabled(store): return None + base = (store.get_setting('kb_embed_url') or DEFAULT_URL).strip().rstrip('/') + model = (store.get_setting('kb_embed_model') or DEFAULT_MODEL).strip() + + def embed(texts: list) -> list: + import requests + from . import redact + out = [] + for i in range(0, len(texts), BATCH): + part = [redact.scrub(t) for t in texts[i:i + BATCH]] # a credential in a document never reaches a remote embedder + r = requests.post(f'{base}/v1/embeddings', json={'model': model, 'input': part}, timeout=180) # the first call loads weights + if r.status_code != 200: raise RuntimeError(f'embeddings at {base} answered {r.status_code}: {r.text[:200]}') + data = sorted(r.json().get('data') or [], key=lambda d: d.get('index', 0)) + if len(data) != len(part): raise RuntimeError(f'asked for {len(part)} embeddings, got {len(data)}') + out += [pack(d['embedding']) for d in data] + return out + embed.model = model + return embed + + +def backfill(store, embed, cid=None, per_call: int = BATCH) -> int: + """Embed every passage that has no vector from this model yet; how many were done. Incremental - a run + that dies halfway resumes where it stopped - and it is what makes switching the setting on after the + library was indexed work: the next reindex fills in the rest.""" + done = 0 + while True: + rows = store.kb_unembedded(embed.model, cid, per_call) + if not rows: return done + vecs = embed([r['Text'] for r in rows]) + store.kb_vec_put(embed.model, [(r['ChunkId'], v) for r, v in zip(rows, vecs)]) + done += len(rows) + + +def semantic_hits(store, embed, text: str, limit: int, cid=None) -> list: + """The passages nearest the question, best first, one per document, in the shape store.kb_search returns.""" + t0 = time.time() + qv = unpack(embed([text[:QUERY_CHARS]])[0]) + near = nearest(qv, store.kb_vectors(embed.model, cid), limit * 4, floor(store)) + hits, seen = [], set() + for h in store.kb_chunk_hits([c for c, _ in near]): + if h['doc_id'] not in seen: seen.add(h['doc_id']); hits.append(h) + if len(hits) >= limit: break + logger.debug(f'knowledge: semantic search over {len(near)} near passages in {int((time.time() - t0) * 1000)}ms') + return hits \ No newline at end of file diff --git a/taskuary/knowledge.py b/taskuary/knowledge.py index 97a32111b..486ea3b70 100644 --- a/taskuary/knowledge.py +++ b/taskuary/knowledge.py @@ -25,6 +25,7 @@ from datetime import datetime from pathlib import Path from loguru import logger +from . import kbembed # nothing in it runs while semantic search is off (numpy is imported only if installed) EXTS = ('txt', 'md', 'csv', 'tsv', 'json', 'html', 'htm', 'docx', 'pptx', 'xlsx', 'pdf') CHUNK, OVERLAP = 1200, 150 # chars; a chunk is one passage a model can quote, with a little of its neighbour @@ -201,6 +202,7 @@ def reindex(store, connector_id=None, prune: bool = True) -> dict: changed; drop what is gone. Unchanged files (same modified stamp and size) are not re-read - a nightly run over a library that did not move costs a listing and nothing else.""" t0, res = time.time(), {'indexed': 0, 'unchanged': 0, 'removed': 0, 'skipped': 0, 'errors': [], 'sources': 0} + emb = _embedder(store, res) # None unless the owner switched semantic search on (and it is reachable) for c in cards(store, connector_id): cid, cfg = c['ConnectorId'], cfg_of(c) exts = exts_of(cfg) @@ -222,18 +224,32 @@ def reindex(store, connector_id=None, prune: bool = True) -> dict: res['errors'].append(f'{name}: {e}'); continue parts = chunk(text) if not parts: res['skipped'] += 1; continue + vecs = None + if emb: + try: vecs = emb(parts) + except Exception as e: # a dead embedder never blocks indexing: keyword search still works, the backfill retries + res['errors'].append(f'semantic search: {e} - this run indexes keywords only'); emb = None store.kb_put({'ConnectorId': cid, 'Source': label, 'Path': rel, 'Name': name, 'Modified': modified, - 'Size': size, 'Chars': len(text)}, parts) + 'Size': size, 'Chars': len(text)}, parts, vecs, getattr(emb, 'model', '') if vecs else '') res['indexed'] += 1 if prune: res['removed'] += store.kb_prune(cid, label, seen) except Exception as e: res['errors'].append(f'{label}: {e}') logger.warning(f'knowledge: {label} failed - {e}') + if emb: # passages indexed before it was switched on, or with another model, or while it was down + try: res['embedded'] = kbembed.backfill(store, emb, cid) + except Exception as e: res['errors'].append(f'semantic search: {e}') store.set_setting(f'kb_last:{cid}', json.dumps({'at': datetime.now().isoformat(sep=' ', timespec='seconds'), **store.kb_count(cid), **{k: v for k, v in res.items() if k != 'errors'}, 'errors': res['errors'][:5]}), 'knowledge') res.update(store.kb_count(connector_id), ms=int((time.time() - t0) * 1000)) return res +def _embedder(store, res: dict): + """The embedder when semantic search is on, else None - and then no embedder is built and nothing is sent anywhere.""" + if store.get_setting('kb_semantic', '0') != '1': return None + try: return kbembed.make(store) + except Exception as e: res['errors'].append(f'semantic search: {e}'); return None + def _query(text: str, most: int = 40) -> str: """An FTS5 query out of prose: the distinctive words, OR-ed, so bm25 ranks by how many a passage carries. Quoted, so nothing in the text is read as FTS syntax.""" @@ -249,9 +265,29 @@ def search(store, text: str, limit: int = 8, connector_id=None) -> list: """Ranked passages for a question or a thread: [{'name','path','source','modified','snippet','score'}].""" q = _query(text) hits = store.kb_search(q, limit, connector_id) if q else [] + if store.get_setting('kb_semantic', '0') == '1': hits = _hybrid(store, text, hits, limit, connector_id) return [{**h, 'snippet': passage(h.pop('text', '') or '', h.get('snippet') or '')} for h in hits] +def _hybrid(store, text: str, keyword: list, limit: int, connector_id=None) -> list: + """Keyword hits merged with the nearest passages by reciprocal rank fusion, per document. A document + both found keeps the keyword passage (its snippet shows the match); one only meaning found shows its + nearest passage. Any trouble with the embedder - down, wrong model, nothing embedded yet - returns the + keyword hits unchanged: semantic search adds recall, it is never a way for search to fail.""" + if not (text or '').strip(): return keyword + try: + emb = kbembed.make(store) + near = kbembed.semantic_hits(store, emb, text, limit, connector_id) if emb else [] + except Exception as e: + logger.warning(f'knowledge: semantic search unavailable, keyword results only - {e}') + return keyword + if not near: return keyword + by_doc = {h['doc_id']: h for h in near} + by_doc.update({h['doc_id']: h for h in keyword}) + fused = kbembed.fuse([h['doc_id'] for h in keyword], [h['doc_id'] for h in near]) + return [{**by_doc[d], 'score': round(s, 4)} for d, s in fused[:limit]] + + def passage(text: str, snip: str = '', size: int = HIT_CHARS * 2) -> str: """What a hit quotes: the whole chunk when it is short, else the sentences around the match. FTS5's 48-token snippet cut "anything over $2,500 needs the CFO's approval" to "anything over …", and the model could not say what the @@ -291,6 +327,7 @@ def run_kb_reindex(cfg): picks one Knowledge card when there are several.""" r = reindex(cfg['store'], cfg.get('connector_id')) head = (f"{r['indexed']} indexed, {r['unchanged']} unchanged, {r['removed']} removed - {r['docs']} documents / {r['chunks']} passages" + + (f", {r['embedded']} passages embedded" if r.get('embedded') else '') + (f", {len(r['errors'])} problems" if r['errors'] else '')) return head, json.dumps(r, indent=1) diff --git a/taskuary/settings_defaults.json b/taskuary/settings_defaults.json index a7dbbd488..d260cc1bf 100644 --- a/taskuary/settings_defaults.json +++ b/taskuary/settings_defaults.json @@ -1,6 +1,10 @@ { "_": "The shipped default of every Settings knob that has one, for the docs build (website/tools/build-docs.mjs), which runs in node and cannot import store.py: store.DEFAULT_SETTINGS, plus the knobs whose fallback is a named constant in code. tests/test_settings_defaults_json.py fails when this copy is out of date and says how to regenerate it.", "defaults": { + "kb_semantic": "0", + "kb_embed_url": "http://127.0.0.1:11434", + "kb_embed_model": "nomic-embed-text", + "kb_semantic_min": "0.5", "intent_classify_enabled": "1", "triage_ai": "", "triage_backup_ai": "", diff --git a/taskuary/settings_schema.json b/taskuary/settings_schema.json index c5e4a6bd3..57e912011 100644 --- a/taskuary/settings_schema.json +++ b/taskuary/settings_schema.json @@ -27,6 +27,34 @@ "Other" ], "knobs": { + "kb_semantic": { + "group": "Assistant", + "label": "Semantic search", + "type": "switch", + "desc": "Find documents by meaning, not only by their words: \"time off\" finds the leave policy.", + "help": "Off (the default): the knowledge base is searched by keywords, exactly as before, and nothing extra is installed or run.\n\nOn: each passage is turned into a vector by a small embedding model as it is indexed, and a search merges the nearest passages with the keyword hits. Needs an embedding model served at the address below (Ollama by default: `ollama pull nomic-embed-text`). Passages indexed before you switched this on are embedded by the next Reindex. If the model is unreachable, search quietly falls back to keywords.\n\nThe text of your documents goes to that address to be embedded. The default is this machine; point it at a remote server only if you are content for the documents to go there. `pip install taskuary[semantic]` (numpy) makes the scan faster on a large library." + }, + "kb_embed_url": { + "group": "Assistant", + "label": "Embedding server", + "type": "text", + "desc": "Address of a server that answers /v1/embeddings (Ollama, LM Studio, llama.cpp, vLLM).", + "help": "Default http://127.0.0.1:11434, Ollama on this machine. Any server speaking the OpenAI embeddings surface works." + }, + "kb_embed_model": { + "group": "Assistant", + "label": "Embedding model", + "type": "text", + "desc": "The model that turns passages into vectors (default nomic-embed-text).", + "help": "Changing it re-embeds the library on the next Reindex; passages embedded with the old model are ignored meanwhile, so results fall back to keywords until then." + }, + "kb_semantic_min": { + "group": "Assistant", + "label": "Least similarity (0-1)", + "type": "number", + "desc": "How close a passage must be in meaning to count as a hit.", + "help": "A nearest-neighbour search always has a nearest neighbour, even when nothing is related, so passages below this cosine similarity are dropped. Raise it if unrelated passages turn up; lower it if related ones are missed. Scales differ between models, and the default 0.5 is a starting point, not a measurement for yours." + }, "intent_classify_enabled": { "group": "Triage & routing", "label": "Intent triage", diff --git a/taskuary/store.py b/taskuary/store.py index 572686356..bb7e64842 100644 --- a/taskuary/store.py +++ b/taskuary/store.py @@ -411,6 +411,9 @@ def _audit_payload(et, eid, action, actor, actor_type, run_id, detail): CREATE TABLE IF NOT EXISTS kb_doc (DocId INTEGER PRIMARY KEY, ConnectorId INTEGER, Source TEXT, Path TEXT, Name TEXT, Modified TEXT, Size INTEGER, Chars INTEGER, IndexedAt TEXT); CREATE TABLE IF NOT EXISTS kb_chunk (ChunkId INTEGER PRIMARY KEY, DocId INTEGER, Seq INTEGER, Text TEXT); +-- optional semantic search (kbembed.py): one unit-length float32 vector per passage, written only while the +-- owner has it switched on. Keyed by the embedding MODEL: a changed model leaves old rows unread, then re-embedded. +CREATE TABLE IF NOT EXISTS kb_vec (ChunkId INTEGER PRIMARY KEY, Model TEXT, Dim INTEGER, Vec BLOB); CREATE TABLE IF NOT EXISTS metric (MetricId INTEGER PRIMARY KEY, Name TEXT UNIQUE, Label TEXT, Grain TEXT, Definition TEXT, SpecJson TEXT, Notes TEXT, Status TEXT DEFAULT 'draft', ConnectorId INTEGER, Skill TEXT, LastCheckAt TEXT, LastCheckPass INTEGER, LastCheckNote TEXT, @@ -565,6 +568,9 @@ def _audit_payload(et, eid, action, actor, actor_type, run_id, detail): # a sound in the app and the browser's own desktop notification - each its own switch 'hand_sound': 'chime', 'hand_desktop': '1', 'calendar_enabled': '1', # a reply about time reads the owner's calendar first + # optional semantic search over the knowledge base (kbembed.py). OFF: nothing is embedded or imported. + 'kb_semantic': '0', 'kb_embed_url': 'http://127.0.0.1:11434', 'kb_embed_model': 'nomic-embed-text', + 'kb_semantic_min': '0.5', # the assistant's POST on the Timeline (assistant.py): what it looks for, the silence and # quiet that count as news, how much it says. Its clock and its instruction live on the # Reports tab (the seeded 'Assistant' report). @@ -5262,9 +5268,10 @@ def task_detail(self, task_id): # ── knowledge base (knowledge.py): documents as passages behind an FTS5 index ── def kb_doc(self, cid, source, path): return self._one('SELECT * FROM kb_doc WHERE ConnectorId=? AND Source=? AND Path=?', (cid, source, path)) - def kb_put(self, doc: dict, chunks: list) -> int: + def kb_put(self, doc: dict, chunks: list, vecs: list = None, model: str = '') -> int: """Replace one document's passages in a single transaction - _exec commits per statement, - and a library of a thousand files is not a thousand fsyncs per file.""" + and a library of a thousand files is not a thousand fsyncs per file. `vecs` (kbembed.pack blobs, + one per passage) ride in the same transaction, so a passage and its vector are never out of step.""" with self.lock: cur = self.cx.cursor() old = cur.execute('SELECT DocId FROM kb_doc WHERE ConnectorId=? AND Source=? AND Path=?', @@ -5276,12 +5283,42 @@ def kb_put(self, doc: dict, chunks: list) -> int: did = cur.lastrowid for i, t in enumerate(chunks): cur.execute('INSERT INTO kb_chunk (DocId, Seq, Text) VALUES (?,?,?)', (did, i, t)) - if self.kb_fts: cur.execute('INSERT INTO kb_fts (Text, ChunkId) VALUES (?,?)', (t, cur.lastrowid)) + cid = cur.lastrowid + if self.kb_fts: cur.execute('INSERT INTO kb_fts (Text, ChunkId) VALUES (?,?)', (t, cid)) + if vecs and i < len(vecs) and vecs[i]: + cur.execute('INSERT OR REPLACE INTO kb_vec (ChunkId, Model, Dim, Vec) VALUES (?,?,?,?)', (cid, model, len(vecs[i]) // 4, vecs[i])) self.cx.commit(); self._writes += 1 return did def _kb_drop(self, cur, did): if self.kb_fts: cur.execute('DELETE FROM kb_fts WHERE ChunkId IN (SELECT ChunkId FROM kb_chunk WHERE DocId=?)', (did,)) + cur.execute('DELETE FROM kb_vec WHERE ChunkId IN (SELECT ChunkId FROM kb_chunk WHERE DocId=?)', (did,)) cur.execute('DELETE FROM kb_chunk WHERE DocId=?', (did,)); cur.execute('DELETE FROM kb_doc WHERE DocId=?', (did,)) + def kb_unembedded(self, model: str, cid=None, limit: int = 64) -> list: + """Passages with no vector from `model` yet: what a first run after switching semantic search on, a changed + model, or a reindex whose embedder was down still owes. [{'ChunkId', 'Text'}]""" + w, p = (' AND d.ConnectorId=?', (cid,)) if cid else ('', ()) + return self._rows('SELECT c.ChunkId, c.Text FROM kb_chunk c JOIN kb_doc d ON d.DocId=c.DocId ' + f'LEFT JOIN kb_vec v ON v.ChunkId=c.ChunkId AND v.Model=? WHERE v.ChunkId IS NULL{w} ORDER BY c.ChunkId LIMIT ?', + (model, *p, limit)) + def kb_vec_put(self, model: str, items: list): + """items: [(ChunkId, blob)]. One transaction; a passage that already has a vector (another model's) is replaced.""" + with self.lock: + self.cx.executemany('INSERT OR REPLACE INTO kb_vec (ChunkId, Model, Dim, Vec) VALUES (?,?,?,?)', + [(i, model, len(b) // 4, b) for i, b in items]) + self.cx.commit(); self._writes += 1 + def kb_vectors(self, model: str, cid=None) -> list: + """[(ChunkId, Dim, blob)] for every passage embedded with `model` - the whole set, because the search is a brute-force scan.""" + w, p = (' JOIN kb_chunk c ON c.ChunkId=v.ChunkId JOIN kb_doc d ON d.DocId=c.DocId WHERE v.Model=? AND d.ConnectorId=?', (model, cid)) \ + if cid else (' WHERE v.Model=?', (model,)) + with self.lock: return [tuple(r) for r in self.cx.execute(f'SELECT v.ChunkId, v.Dim, v.Vec FROM kb_vec v{w}', p).fetchall()] + def kb_chunk_hits(self, chunk_ids: list) -> list: + """The kb_search hit shape for these passages, in the order given (no snippet: nothing matched a WORD).""" + if not chunk_ids: return [] + rows = {r['ChunkId']: r for r in self._rows( + 'SELECT c.ChunkId, d.DocId, d.Name, d.Path, d.Source, d.Modified, c.Seq, c.Text FROM kb_chunk c JOIN kb_doc d ON d.DocId=c.DocId ' + f'WHERE c.ChunkId IN ({",".join("?" * len(chunk_ids))})', tuple(chunk_ids))} + return [{'doc_id': r['DocId'], 'name': r['Name'], 'path': r['Path'], 'source': r['Source'], 'modified': r['Modified'] or '', + 'seq': r['Seq'], 'score': 0.0, 'snippet': '', 'text': r['Text'] or ''} for i in chunk_ids if (r := rows.get(i))] def kb_prune(self, cid, source, keep: set) -> int: """Drop the documents of one source that a fresh walk did not see - deleted files leave the index.""" gone = [r for r in self._rows('SELECT DocId, Path FROM kb_doc WHERE ConnectorId=? AND Source=?', (cid, source)) if r['Path'] not in keep] diff --git a/tests/test_knowledge_semantic.py b/tests/test_knowledge_semantic.py new file mode 100644 index 000000000..8fcea3fa4 --- /dev/null +++ b/tests/test_knowledge_semantic.py @@ -0,0 +1,183 @@ +"""Optional semantic search for the knowledge base (kbembed.py). Off by default and then invisible; +on, a tiny fake embedder proves "different words, same meaning". No network: the embedder is a fake +and the one HTTP test mocks requests.post.""" +import json, os, time, unittest +from pathlib import Path +from unittest import mock +from taskuary import kbembed, knowledge as kb +from taskuary.store import MemoryStore, DEFAULT_SETTINGS + +# a three-word "language model": every word sits on one of these meaning axes, so two passages that share a +# MEANING but no word are near each other - which is the whole point, and which keywords cannot do +AXES = {'leave': 0, 'absence': 0, 'time': 0, 'off': 0, 'vacation': 0, 'holiday': 0, + 'parking': 1, 'permit': 1, 'car': 1, 'refund': 2, 'payment': 2, 'money': 2} + +def fake_vec(text): + v = [0.0, 0.0, 0.0, 0.01] + for w in text.lower().replace('.', ' ').replace('?', ' ').split(): + if w in AXES: v[AXES[w]] += 1.0 + return v + +class FakeEmbedder: + def __init__(self, model='fake-1'): self.model, self.calls, self.texts = model, 0, [] + def __call__(self, texts): + self.calls += 1; self.texts += list(texts) + return [kbembed.pack(fake_vec(t)) for t in texts] + +def _put(s, name, *chunks, cid=1): + return s.kb_put({'ConnectorId': cid, 'Source': 'folder:/docs', 'Path': name, 'Name': name, 'Modified': 'm', 'Size': 1, 'Chars': 1}, list(chunks)) + +def _on(s, **kw): + s.set_setting('kb_semantic', '1', 't') + for k, v in kw.items(): s.set_setting(k, v, 't') + + +class OffByDefaultTests(unittest.TestCase): + def test_the_switch_ships_off_and_off_is_exactly_todays_search(self): + self.assertEqual(DEFAULT_SETTINGS['kb_semantic'], '0') + s = MemoryStore() + _put(s, 'leave.md', 'Leave of absence is granted for up to twelve weeks.') + _put(s, 'parking.md', 'Parking permits are issued at the front desk.') + today = lambda q: [{k: h[k] for k in ('name', 'path', 'source', 'modified', 'seq', 'score', 'snippet')} + for h in kb.search(s, q)] + # what the keyword path alone returns - the thing the setting must not change + direct = lambda q: [{**h, 'snippet': kb.passage(h.pop('text', '') or '', h.get('snippet') or '')} + for h in s.kb_search(kb._query(q), 8)] + for q in ('leave of absence policy', 'parking permits', 'time off', 'zebra'): + self.assertEqual(today(q), [{k: h[k] for k in ('name', 'path', 'source', 'modified', 'seq', 'score', 'snippet')} for h in direct(q)], q) + # ...and no embedder is built, no vector written, even when one is available + with mock.patch.object(kbembed, 'make', side_effect=AssertionError('the embedder must not be built while the switch is off')): + kb.search(s, 'time off'); kb.block(s, 'time off') + self.assertEqual(kb.reindex(s)['errors'], []) + self.assertEqual(s.kb_vectors('fake-1'), []) + + def test_the_knobs_are_in_the_schema_with_a_group(self): + from taskuary import settings_schema + k = settings_schema.knobs() + for key in ('kb_semantic', 'kb_embed_url', 'kb_embed_model', 'kb_semantic_min'): + self.assertIn(key, k); self.assertIn(k[key]['group'], settings_schema.GROUPS); self.assertIn(key, DEFAULT_SETTINGS) + self.assertEqual(k['kb_semantic']['type'], 'switch') + + +class DifferentWordsSameMeaningTests(unittest.TestCase): + def setUp(self): + self.s = MemoryStore(); self.emb = FakeEmbedder() + _put(self.s, 'leave.md', 'Leave of absence is granted for up to twelve weeks.') + _put(self.s, 'parking.md', 'Parking permits are issued at the front desk.') + _put(self.s, 'refunds.md', 'Refund payment arrives within thirty days.') + + def test_time_off_finds_the_leave_policy_only_with_it_on(self): + self.assertEqual(kb.search(self.s, 'time off'), []) # keywords: no shared word + _on(self.s) + with mock.patch.object(kbembed, 'make', return_value=self.emb): + self.assertEqual(kbembed.backfill(self.s, self.emb), 3) + hits = kb.search(self.s, 'time off') + self.assertEqual([h['name'] for h in hits], ['leave.md']) # unrelated passages fall under the floor + self.assertEqual(set(hits[0]) - {'seq'}, {'doc_id', 'name', 'path', 'source', 'modified', 'score', 'snippet', 'text'}) # the shape kb_search gives today + self.assertIn('twelve weeks', hits[0]['snippet']) + self.assertIn('FROM THE KNOWLEDGE BASE', kb.block(self.s, 'How much time off do I get?')) + self.assertEqual(kb.block(self.s, 'zebra giraffe'), '') # nothing near: still no block + + def test_a_document_found_both_ways_ranks_first_and_keeps_its_keyword_snippet(self): + _on(self.s) + _put(self.s, 'holiday.md', 'Holiday time is booked through the portal.') + with mock.patch.object(kbembed, 'make', return_value=self.emb): + kbembed.backfill(self.s, self.emb) + hits = kb.search(self.s, 'leave of absence') + self.assertEqual(hits[0]['name'], 'leave.md') + self.assertIn('absence', hits[0]['snippet']) + self.assertIn('holiday.md', [h['name'] for h in hits]) # meaning-only neighbour joins in + + def test_search_falls_back_to_keywords_when_the_embedder_fails_or_nothing_is_embedded(self): + _on(self.s) + with mock.patch.object(kbembed, 'make', side_effect=RuntimeError('connection refused')): + self.assertEqual([h['name'] for h in kb.search(self.s, 'parking permits')], ['parking.md']) + with mock.patch.object(kbembed, 'make', return_value=self.emb): # on, but never indexed with vectors + self.assertEqual([h['name'] for h in kb.search(self.s, 'parking permits')], ['parking.md']) + self.assertEqual(kb.search(self.s, 'time off'), []) + + +class IndexingTests(unittest.TestCase): + def setUp(self): + self.s = MemoryStore() + self.tmp = Path(os.environ['TASKUARY_HOME']) / f'kbsem-{time.time_ns()}'; self.tmp.mkdir(parents=True) + (self.tmp / 'leave.md').write_text('Leave of absence is granted for up to twelve weeks.', encoding='utf-8') + (self.tmp / 'parking.md').write_text('Parking permits come from the front desk.', encoding='utf-8') + cid = self.s.get_connector_by_type('knowledge')['ConnectorId'] + self.s.save_connector({'ConnectorId': cid, 'Active': 1, 'ConfigJson': json.dumps({'folders': str(self.tmp)})}, 't') + self.cid, self.emb = cid, FakeEmbedder() + + def test_embeddings_are_built_during_reindex_and_only_for_what_changed(self): + _on(self.s) + with mock.patch.object(kbembed, 'make', return_value=self.emb): + r = kb.reindex(self.s, self.cid) + self.assertEqual((r['indexed'], r['errors']), (2, [])); self.assertEqual(len(self.s.kb_vectors('fake-1')), 2) + self.assertEqual(self.emb.calls, 2) # one call per new document + r2 = kb.reindex(self.s, self.cid); self.assertEqual((r2['unchanged'], self.emb.calls), (2, 2)) # nothing re-embedded + (self.tmp / 'parking.md').write_text('Parking permits are renewed yearly at the desk.', encoding='utf-8') + kb.reindex(self.s, self.cid) + self.assertEqual((self.emb.calls, len(self.s.kb_vectors('fake-1'))), (3, 2)) # the changed file only; the old vector is gone + (self.tmp / 'parking.md').unlink(); kb.reindex(self.s, self.cid) + self.assertEqual(len(self.s.kb_vectors('fake-1')), 1) # pruned documents take their vectors along + self.assertEqual([h['name'] for h in kb.search(self.s, 'time off')], []) # off the mock the embedder is unreachable: keywords only + + def test_switching_it_on_after_indexing_is_filled_in_by_the_next_reindex(self): + kb.reindex(self.s, self.cid) # indexed with it off + self.assertEqual(self.s.kb_vectors('fake-1'), []) + _on(self.s) + with mock.patch.object(kbembed, 'make', return_value=self.emb): + r = kb.reindex(self.s, self.cid) + self.assertEqual((r['indexed'], r['unchanged'], r['embedded']), (0, 2, 2)) + self.assertEqual([h['name'] for h in kb.search(self.s, 'time off')], ['leave.md']) + self.emb.model = 'fake-2' # another model: the old vectors are ignored, then replaced + with mock.patch.object(kbembed, 'make', return_value=self.emb): + self.assertEqual(kb.search(self.s, 'time off'), []) + self.assertEqual(kb.reindex(self.s, self.cid)['embedded'], 2) + self.assertEqual([h['name'] for h in kb.search(self.s, 'time off')], ['leave.md']) + + def test_a_dead_embedder_does_not_stop_indexing(self): + _on(self.s) + dead = mock.Mock(side_effect=ConnectionError('refused'), model='fake-1') + with mock.patch.object(kbembed, 'make', return_value=dead): + r = kb.reindex(self.s, self.cid) + self.assertEqual((r['indexed'], r['docs']), (2, 2)); self.assertEqual(dead.call_count, 1) # not hammered once per file + self.assertTrue(any('semantic search' in e for e in r['errors'])) + self.assertEqual([h['name'] for h in kb.search(self.s, 'parking permits')], ['parking.md']) + + +class PartsTests(unittest.TestCase): + def test_rank_fusion_rewards_agreement(self): + order = [k for k, _ in kbembed.fuse(['a', 'b', 'c'], ['b', 'd'])] + self.assertEqual(order[0], 'b'); self.assertEqual(set(order), {'a', 'b', 'c', 'd'}) + self.assertEqual(kbembed.fuse([], []), []) + + def test_nearest_ranks_by_cosine_and_applies_the_floor_in_both_scan_paths(self): + rows = [(1, 3, kbembed.pack([1, 0, 0])), (2, 3, kbembed.pack([0.8, 0.6, 0])), (3, 3, kbembed.pack([0, 0, 1])), (4, 2, kbembed.pack([1, 0]))] + for np_mod in (kbembed._np, None): # numpy when installed, pure Python always + with mock.patch.object(kbembed, '_np', np_mod): + got = kbembed.nearest([1, 0, 0], rows, 5, 0.5) + self.assertEqual([c for c, _ in got], [1, 2]); self.assertAlmostEqual(got[1][1], 0.8, places=5) # wrong dimension skipped + self.assertEqual(kbembed.nearest([1, 0, 0], [], 3), []) + with self.assertRaises(ValueError): kbembed.pack([0, 0, 0]) + + def test_the_http_embedder_speaks_the_openai_surface(self): + s = MemoryStore(); _on(s, kb_embed_url='http://127.0.0.1:9/', kb_embed_model='tiny') + class R: + status_code = 200; text = '' + def json(self): return {'data': [{'index': 1, 'embedding': [0, 1]}, {'index': 0, 'embedding': [1, 0]}]} + with mock.patch('requests.post', return_value=R()) as post: + emb = kbembed.make(s) + vs = emb(['one', 'two']) + self.assertEqual(emb.model, 'tiny'); self.assertEqual(post.call_args.args[0], 'http://127.0.0.1:9/v1/embeddings') + self.assertEqual(post.call_args.kwargs['json'], {'model': 'tiny', 'input': ['one', 'two']}) + self.assertEqual([list(kbembed.unpack(v)) for v in vs], [[1.0, 0.0], [0.0, 1.0]]) # reordered by index + with mock.patch('requests.post', return_value=R()) as post2: + emb(['the key is AKIAABCDEFGHIJKLMNOP ok', 'fine']) + sent = post2.call_args.kwargs['json']['input'] + self.assertNotIn('AKIAABCDEFGHIJKLMNOP', sent[0]); self.assertIn('[redacted:', sent[0]); self.assertEqual(sent[1], 'fine') # scrubbed on the way out + bad = R(); bad.status_code = 500; bad.text = 'boom' + with mock.patch('requests.post', return_value=bad), self.assertRaises(RuntimeError): emb(['x']) + self.assertIsNone(kbembed.make(MemoryStore())) # off: no embedder + + +if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/tools/bench_kb_semantic.py b/tools/bench_kb_semantic.py new file mode 100644 index 000000000..4b64f9ae2 --- /dev/null +++ b/tools/bench_kb_semantic.py @@ -0,0 +1,58 @@ +"""Time the semantic knowledge search on a library of a few thousand passages. + + python tools/bench_kb_semantic.py [chunks=5000] [dim=384] + +Measures what THIS code adds: writing vectors in the same transaction as the passages, loading them, the +brute-force scan (numpy and pure Python), and a whole hybrid `knowledge.search`. The embedder is a seeded +random-vector fake, so the model's own inference time is NOT in these numbers - that is whatever your +Ollama takes per passage (measure it with `time ollama run`/a request to /v1/embeddings). Invented text only. +""" +import os, random, sys, tempfile, time +os.environ.setdefault('TASKUARY_HOME', tempfile.mkdtemp(prefix='taskuary_bench_')) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +from unittest import mock +from taskuary import kbembed, knowledge as kb +from taskuary.store import MemoryStore + +N = int(sys.argv[1]) if len(sys.argv) > 1 else 5000 +DIM = int(sys.argv[2]) if len(sys.argv) > 2 else 384 +rnd = random.Random(7) +WORDS = [f'word{i}' for i in range(2000)] + +def passage_text(): return ' '.join(rnd.choice(WORDS) for _ in range(150)) # ~1200 chars, like a real chunk + +class Fake: + model = 'bench' + def __call__(self, texts): return [kbembed.pack([rnd.gauss(0, 1) for _ in range(DIM)]) for _ in texts] + +def ms(t0): return f'{(time.perf_counter() - t0) * 1000:8.1f} ms' + +s = MemoryStore(); emb = Fake() +s.set_setting('kb_semantic', '1', 'bench'); s.set_setting('kb_semantic_min', '0', 'bench') +docs = N // 5 # five passages per document +texts = [[passage_text() for _ in range(5)] for _ in range(docs)] +vecs = [emb(t) for t in texts] # the fake's own cost stays out of the timings + +t0 = time.perf_counter() +for i, parts in enumerate(texts): s.kb_put({'ConnectorId': 1, 'Source': 'bench', 'Path': f'd{i}', 'Name': f'd{i}', 'Modified': 'm', 'Size': 1, 'Chars': 1}, parts) +print(f'{N} passages, keywords only index {ms(t0)}') +s2 = MemoryStore() +t0 = time.perf_counter() +for i, parts in enumerate(texts): s2.kb_put({'ConnectorId': 1, 'Source': 'bench', 'Path': f'd{i}', 'Name': f'd{i}', 'Modified': 'm', 'Size': 1, 'Chars': 1}, parts, vecs[i], 'bench') +print(f'{N} passages, + {DIM}-d vectors index {ms(t0)} (storage write only)') +s2.set_setting('kb_semantic', '1', 'bench'); s2.set_setting('kb_semantic_min', '0', 'bench') +print(f'vector bytes in the database: {N * (DIM * 4 + 20) / 1e6:.1f} MB') + +q = emb(['query'])[0] +t0 = time.perf_counter(); rows = s2.kb_vectors('bench'); print(f'load all vectors from SQLite {ms(t0)}') +for label, mod in (('numpy', kbembed._np), ('pure Python', None)): + if label == 'numpy' and mod is None: print('numpy not installed - skipping that path'); continue + with mock.patch.object(kbembed, '_np', mod): + best = min((lambda t: (kbembed.nearest(kbembed.unpack(q), rows, 32), time.perf_counter() - t)[1])(time.perf_counter()) for _ in range(5)) + print(f'brute-force scan, {label:11s} {best * 1000:8.1f} ms (best of 5)') +text = ' '.join(rnd.choice(WORDS) for _ in range(12)) +for label, setting in (('keyword search (today)', '0'), ('hybrid search', '1')): + s2.set_setting('kb_semantic', setting, 'bench') + with mock.patch.object(kbembed, 'make', return_value=emb): + best = min((lambda t: (kb.search(s2, text), time.perf_counter() - t)[1])(time.perf_counter()) for _ in range(5)) + print(f'{label:34s} {best * 1000:8.1f} ms (best of 5, one query embedding from the fake = ~0)') \ No newline at end of file