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.
Files changed (114) hide show
  1. {threadkeeper-0.6.2/threadkeeper.egg-info → threadkeeper-0.8.0}/PKG-INFO +39 -4
  2. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/README.md +32 -2
  3. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/pyproject.toml +17 -2
  4. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_curator.py +82 -0
  5. threadkeeper-0.8.0/tests/test_evolve_daemon.py +187 -0
  6. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_nudges.py +13 -0
  7. threadkeeper-0.8.0/tests/test_onnx_embeddings.py +133 -0
  8. threadkeeper-0.8.0/tests/test_panel.py +188 -0
  9. threadkeeper-0.8.0/tests/test_probe_daemon.py +211 -0
  10. threadkeeper-0.8.0/tests/test_search_fts_punctuation.py +67 -0
  11. threadkeeper-0.8.0/tests/test_skill_passive_tier.py +117 -0
  12. threadkeeper-0.8.0/tests/test_spawn_reap.py +80 -0
  13. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/brief.py +21 -7
  14. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/config.py +96 -7
  15. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/curator.py +61 -1
  16. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/db.py +18 -2
  17. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/embeddings.py +68 -19
  18. threadkeeper-0.8.0/threadkeeper/evolve_daemon.py +233 -0
  19. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/helpers.py +21 -0
  20. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/identity.py +10 -0
  21. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/ingest.py +75 -42
  22. threadkeeper-0.8.0/threadkeeper/migrate_embeddings.py +146 -0
  23. threadkeeper-0.8.0/threadkeeper/probe_daemon.py +276 -0
  24. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/server.py +1 -0
  25. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/shadow_review.py +2 -0
  26. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/consolidate.py +4 -4
  27. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/dialectic.py +20 -2
  28. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/extract.py +3 -3
  29. threadkeeper-0.8.0/threadkeeper/tools/panel.py +195 -0
  30. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/pickup.py +4 -3
  31. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/session.py +4 -4
  32. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/spawn.py +59 -5
  33. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/threads.py +54 -9
  34. {threadkeeper-0.6.2 → threadkeeper-0.8.0/threadkeeper.egg-info}/PKG-INFO +39 -4
  35. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/SOURCES.txt +11 -0
  36. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/entry_points.txt +1 -0
  37. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/requires.txt +6 -0
  38. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/LICENSE +0 -0
  39. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/setup.cfg +0 -0
  40. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_adapters.py +0 -0
  41. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_brief_sections.py +0 -0
  42. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_candidate_reviewer.py +0 -0
  43. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_core_memory.py +0 -0
  44. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_delegated_search.py +0 -0
  45. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_dialectic.py +0 -0
  46. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_dialectic_tier.py +0 -0
  47. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_error_paths.py +0 -0
  48. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_extract_daemon.py +0 -0
  49. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_i18n_multilang.py +0 -0
  50. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_identity.py +0 -0
  51. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_lessons.py +0 -0
  52. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_memory_guard.py +0 -0
  53. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_missed_spawns.py +0 -0
  54. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_process_health.py +0 -0
  55. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_shadow_review.py +0 -0
  56. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_hint.py +0 -0
  57. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_tier.py +0 -0
  58. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_use_parser.py +0 -0
  59. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skill_watcher.py +0 -0
  60. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_skills.py +0 -0
  61. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_budget.py +0 -0
  62. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_config.py +0 -0
  63. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_hint.py +0 -0
  64. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_spawn_slim.py +0 -0
  65. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_threads.py +0 -0
  66. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_tools_smoke.py +0 -0
  67. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_validate_threads.py +0 -0
  68. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/tests/test_vec_search.py +0 -0
  69. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/__init__.py +0 -0
  70. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/_mcp.py +0 -0
  71. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/_setup.py +0 -0
  72. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/__init__.py +0 -0
  73. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
  74. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/base.py +0 -0
  75. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_code.py +0 -0
  76. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/claude_desktop.py +0 -0
  77. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/codex.py +0 -0
  78. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/copilot.py +0 -0
  79. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/gemini.py +0 -0
  80. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/adapters/vscode.py +0 -0
  81. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/candidate_reviewer.py +0 -0
  82. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/extract_daemon.py +0 -0
  83. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/i18n.py +0 -0
  84. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/lessons.py +0 -0
  85. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/memory_guard.py +0 -0
  86. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/nudges.py +0 -0
  87. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/process_health.py +0 -0
  88. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/review_prompts.py +0 -0
  89. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/search_proxy.py +0 -0
  90. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/skill_watcher.py +0 -0
  91. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/spawn_budget.py +0 -0
  92. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/spawn_config.py +0 -0
  93. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/__init__.py +0 -0
  94. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
  95. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/concepts.py +0 -0
  96. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/core_memory.py +0 -0
  97. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/correlation.py +0 -0
  98. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/curator.py +0 -0
  99. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/dialog.py +0 -0
  100. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/distill.py +0 -0
  101. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/graph.py +0 -0
  102. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/invariants.py +0 -0
  103. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/lessons.py +0 -0
  104. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/memory_guard.py +0 -0
  105. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/missed_spawns.py +0 -0
  106. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/peers.py +0 -0
  107. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/probes.py +0 -0
  108. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/process_health.py +0 -0
  109. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/shadow_review.py +0 -0
  110. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/skills.py +0 -0
  111. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/style.py +0 -0
  112. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper/tools/validate.py +0 -0
  113. {threadkeeper-0.6.2 → threadkeeper-0.8.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
  114. {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.6.2
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: sentence-transformers>=2.2.0; extra == "semantic"
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 sentence-transformers.
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 sentence-transformers |
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 sentence-transformers.
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 sentence-transformers |
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.6.2"
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)