threadkeeper 0.6.2__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {threadkeeper-0.6.2/threadkeeper.egg-info → threadkeeper-0.8.0}/PKG-INFO +39 -4
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/README.md +32 -2
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/pyproject.toml +17 -2
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_curator.py +82 -0
- threadkeeper-0.8.0/tests/test_evolve_daemon.py +187 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_nudges.py +13 -0
- threadkeeper-0.8.0/tests/test_onnx_embeddings.py +133 -0
- threadkeeper-0.8.0/tests/test_panel.py +188 -0
- threadkeeper-0.8.0/tests/test_probe_daemon.py +211 -0
- threadkeeper-0.8.0/tests/test_search_fts_punctuation.py +67 -0
- threadkeeper-0.8.0/tests/test_skill_passive_tier.py +117 -0
- threadkeeper-0.8.0/tests/test_spawn_reap.py +80 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/brief.py +21 -7
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/config.py +96 -7
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/curator.py +61 -1
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/db.py +18 -2
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/embeddings.py +68 -19
- threadkeeper-0.8.0/threadkeeper/evolve_daemon.py +233 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/helpers.py +21 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/identity.py +10 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/ingest.py +75 -42
- threadkeeper-0.8.0/threadkeeper/migrate_embeddings.py +146 -0
- threadkeeper-0.8.0/threadkeeper/probe_daemon.py +276 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/server.py +1 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/shadow_review.py +2 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/consolidate.py +4 -4
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/dialectic.py +20 -2
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/extract.py +3 -3
- threadkeeper-0.8.0/threadkeeper/tools/panel.py +195 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/pickup.py +4 -3
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/session.py +4 -4
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/spawn.py +59 -5
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/threads.py +54 -9
- {threadkeeper-0.6.2 → threadkeeper-0.8.0/threadkeeper.egg-info}/PKG-INFO +39 -4
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/SOURCES.txt +11 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/entry_points.txt +1 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/requires.txt +6 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/LICENSE +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/setup.cfg +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_adapters.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_identity.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_lessons.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_memory_guard.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_process_health.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_shadow_review.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_hint.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skills.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_slim.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_threads.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/_setup.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/extract_daemon.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/memory_guard.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/nudges.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/review_prompts.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/memory_guard.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/skills.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threadkeeper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
|
|
5
5
|
Author: thread-keeper contributors
|
|
6
6
|
License: MIT
|
|
@@ -24,12 +24,17 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: mcp>=1.0.0
|
|
26
26
|
Provides-Extra: semantic
|
|
27
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: fastembed>=0.3; extra == "semantic"
|
|
28
28
|
Requires-Dist: numpy>=1.24.0; extra == "semantic"
|
|
29
29
|
Requires-Dist: sqlite-vec>=0.1.9; extra == "semantic"
|
|
30
|
+
Provides-Extra: semantic-st
|
|
31
|
+
Requires-Dist: sentence-transformers>=2.2.0; extra == "semantic-st"
|
|
32
|
+
Requires-Dist: numpy>=1.24.0; extra == "semantic-st"
|
|
33
|
+
Requires-Dist: sqlite-vec>=0.1.9; extra == "semantic-st"
|
|
30
34
|
Provides-Extra: dev
|
|
31
35
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
32
36
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-forked>=1.6; extra == "dev"
|
|
33
38
|
Dynamic: license-file
|
|
34
39
|
|
|
35
40
|
# thread-keeper
|
|
@@ -189,7 +194,7 @@ autonomous learning daemons cannot recursively start inside review forks.
|
|
|
189
194
|
A daemon measures combined child RSS every 10 s; admission control
|
|
190
195
|
refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
|
|
191
196
|
(3 GB default). Slim children that need semantic search delegate to the
|
|
192
|
-
parent via `search_via_parent` — no per-child copy of
|
|
197
|
+
parent via `search_via_parent` — no per-child copy of the embedding model.
|
|
193
198
|
|
|
194
199
|
### Learning loops
|
|
195
200
|
|
|
@@ -435,7 +440,9 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
435
440
|
| `THREADKEEPER_MEMORY_GUARD_RETIRE_LIVE` | "" (off) | allow retiring parent-alive MCP servers; off protects live clients |
|
|
436
441
|
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
437
442
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
438
|
-
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable
|
|
443
|
+
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable the embedding model (FTS5 + delegate only) |
|
|
444
|
+
| `THREADKEEPER_EMBED_BACKEND` | `onnx` | embedding runtime: `onnx` (fastembed, no PyTorch) or `sentence-transformers` (legacy fallback) |
|
|
445
|
+
| `THREADKEEPER_EMBED_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | 384-dim cross-lingual embedding model |
|
|
439
446
|
| `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
|
|
440
447
|
| `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
|
|
441
448
|
|
|
@@ -525,6 +532,34 @@ Hooks and small runtime artifacts: `~/.threadkeeper/hooks/`.
|
|
|
525
532
|
|
|
526
533
|
---
|
|
527
534
|
|
|
535
|
+
## Embeddings
|
|
536
|
+
|
|
537
|
+
Semantic search runs `paraphrase-multilingual-MiniLM-L12-v2` (384-dim,
|
|
538
|
+
RU+EN+50 langs). The default backend is **fastembed / ONNX Runtime** — no
|
|
539
|
+
PyTorch. A model-loaded process sits at ~700 MB physical footprint
|
|
540
|
+
(~850 MB RSS), down from ~1.8 GB on the PyTorch backend.
|
|
541
|
+
|
|
542
|
+
A **sentence-transformers** (PyTorch) backend is kept as an opt-in fallback.
|
|
543
|
+
It is heavier (~1.8 GB RSS) and produces vectors that are *not numerically
|
|
544
|
+
identical* to the ONNX backend's, so switching backends warrants a recompute:
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
# Install the fallback runtime and switch to it:
|
|
548
|
+
pip install -e '.[semantic-st]'
|
|
549
|
+
export THREADKEEPER_EMBED_BACKEND=sentence-transformers
|
|
550
|
+
|
|
551
|
+
# After any backend switch, homogenize the stored corpus so queries and
|
|
552
|
+
# stored vectors live in the same space:
|
|
553
|
+
tk-migrate-embeddings --all # or --notes-only / --dialog-only
|
|
554
|
+
tk-migrate-embeddings --dry-run # report stale counts only
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The migration is batched, resumable, and idempotent (a second run finds
|
|
558
|
+
nothing stale). Both backends emit 384-dim vectors, so the `vec0` schema is
|
|
559
|
+
unchanged.
|
|
560
|
+
|
|
561
|
+
---
|
|
562
|
+
|
|
528
563
|
## Verifying ingest across CLIs
|
|
529
564
|
|
|
530
565
|
```bash
|
|
@@ -155,7 +155,7 @@ autonomous learning daemons cannot recursively start inside review forks.
|
|
|
155
155
|
A daemon measures combined child RSS every 10 s; admission control
|
|
156
156
|
refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
|
|
157
157
|
(3 GB default). Slim children that need semantic search delegate to the
|
|
158
|
-
parent via `search_via_parent` — no per-child copy of
|
|
158
|
+
parent via `search_via_parent` — no per-child copy of the embedding model.
|
|
159
159
|
|
|
160
160
|
### Learning loops
|
|
161
161
|
|
|
@@ -401,7 +401,9 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
|
|
|
401
401
|
| `THREADKEEPER_MEMORY_GUARD_RETIRE_LIVE` | "" (off) | allow retiring parent-alive MCP servers; off protects live clients |
|
|
402
402
|
| `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
|
|
403
403
|
| `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
|
|
404
|
-
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable
|
|
404
|
+
| `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable the embedding model (FTS5 + delegate only) |
|
|
405
|
+
| `THREADKEEPER_EMBED_BACKEND` | `onnx` | embedding runtime: `onnx` (fastembed, no PyTorch) or `sentence-transformers` (legacy fallback) |
|
|
406
|
+
| `THREADKEEPER_EMBED_MODEL` | `paraphrase-multilingual-MiniLM-L12-v2` | 384-dim cross-lingual embedding model |
|
|
405
407
|
| `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
|
|
406
408
|
| `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
|
|
407
409
|
|
|
@@ -491,6 +493,34 @@ Hooks and small runtime artifacts: `~/.threadkeeper/hooks/`.
|
|
|
491
493
|
|
|
492
494
|
---
|
|
493
495
|
|
|
496
|
+
## Embeddings
|
|
497
|
+
|
|
498
|
+
Semantic search runs `paraphrase-multilingual-MiniLM-L12-v2` (384-dim,
|
|
499
|
+
RU+EN+50 langs). The default backend is **fastembed / ONNX Runtime** — no
|
|
500
|
+
PyTorch. A model-loaded process sits at ~700 MB physical footprint
|
|
501
|
+
(~850 MB RSS), down from ~1.8 GB on the PyTorch backend.
|
|
502
|
+
|
|
503
|
+
A **sentence-transformers** (PyTorch) backend is kept as an opt-in fallback.
|
|
504
|
+
It is heavier (~1.8 GB RSS) and produces vectors that are *not numerically
|
|
505
|
+
identical* to the ONNX backend's, so switching backends warrants a recompute:
|
|
506
|
+
|
|
507
|
+
```bash
|
|
508
|
+
# Install the fallback runtime and switch to it:
|
|
509
|
+
pip install -e '.[semantic-st]'
|
|
510
|
+
export THREADKEEPER_EMBED_BACKEND=sentence-transformers
|
|
511
|
+
|
|
512
|
+
# After any backend switch, homogenize the stored corpus so queries and
|
|
513
|
+
# stored vectors live in the same space:
|
|
514
|
+
tk-migrate-embeddings --all # or --notes-only / --dialog-only
|
|
515
|
+
tk-migrate-embeddings --dry-run # report stale counts only
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
The migration is batched, resumable, and idempotent (a second run finds
|
|
519
|
+
nothing stale). Both backends emit 384-dim vectors, so the `vec0` schema is
|
|
520
|
+
unchanged.
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
494
524
|
## Verifying ingest across CLIs
|
|
495
525
|
|
|
496
526
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "threadkeeper"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
authors = [{ name = "thread-keeper contributors" }]
|
|
@@ -32,15 +32,27 @@ dependencies = [
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
# Semantic cross-language search + sub-linear vector index. Recommended
|
|
34
34
|
# for any real use — without it, dialog_search falls back to FTS5 only.
|
|
35
|
+
# Default backend is fastembed/ONNX Runtime: no PyTorch, ~700MB footprint.
|
|
35
36
|
semantic = [
|
|
37
|
+
"fastembed>=0.3",
|
|
38
|
+
"numpy>=1.24.0",
|
|
39
|
+
"sqlite-vec>=0.1.9",
|
|
40
|
+
]
|
|
41
|
+
# Legacy PyTorch backend, kept as an opt-in fallback. Install this AND set
|
|
42
|
+
# THREADKEEPER_EMBED_BACKEND=sentence-transformers to use it. ~1.8GB RSS.
|
|
43
|
+
semantic-st = [
|
|
36
44
|
"sentence-transformers>=2.2.0",
|
|
37
45
|
"numpy>=1.24.0",
|
|
38
46
|
"sqlite-vec>=0.1.9",
|
|
39
47
|
]
|
|
40
|
-
# Test runner + coverage.
|
|
48
|
+
# Test runner + coverage. pytest-forked isolates each test in its own
|
|
49
|
+
# process: the per-test package re-import (tests/conftest.py) accumulates
|
|
50
|
+
# native ONNX/tokenizer thread pools that can deadlock sqlite finalize in a
|
|
51
|
+
# single long-lived process, so CI runs `pytest --forked`.
|
|
41
52
|
dev = [
|
|
42
53
|
"pytest>=8.0",
|
|
43
54
|
"pytest-cov>=5.0",
|
|
55
|
+
"pytest-forked>=1.6",
|
|
44
56
|
]
|
|
45
57
|
|
|
46
58
|
[project.urls]
|
|
@@ -54,6 +66,9 @@ Changelog = "https://github.com/po4erk91/thread-keeper/releases"
|
|
|
54
66
|
# After `pip install threadkeeper`, the user gets `thread-keeper-setup`
|
|
55
67
|
# directly on PATH. Equivalent to `python -m threadkeeper._setup`.
|
|
56
68
|
thread-keeper-setup = "threadkeeper._setup:main"
|
|
69
|
+
# Recompute stored embeddings with the active backend (e.g. after switching to
|
|
70
|
+
# the ONNX default). Equivalent to `python -m threadkeeper.migrate_embeddings`.
|
|
71
|
+
tk-migrate-embeddings = "threadkeeper.migrate_embeddings:main"
|
|
57
72
|
|
|
58
73
|
[tool.setuptools.packages.find]
|
|
59
74
|
include = ["threadkeeper*"]
|
|
@@ -321,3 +321,85 @@ def test_advisory_mode_default_excludes_destructive_tools(
|
|
|
321
321
|
assert "lesson_append" not in allowed
|
|
322
322
|
assert "ADVISORY MODE" in kw["prompt"]
|
|
323
323
|
assert "DESTRUCTIVE MODE ENABLED" not in kw["prompt"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
327
|
+
# Concepts review (F1) — curator also audits the concepts store
|
|
328
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
def _add_concept(conn, cid, desc, confidence="medium",
|
|
331
|
+
registered_at=None, last_evidence_at=None):
|
|
332
|
+
now = int(time.time())
|
|
333
|
+
conn.execute(
|
|
334
|
+
"INSERT INTO concepts (id, description, confidence, registered_at, "
|
|
335
|
+
"last_evidence_at) VALUES (?,?,?,?,?)",
|
|
336
|
+
(cid, desc, confidence, registered_at or now, last_evidence_at),
|
|
337
|
+
)
|
|
338
|
+
conn.commit()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_collect_concepts_empty(tmp_path, monkeypatch):
|
|
342
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
343
|
+
conn = pkg["db"].get_db()
|
|
344
|
+
text, n = pkg["curator"]._collect_concepts(conn)
|
|
345
|
+
assert n == 0
|
|
346
|
+
assert text == ""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_collect_concepts_lists_with_age(tmp_path, monkeypatch):
|
|
350
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
351
|
+
conn = pkg["db"].get_db()
|
|
352
|
+
now = int(time.time())
|
|
353
|
+
_add_concept(conn, "Cfresh", "fresh high-conf idea",
|
|
354
|
+
confidence="high", last_evidence_at=now - 86400) # 1d
|
|
355
|
+
_add_concept(conn, "Cstale", "stale low-conf idea",
|
|
356
|
+
confidence="low",
|
|
357
|
+
registered_at=now - 40 * 86400,
|
|
358
|
+
last_evidence_at=None) # never corroborated, 40d old
|
|
359
|
+
text, n = pkg["curator"]._collect_concepts(conn)
|
|
360
|
+
assert n == 2
|
|
361
|
+
assert "Cfresh" in text and "Cstale" in text
|
|
362
|
+
assert "CONCEPTS (n=2)" in text
|
|
363
|
+
# stale concept (no last_evidence, registered 40d ago) shows ~40d age
|
|
364
|
+
assert "40d_ago" in text
|
|
365
|
+
# oldest-first ordering: stale concept appears before fresh one
|
|
366
|
+
assert text.index("Cstale") < text.index("Cfresh")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_run_curator_pass_includes_concepts_in_inventory(
|
|
370
|
+
tmp_path, monkeypatch,
|
|
371
|
+
):
|
|
372
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="2")
|
|
373
|
+
pkg["lessons"].append_lesson(title="a", body="b1", source="shadow")
|
|
374
|
+
pkg["lessons"].append_lesson(title="b", body="b2", source="shadow")
|
|
375
|
+
conn = pkg["db"].get_db()
|
|
376
|
+
_add_concept(conn, "Cabc", "asymmetric in-band reactivity",
|
|
377
|
+
confidence="high")
|
|
378
|
+
|
|
379
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
380
|
+
captured: list[dict] = []
|
|
381
|
+
monkeypatch.setattr(
|
|
382
|
+
spawn_mod, "spawn",
|
|
383
|
+
lambda **kw: captured.append(kw) or "spawn task_id=fake pid=0",
|
|
384
|
+
)
|
|
385
|
+
pkg["curator"].run_curator_pass(force=True)
|
|
386
|
+
prompt = captured[0]["prompt"]
|
|
387
|
+
assert "CONCEPTS (n=1)" in prompt
|
|
388
|
+
assert "Cabc" in prompt
|
|
389
|
+
assert "asymmetric in-band reactivity" in prompt
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_concepts_alone_do_not_trigger_pass(tmp_path, monkeypatch):
|
|
393
|
+
"""Concepts enrich the review but don't lower the lesson threshold —
|
|
394
|
+
a pass still requires CURATOR_MIN_LESSONS lessons."""
|
|
395
|
+
pkg = _bootstrap(tmp_path, monkeypatch, min_lessons="3")
|
|
396
|
+
conn = pkg["db"].get_db()
|
|
397
|
+
_add_concept(conn, "Conly", "a lone concept", confidence="high")
|
|
398
|
+
|
|
399
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
400
|
+
called = []
|
|
401
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
402
|
+
lambda **kw: called.append(kw) or "x")
|
|
403
|
+
out = pkg["curator"].run_curator_pass(force=True)
|
|
404
|
+
assert out.startswith("below_threshold")
|
|
405
|
+
assert called == []
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Evolve reviewer daemon — autonomous triage of the format-evolution queue.
|
|
2
|
+
|
|
3
|
+
The daemon never APPLIES a suggestion (that edits format/code). It spawns a
|
|
4
|
+
child that calls evolve_decide(promote|dismiss) to keep the queue honest.
|
|
5
|
+
Tests exercise the pure logic + dispatch with spawn monkeypatched; no real
|
|
6
|
+
child is launched.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_FAKE_CID = "dddd4444-5555-6666-7777-888899990000"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _bootstrap(tmp_path, monkeypatch, interval="0", review_min="2"):
|
|
19
|
+
env = {
|
|
20
|
+
"THREADKEEPER_DB": str(tmp_path / "db.sqlite"),
|
|
21
|
+
"CLAUDE_PROJECTS_DIR": str(tmp_path / "fake_claude_projects"),
|
|
22
|
+
"THREADKEEPER_INGEST_INTERVAL_S": "0",
|
|
23
|
+
"THREADKEEPER_INGEST_CAP": "0",
|
|
24
|
+
"THREADKEEPER_SKILL_WATCH_INTERVAL_S": "0",
|
|
25
|
+
"THREADKEEPER_SPAWN_BUDGET_POLL_S": "0",
|
|
26
|
+
"THREADKEEPER_SEARCH_PROXY_POLL_S": "0",
|
|
27
|
+
"THREADKEEPER_MEMORY_GUARD_POLL_S": "0",
|
|
28
|
+
"THREADKEEPER_SHADOW_REVIEW_INTERVAL_S": "0",
|
|
29
|
+
"THREADKEEPER_CURATOR_INTERVAL_S": "0",
|
|
30
|
+
"THREADKEEPER_EXTRACT_INTERVAL_S": "0",
|
|
31
|
+
"THREADKEEPER_CANDIDATE_REVIEW_INTERVAL_S": "0",
|
|
32
|
+
"THREADKEEPER_PROBE_INTERVAL_S": "0",
|
|
33
|
+
"THREADKEEPER_EVOLVE_REVIEW_INTERVAL_S": interval,
|
|
34
|
+
"THREADKEEPER_EVOLVE_REVIEW_MIN": review_min,
|
|
35
|
+
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
36
|
+
"THREADKEEPER_CLIENT": "pytest",
|
|
37
|
+
"THREADKEEPER_FORCE_CID": _FAKE_CID,
|
|
38
|
+
"THREADKEEPER_NO_EMBEDDINGS": "1",
|
|
39
|
+
}
|
|
40
|
+
for k, v in env.items():
|
|
41
|
+
monkeypatch.setenv(k, v)
|
|
42
|
+
Path(env["CLAUDE_PROJECTS_DIR"]).mkdir(parents=True, exist_ok=True)
|
|
43
|
+
for name in [m for m in list(sys.modules) if m.startswith("threadkeeper")]:
|
|
44
|
+
del sys.modules[name]
|
|
45
|
+
import threadkeeper.server # noqa: F401
|
|
46
|
+
from threadkeeper import _mcp, db, evolve_daemon, identity
|
|
47
|
+
return {"mcp": _mcp.mcp, "db": db, "ed": evolve_daemon, "identity": identity}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tool(pkg, name):
|
|
51
|
+
return pkg["mcp"]._tool_manager._tools[name].fn
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _add_evolve(conn, suggestion, rationale=None, applied=0, status="pending"):
|
|
55
|
+
conn.execute(
|
|
56
|
+
"INSERT INTO evolve (suggestion, rationale, applied, status, created_at) "
|
|
57
|
+
"VALUES (?,?,?,?,?)",
|
|
58
|
+
(suggestion, rationale, applied, status, int(time.time())),
|
|
59
|
+
)
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── pending selection ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def test_pending_excludes_applied_and_decided(tmp_path, monkeypatch):
|
|
66
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
67
|
+
conn = pkg["db"].get_db()
|
|
68
|
+
_add_evolve(conn, "pending one")
|
|
69
|
+
_add_evolve(conn, "already applied", applied=1)
|
|
70
|
+
_add_evolve(conn, "already dismissed", status="dismissed")
|
|
71
|
+
_add_evolve(conn, "already promoted", status="promoted")
|
|
72
|
+
pend = pkg["ed"]._pending(conn)
|
|
73
|
+
sugg = [r["suggestion"] for r in pend]
|
|
74
|
+
assert sugg == ["pending one"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── evolve_decide tool ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def test_evolve_decide_promote(tmp_path, monkeypatch):
|
|
80
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
81
|
+
conn = pkg["db"].get_db()
|
|
82
|
+
_add_evolve(conn, "make briefs shorter")
|
|
83
|
+
eid = conn.execute("SELECT id FROM evolve").fetchone()["id"]
|
|
84
|
+
out = _tool(pkg, "evolve_decide")(evolve_id=eid, decision="promote",
|
|
85
|
+
reason="clear win")
|
|
86
|
+
assert "status=promoted" in out
|
|
87
|
+
row = conn.execute("SELECT status, review_reason, reviewed_at FROM evolve "
|
|
88
|
+
"WHERE id=?", (eid,)).fetchone()
|
|
89
|
+
assert row["status"] == "promoted"
|
|
90
|
+
assert row["review_reason"] == "clear win"
|
|
91
|
+
assert row["reviewed_at"] is not None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_evolve_decide_dismiss_and_bad_inputs(tmp_path, monkeypatch):
|
|
95
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
96
|
+
conn = pkg["db"].get_db()
|
|
97
|
+
_add_evolve(conn, "dup suggestion")
|
|
98
|
+
eid = conn.execute("SELECT id FROM evolve").fetchone()["id"]
|
|
99
|
+
assert "status=dismissed" in _tool(pkg, "evolve_decide")(
|
|
100
|
+
evolve_id=eid, decision="dismiss", reason="duplicate of #1")
|
|
101
|
+
assert _tool(pkg, "evolve_decide")(
|
|
102
|
+
evolve_id=eid, decision="banana").startswith("ERR bad_decision")
|
|
103
|
+
assert _tool(pkg, "evolve_decide")(
|
|
104
|
+
evolve_id=9999, decision="promote").startswith("ERR evolve_not_found")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── run_evolve_pass dispatch ────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def test_run_evolve_pass_disabled(tmp_path, monkeypatch):
|
|
110
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
111
|
+
assert pkg["ed"].run_evolve_pass() == "disabled"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_run_evolve_pass_no_pending(tmp_path, monkeypatch):
|
|
115
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
116
|
+
assert pkg["ed"].run_evolve_pass(force=True) == "no_pending"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_run_evolve_pass_below_min(tmp_path, monkeypatch):
|
|
120
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="2")
|
|
121
|
+
conn = pkg["db"].get_db()
|
|
122
|
+
_add_evolve(conn, "only one")
|
|
123
|
+
assert pkg["ed"].run_evolve_pass(force=True) == "below_min n=1"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_run_evolve_pass_spawns_reviewer(tmp_path, monkeypatch):
|
|
127
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="2")
|
|
128
|
+
conn = pkg["db"].get_db()
|
|
129
|
+
_add_evolve(conn, "suggestion alpha", rationale="friction A")
|
|
130
|
+
_add_evolve(conn, "suggestion beta")
|
|
131
|
+
calls = {}
|
|
132
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
133
|
+
monkeypatch.setattr(spawn_mod, "spawn",
|
|
134
|
+
lambda **kw: calls.update(kw) or "ok task=tk_ev pid=1")
|
|
135
|
+
out = pkg["ed"].run_evolve_pass(force=True)
|
|
136
|
+
assert out.startswith("spawned n=2")
|
|
137
|
+
# both suggestions reached the child prompt
|
|
138
|
+
assert "suggestion alpha" in calls["prompt"]
|
|
139
|
+
assert "suggestion beta" in calls["prompt"]
|
|
140
|
+
assert "friction A" in calls["prompt"]
|
|
141
|
+
assert calls["write_origin"] == "evolve"
|
|
142
|
+
assert calls["role"] == "evolve_reviewer"
|
|
143
|
+
# narrow tool surface: triage only, never applies
|
|
144
|
+
assert "evolve_decide" in calls["extra_allowed_tools"]
|
|
145
|
+
assert "skill_manage" not in calls["extra_allowed_tools"]
|
|
146
|
+
assert pkg["ed"]._last_evolve_ts(conn) > 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_run_evolve_pass_single_flight(tmp_path, monkeypatch):
|
|
150
|
+
pkg = _bootstrap(tmp_path, monkeypatch, review_min="1")
|
|
151
|
+
conn = pkg["db"].get_db()
|
|
152
|
+
_add_evolve(conn, "s1")
|
|
153
|
+
import os
|
|
154
|
+
conn.execute(
|
|
155
|
+
"INSERT INTO tasks (id, pid, cwd, prompt, started_at) "
|
|
156
|
+
"VALUES (?,?,?,?,?)",
|
|
157
|
+
("tk_evr", os.getpid(), "/tmp",
|
|
158
|
+
"You are an EVOLVE REVIEWER triaging the queue.", int(time.time())),
|
|
159
|
+
)
|
|
160
|
+
conn.commit()
|
|
161
|
+
|
|
162
|
+
def _boom(**kw):
|
|
163
|
+
raise AssertionError("must not spawn while a reviewer runs")
|
|
164
|
+
import threadkeeper.tools.spawn as spawn_mod
|
|
165
|
+
monkeypatch.setattr(spawn_mod, "spawn", _boom)
|
|
166
|
+
assert "reviewer_running" in pkg["ed"].run_evolve_pass(force=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── brief surfaces promoted ★ first, drops dismissed ───────────────────
|
|
170
|
+
|
|
171
|
+
def test_brief_evolve_promoted_marked_dismissed_hidden(tmp_path, monkeypatch):
|
|
172
|
+
pkg = _bootstrap(tmp_path, monkeypatch)
|
|
173
|
+
conn = pkg["db"].get_db()
|
|
174
|
+
_add_evolve(conn, "promoted one", status="promoted")
|
|
175
|
+
_add_evolve(conn, "pending one", status="pending")
|
|
176
|
+
_add_evolve(conn, "dismissed one", status="dismissed")
|
|
177
|
+
from threadkeeper.brief import render_brief
|
|
178
|
+
text = render_brief(conn)
|
|
179
|
+
# suggestion text is wrapped by q(); assert on the ★ marker + substring
|
|
180
|
+
assert "★" in text
|
|
181
|
+
assert "promoted one" in text
|
|
182
|
+
assert "pending one" in text
|
|
183
|
+
assert "dismissed one" not in text
|
|
184
|
+
# the ★ marker attaches to the promoted suggestion, not the pending one
|
|
185
|
+
assert text.index("★") < text.index("promoted one")
|
|
186
|
+
# promoted sorts before pending
|
|
187
|
+
assert text.index("promoted one") < text.index("pending one")
|
|
@@ -37,6 +37,19 @@ def _bootstrap_with_env(tmp_path, monkeypatch,
|
|
|
37
37
|
"CLAUDE_PROJECTS_DIR": str(tmp_path / "fake_claude_projects"),
|
|
38
38
|
"THREADKEEPER_INGEST_INTERVAL_S": "0",
|
|
39
39
|
"THREADKEEPER_INGEST_CAP": "0",
|
|
40
|
+
# Zero every background-daemon interval so no daemon thread fires a
|
|
41
|
+
# pass mid-test and emits a counted `spawn` event that races the
|
|
42
|
+
# nudge-counter assertions. Inherited from the real shell env
|
|
43
|
+
# otherwise (a dev box with probe/evolve daemons enabled in
|
|
44
|
+
# settings.json leaks the interval into pytest).
|
|
45
|
+
"THREADKEEPER_PROBE_INTERVAL_S": "0",
|
|
46
|
+
"THREADKEEPER_EVOLVE_REVIEW_INTERVAL_S": "0",
|
|
47
|
+
"THREADKEEPER_SHADOW_REVIEW_INTERVAL_S": "0",
|
|
48
|
+
"THREADKEEPER_CURATOR_INTERVAL_S": "0",
|
|
49
|
+
"THREADKEEPER_EXTRACT_INTERVAL_S": "0",
|
|
50
|
+
"THREADKEEPER_CANDIDATE_REVIEW_INTERVAL_S": "0",
|
|
51
|
+
"THREADKEEPER_SPAWN_BUDGET_POLL_S": "0",
|
|
52
|
+
"THREADKEEPER_MEMORY_GUARD_POLL_S": "0",
|
|
40
53
|
"THREADKEEPER_TASK_LOG_DIR": str(tmp_path / "tasks"),
|
|
41
54
|
"THREADKEEPER_CLIENT": "pytest",
|
|
42
55
|
"THREADKEEPER_MEMORY_NUDGE_INTERVAL": str(memory_interval),
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""ONNX embedding backend + tk-migrate-embeddings.
|
|
2
|
+
|
|
3
|
+
Verifies that:
|
|
4
|
+
- the active backend encodes to L2-normalized 384-dim float32 vectors
|
|
5
|
+
- embed_tag stamps the active backend for a real blob, None otherwise
|
|
6
|
+
- freshly inserted notes carry the embed_backend tag
|
|
7
|
+
- the migration recomputes stale (NULL-tagged) rows, tags them, and is
|
|
8
|
+
idempotent + dry-run-safe
|
|
9
|
+
|
|
10
|
+
Skips entirely when no embedding backend is installed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.slow # model warmup on first encode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _tool(pkg, name):
|
|
22
|
+
return pkg["mcp"]._tool_manager._tools[name].fn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture()
|
|
26
|
+
def sem_pkg(fresh_mp):
|
|
27
|
+
"""Fresh package against a clean tmp DB; skip if semantic search is off."""
|
|
28
|
+
if not fresh_mp["config"].SEMANTIC_AVAILABLE:
|
|
29
|
+
pytest.skip("no embedding backend installed in this environment")
|
|
30
|
+
return fresh_mp
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _seed_legacy_notes(conn, n: int):
|
|
34
|
+
"""Insert n notes with a real embedding blob but a NULL backend tag,
|
|
35
|
+
simulating rows written before the ONNX migration."""
|
|
36
|
+
from threadkeeper import embeddings as emb
|
|
37
|
+
for i in range(n):
|
|
38
|
+
blob = emb._embed(f"legacy seeded note {i} about webhooks and retries")
|
|
39
|
+
conn.execute(
|
|
40
|
+
"INSERT INTO notes (content, kind, created_at, embedding, embed_backend) "
|
|
41
|
+
"VALUES (?,?,?,?,NULL)",
|
|
42
|
+
(f"legacy seeded note {i}", "insight", int(time.time()), blob),
|
|
43
|
+
)
|
|
44
|
+
conn.commit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── encode primitives ────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def test_encode_is_normalized_384_float32(sem_pkg):
|
|
50
|
+
import numpy as np
|
|
51
|
+
from threadkeeper import embeddings as emb
|
|
52
|
+
arr = emb._encode(["привет мир", "hello world"])
|
|
53
|
+
assert arr is not None
|
|
54
|
+
assert arr.shape == (2, 384)
|
|
55
|
+
assert arr.dtype == np.dtype("float32")
|
|
56
|
+
assert np.allclose(np.linalg.norm(arr, axis=1), 1.0, atol=1e-3)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_encode_is_cross_lingual(sem_pkg):
|
|
60
|
+
"""A RU/EN translation pair must score higher than an unrelated phrase."""
|
|
61
|
+
from threadkeeper import embeddings as emb
|
|
62
|
+
v = emb._encode(["кошка", "cat", "quarterly financial report"])
|
|
63
|
+
assert float(v[0] @ v[1]) > float(v[0] @ v[2])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_embed_tag(sem_pkg):
|
|
67
|
+
from threadkeeper import embeddings as emb
|
|
68
|
+
active = sem_pkg["config"].EMBED_BACKEND
|
|
69
|
+
assert emb.embed_tag(b"\x00\x01") == active
|
|
70
|
+
assert emb.embed_tag(None) is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── write-path tagging ───────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def test_new_note_carries_backend_tag(sem_pkg):
|
|
76
|
+
tid = _tool(sem_pkg, "open_thread")(question="backend tag test")
|
|
77
|
+
_tool(sem_pkg, "note")(thread_id=tid,
|
|
78
|
+
content="tagged note about idempotency keys",
|
|
79
|
+
kind="insight")
|
|
80
|
+
conn = sem_pkg["db"].get_db()
|
|
81
|
+
active = sem_pkg["config"].EMBED_BACKEND
|
|
82
|
+
row = conn.execute(
|
|
83
|
+
"SELECT embedding, embed_backend FROM notes "
|
|
84
|
+
"WHERE thread_id=? ORDER BY id DESC LIMIT 1",
|
|
85
|
+
(tid,),
|
|
86
|
+
).fetchone()
|
|
87
|
+
assert row["embedding"] is not None
|
|
88
|
+
assert row["embed_backend"] == active
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── migration ────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def test_migration_recomputes_tags_and_is_idempotent(sem_pkg):
|
|
94
|
+
from threadkeeper import migrate_embeddings as mig
|
|
95
|
+
active = sem_pkg["config"].EMBED_BACKEND
|
|
96
|
+
conn = sem_pkg["db"].get_db()
|
|
97
|
+
_seed_legacy_notes(conn, 3)
|
|
98
|
+
|
|
99
|
+
assert mig._count_stale(conn, "notes", active) == 3
|
|
100
|
+
|
|
101
|
+
rc = mig.run(do_notes=True, do_dialog=False, batch=2,
|
|
102
|
+
dry_run=False, log=lambda _m: None)
|
|
103
|
+
assert rc == 0
|
|
104
|
+
assert mig._count_stale(conn, "notes", active) == 0
|
|
105
|
+
tagged = conn.execute(
|
|
106
|
+
"SELECT COUNT(*) FROM notes WHERE embed_backend=?", (active,)
|
|
107
|
+
).fetchone()[0]
|
|
108
|
+
assert tagged >= 3
|
|
109
|
+
|
|
110
|
+
# idempotent: a second pass finds nothing stale and changes nothing.
|
|
111
|
+
rc2 = mig.run(do_notes=True, do_dialog=False, batch=2,
|
|
112
|
+
dry_run=False, log=lambda _m: None)
|
|
113
|
+
assert rc2 == 0
|
|
114
|
+
assert mig._count_stale(conn, "notes", active) == 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_migration_dry_run_writes_nothing(sem_pkg):
|
|
118
|
+
from threadkeeper import migrate_embeddings as mig
|
|
119
|
+
active = sem_pkg["config"].EMBED_BACKEND
|
|
120
|
+
conn = sem_pkg["db"].get_db()
|
|
121
|
+
_seed_legacy_notes(conn, 2)
|
|
122
|
+
|
|
123
|
+
assert mig._count_stale(conn, "notes", active) == 2
|
|
124
|
+
mig.run(do_notes=True, do_dialog=False, batch=10,
|
|
125
|
+
dry_run=True, log=lambda _m: None)
|
|
126
|
+
# still stale — dry run must not touch the rows
|
|
127
|
+
assert mig._count_stale(conn, "notes", active) == 2
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_migration_requires_a_scope_flag(sem_pkg):
|
|
131
|
+
from threadkeeper import migrate_embeddings as mig
|
|
132
|
+
with pytest.raises(SystemExit):
|
|
133
|
+
mig.main([]) # argparse error → SystemExit(2)
|