threadkeeper 0.5.2__tar.gz → 0.6.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 (103) hide show
  1. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/PKG-INFO +35 -16
  2. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/README.md +34 -15
  3. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/pyproject.toml +1 -1
  4. threadkeeper-0.6.0/tests/test_memory_guard.py +219 -0
  5. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_nudges.py +66 -0
  6. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_shadow_review.py +55 -0
  7. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_hint.py +1 -1
  8. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skills.py +66 -1
  9. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_slim.py +17 -2
  10. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/_setup.py +37 -5
  11. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/brief.py +30 -2
  12. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/candidate_reviewer.py +5 -4
  13. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/config.py +49 -0
  14. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/curator.py +5 -3
  15. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/db.py +16 -0
  16. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/embeddings.py +52 -18
  17. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/extract_daemon.py +5 -3
  18. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/identity.py +10 -0
  19. threadkeeper-0.6.0/threadkeeper/memory_guard.py +487 -0
  20. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/nudges.py +62 -1
  21. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/review_prompts.py +4 -4
  22. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/server.py +1 -0
  23. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/shadow_review.py +56 -15
  24. threadkeeper-0.6.0/threadkeeper/tools/memory_guard.py +127 -0
  25. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/skills.py +116 -41
  26. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/spawn.py +36 -10
  27. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/threads.py +12 -3
  28. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/PKG-INFO +35 -16
  29. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/SOURCES.txt +3 -0
  30. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/LICENSE +0 -0
  31. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/setup.cfg +0 -0
  32. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_adapters.py +0 -0
  33. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_brief_sections.py +0 -0
  34. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_candidate_reviewer.py +0 -0
  35. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_core_memory.py +0 -0
  36. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_curator.py +0 -0
  37. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_delegated_search.py +0 -0
  38. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_dialectic.py +0 -0
  39. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_dialectic_tier.py +0 -0
  40. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_error_paths.py +0 -0
  41. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_extract_daemon.py +0 -0
  42. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_i18n_multilang.py +0 -0
  43. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_identity.py +0 -0
  44. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_lessons.py +0 -0
  45. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_missed_spawns.py +0 -0
  46. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_process_health.py +0 -0
  47. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_tier.py +0 -0
  48. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_use_parser.py +0 -0
  49. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_watcher.py +0 -0
  50. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_budget.py +0 -0
  51. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_config.py +0 -0
  52. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_hint.py +0 -0
  53. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_threads.py +0 -0
  54. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_tools_smoke.py +0 -0
  55. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_validate_threads.py +0 -0
  56. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_vec_search.py +0 -0
  57. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/__init__.py +0 -0
  58. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/_mcp.py +0 -0
  59. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/__init__.py +0 -0
  60. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
  61. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/base.py +0 -0
  62. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_code.py +0 -0
  63. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_desktop.py +0 -0
  64. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/codex.py +0 -0
  65. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/copilot.py +0 -0
  66. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/gemini.py +0 -0
  67. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/vscode.py +0 -0
  68. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/helpers.py +0 -0
  69. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/i18n.py +0 -0
  70. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/ingest.py +0 -0
  71. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/lessons.py +0 -0
  72. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/process_health.py +0 -0
  73. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/search_proxy.py +0 -0
  74. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/skill_watcher.py +0 -0
  75. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/spawn_budget.py +0 -0
  76. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/spawn_config.py +0 -0
  77. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/__init__.py +0 -0
  78. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
  79. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/concepts.py +0 -0
  80. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/consolidate.py +0 -0
  81. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/core_memory.py +0 -0
  82. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/correlation.py +0 -0
  83. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/curator.py +0 -0
  84. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/dialectic.py +0 -0
  85. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/dialog.py +0 -0
  86. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/distill.py +0 -0
  87. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/extract.py +0 -0
  88. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/graph.py +0 -0
  89. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/invariants.py +0 -0
  90. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/lessons.py +0 -0
  91. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/missed_spawns.py +0 -0
  92. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/peers.py +0 -0
  93. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/pickup.py +0 -0
  94. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/probes.py +0 -0
  95. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/process_health.py +0 -0
  96. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/session.py +0 -0
  97. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/shadow_review.py +0 -0
  98. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/style.py +0 -0
  99. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/validate.py +0 -0
  100. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
  101. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/entry_points.txt +0 -0
  102. {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/requires.txt +0 -0
  103. {threadkeeper-0.5.2 → threadkeeper-0.6.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.5.2
3
+ Version: 0.6.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
@@ -82,10 +82,10 @@ make it more than a memory store:
82
82
  harvester, candidate-reviewer, weekly Curator) materialize
83
83
  class-level skills as the agents work. Adapted to multi-CLI:
84
84
  SKILL.md is the primary write target and gets mirrored to every
85
- detected CLI's skills directory simultaneously
86
- (`~/.claude/skills/`, `~/.codex/skills/`, `~/.threadkeeper/skills/`),
87
- with lessons.md as a fallback for CLIs without a native skills
88
- loader.
85
+ known/configured skills root simultaneously (`~/.claude/skills/`,
86
+ `~/.codex/skills/`, existing `~/.agents/skills/`, extra roots from
87
+ `THREADKEEPER_EXTRA_SKILLS_DIRS`, and `~/.threadkeeper/skills/`),
88
+ with lessons.md as a fallback for CLIs without a native skills loader.
89
89
 
90
90
  ---
91
91
 
@@ -183,6 +183,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
183
183
  child loads only the thread-keeper MCP, no embeddings, no third-party
184
184
  servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
185
185
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
186
+ Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
187
+ autonomous learning daemons cannot recursively start inside review forks.
186
188
 
187
189
  A daemon measures combined child RSS every 10 s; admission control
188
190
  refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
@@ -223,8 +225,8 @@ shows agents focused on their primary task rarely do).
223
225
  brief() SKILL.md + lessons.md ─► skill_usage │
224
226
  │ │ │ │
225
227
  │ ▼ ▼ │
226
- │ (every detected CLI's │ │
227
- │ skills/ directory) │ │
228
+ │ (every configured │ │
229
+ │ skills/ root) │ │
228
230
  │ │ │ │
229
231
  │ └──────► [5] Curator daemon ───┘
230
232
  │ (cron, every 7d)
@@ -246,11 +248,11 @@ shows agents focused on their primary task rarely do).
246
248
  | 5 | Curator daemon | every 7 days (env knob) | every existing lesson + recently-touched skill | REPORT-`<date>`.md (advisory) or direct PATCH/PRUNE/CONSOLIDATE |
247
249
 
248
250
  All five write into the universal Skill format (`SKILL.md` under each
249
- detected CLI's skills directory — `~/.claude/skills/`,
250
- `~/.codex/skills/`, plus the canonical `~/.threadkeeper/skills/`
251
- mirror), with `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback
252
- for clients without a native skills loader (Gemini, Copilot, bare
253
- MCP).
251
+ known/configured skills root — `~/.claude/skills/`, `~/.codex/skills/`,
252
+ existing `~/.agents/skills/`, optional `THREADKEEPER_EXTRA_SKILLS_DIRS`,
253
+ plus the canonical `~/.threadkeeper/skills/` mirror), with
254
+ `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback for clients
255
+ without a native skills loader (Gemini, Copilot, bare MCP).
254
256
 
255
257
  #### 1. Auto-review on close_thread
256
258
 
@@ -260,9 +262,12 @@ thread's notes. The prompt is rubric-form (Q1–Q5 yes/no) with explicit
260
262
  positive examples for incident-vs-rule classification. The fork also
261
263
  receives a "recently active skills" block so it prefers PATCHing
262
264
  existing umbrellas over creating new ones (*active-update bias*).
263
- Child appends a lesson via `lesson_append`, optionally mirrors to
264
- `~/.claude/skills/<name>/SKILL.md`, then closes with
265
- `mark_skill_materialized`. Opt in with `THREADKEEPER_AUTO_REVIEW=1`.
265
+ Child appends a lesson via `lesson_append`, writes/patches a skill via
266
+ `skill_manage` or writes a skill file directly, then closes with
267
+ `mark_skill_materialized`. If `skill_path` points at a `SKILL.md` (or a
268
+ skill directory), thread-keeper immediately mirrors that whole skill
269
+ into every configured skills root. Opt in with
270
+ `THREADKEEPER_AUTO_REVIEW=1`.
266
271
 
267
272
  #### 2. Shadow-review daemon
268
273
 
@@ -272,7 +277,11 @@ the last cursor **across all CLIs at once**. The window filters
272
277
  internal review-child sessions (no self-pollution) and strips adapter
273
278
  `[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
274
279
  ≥500 chars of meaningful signal remain, spawns a slim observer child
275
- that decides on class-level learning. Idempotent through
280
+ that decides on class-level learning. It is single-flight across the shared
281
+ DB: if any shadow observer task is already running, the daemon does not spawn
282
+ another one and does not advance the cursor. Shadow observer children are
283
+ marked as spawned/background processes, so they cannot start their own shadow
284
+ daemon even if a CLI drops the no-embeddings env. Idempotent through
276
285
  `events.kind='shadow_review_pass'`.
277
286
 
278
287
  #### 3. Extract daemon
@@ -415,8 +424,18 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
415
424
  | `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
416
425
  | `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
417
426
  | `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
427
+ | `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
428
+ | `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
429
+ | `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
430
+ | `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
431
+ | `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
432
+ | `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
433
+ | `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
434
+ | `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
435
+ | `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
418
436
  | `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
419
437
  | `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
438
+ | `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
420
439
  | `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
421
440
 
422
441
  Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
@@ -48,10 +48,10 @@ make it more than a memory store:
48
48
  harvester, candidate-reviewer, weekly Curator) materialize
49
49
  class-level skills as the agents work. Adapted to multi-CLI:
50
50
  SKILL.md is the primary write target and gets mirrored to every
51
- detected CLI's skills directory simultaneously
52
- (`~/.claude/skills/`, `~/.codex/skills/`, `~/.threadkeeper/skills/`),
53
- with lessons.md as a fallback for CLIs without a native skills
54
- loader.
51
+ known/configured skills root simultaneously (`~/.claude/skills/`,
52
+ `~/.codex/skills/`, existing `~/.agents/skills/`, extra roots from
53
+ `THREADKEEPER_EXTRA_SKILLS_DIRS`, and `~/.threadkeeper/skills/`),
54
+ with lessons.md as a fallback for CLIs without a native skills loader.
55
55
 
56
56
  ---
57
57
 
@@ -149,6 +149,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
149
149
  child loads only the thread-keeper MCP, no embeddings, no third-party
150
150
  servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
151
151
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
152
+ Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
153
+ autonomous learning daemons cannot recursively start inside review forks.
152
154
 
153
155
  A daemon measures combined child RSS every 10 s; admission control
154
156
  refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
@@ -189,8 +191,8 @@ shows agents focused on their primary task rarely do).
189
191
  brief() SKILL.md + lessons.md ─► skill_usage │
190
192
  │ │ │ │
191
193
  │ ▼ ▼ │
192
- │ (every detected CLI's │ │
193
- │ skills/ directory) │ │
194
+ │ (every configured │ │
195
+ │ skills/ root) │ │
194
196
  │ │ │ │
195
197
  │ └──────► [5] Curator daemon ───┘
196
198
  │ (cron, every 7d)
@@ -212,11 +214,11 @@ shows agents focused on their primary task rarely do).
212
214
  | 5 | Curator daemon | every 7 days (env knob) | every existing lesson + recently-touched skill | REPORT-`<date>`.md (advisory) or direct PATCH/PRUNE/CONSOLIDATE |
213
215
 
214
216
  All five write into the universal Skill format (`SKILL.md` under each
215
- detected CLI's skills directory — `~/.claude/skills/`,
216
- `~/.codex/skills/`, plus the canonical `~/.threadkeeper/skills/`
217
- mirror), with `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback
218
- for clients without a native skills loader (Gemini, Copilot, bare
219
- MCP).
217
+ known/configured skills root — `~/.claude/skills/`, `~/.codex/skills/`,
218
+ existing `~/.agents/skills/`, optional `THREADKEEPER_EXTRA_SKILLS_DIRS`,
219
+ plus the canonical `~/.threadkeeper/skills/` mirror), with
220
+ `~/.threadkeeper/lessons.md` as a CLI-agnostic fallback for clients
221
+ without a native skills loader (Gemini, Copilot, bare MCP).
220
222
 
221
223
  #### 1. Auto-review on close_thread
222
224
 
@@ -226,9 +228,12 @@ thread's notes. The prompt is rubric-form (Q1–Q5 yes/no) with explicit
226
228
  positive examples for incident-vs-rule classification. The fork also
227
229
  receives a "recently active skills" block so it prefers PATCHing
228
230
  existing umbrellas over creating new ones (*active-update bias*).
229
- Child appends a lesson via `lesson_append`, optionally mirrors to
230
- `~/.claude/skills/<name>/SKILL.md`, then closes with
231
- `mark_skill_materialized`. Opt in with `THREADKEEPER_AUTO_REVIEW=1`.
231
+ Child appends a lesson via `lesson_append`, writes/patches a skill via
232
+ `skill_manage` or writes a skill file directly, then closes with
233
+ `mark_skill_materialized`. If `skill_path` points at a `SKILL.md` (or a
234
+ skill directory), thread-keeper immediately mirrors that whole skill
235
+ into every configured skills root. Opt in with
236
+ `THREADKEEPER_AUTO_REVIEW=1`.
232
237
 
233
238
  #### 2. Shadow-review daemon
234
239
 
@@ -238,7 +243,11 @@ the last cursor **across all CLIs at once**. The window filters
238
243
  internal review-child sessions (no self-pollution) and strips adapter
239
244
  `[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
240
245
  ≥500 chars of meaningful signal remain, spawns a slim observer child
241
- that decides on class-level learning. Idempotent through
246
+ that decides on class-level learning. It is single-flight across the shared
247
+ DB: if any shadow observer task is already running, the daemon does not spawn
248
+ another one and does not advance the cursor. Shadow observer children are
249
+ marked as spawned/background processes, so they cannot start their own shadow
250
+ daemon even if a CLI drops the no-embeddings env. Idempotent through
242
251
  `events.kind='shadow_review_pass'`.
243
252
 
244
253
  #### 3. Extract daemon
@@ -381,8 +390,18 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
381
390
  | `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
382
391
  | `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
383
392
  | `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
393
+ | `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
394
+ | `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
395
+ | `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
396
+ | `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
397
+ | `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
398
+ | `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
399
+ | `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
400
+ | `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
401
+ | `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
384
402
  | `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
385
403
  | `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
404
+ | `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
386
405
  | `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
387
406
 
388
407
  Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.5.2"
7
+ version = "0.6.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" }]
@@ -0,0 +1,219 @@
1
+ """Memory guard for thread-keeper server RSS thresholds."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal as _sig
6
+
7
+
8
+ _FAKE_CID = "55556666-7777-8888-9999-000011112222"
9
+
10
+
11
+ def _tool(pkg, name):
12
+ return pkg["mcp"]._tool_manager._tools[name].fn
13
+
14
+
15
+ def _proc(pid, rss_mb, *, ppid=None):
16
+ return {
17
+ "pid": pid,
18
+ "ppid": os.getpid() if ppid is None else ppid,
19
+ "rss_kb": rss_mb * 1024,
20
+ "rss_mb": rss_mb,
21
+ "etime": "1:00",
22
+ "command": "python -m threadkeeper.server",
23
+ "parent_alive": True,
24
+ "heartbeat_age_s": 5,
25
+ "is_self": pid == os.getpid(),
26
+ "is_orphaned": False,
27
+ "orphan_reason": "parent_alive",
28
+ }
29
+
30
+
31
+ def test_scan_over_limit_splits_warn_and_kill(mp_with_cid, monkeypatch):
32
+ mp_with_cid(_FAKE_CID)
33
+ from threadkeeper import memory_guard, process_health
34
+
35
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
36
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
37
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
38
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
39
+ monkeypatch.setattr(process_health, "scan", lambda: [
40
+ _proc(1001, 800),
41
+ _proc(1002, 1200),
42
+ _proc(1003, 2500),
43
+ ])
44
+
45
+ out = memory_guard.scan_over_limit()
46
+ assert [p["pid"] for p in out["warn"]] == [1002]
47
+ assert [p["pid"] for p in out["kill"]] == [1003]
48
+
49
+
50
+ def test_check_dry_run_does_not_kill(mp_with_cid, monkeypatch):
51
+ mp_with_cid(_FAKE_CID)
52
+ from threadkeeper import memory_guard, process_health
53
+
54
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
55
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
56
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
57
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
58
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1003, 2500)])
59
+ killed: list[tuple[int, int]] = []
60
+ monkeypatch.setattr(
61
+ "os.kill",
62
+ lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
63
+ )
64
+
65
+ out = memory_guard.check_once(dry_run=True, notify=False)
66
+ assert [p["pid"] for p in out["kill"]] == [1003]
67
+ assert out["killed"] == []
68
+ assert killed == []
69
+
70
+
71
+ def test_check_apply_sends_sigterm(mp_with_cid, monkeypatch):
72
+ mp_with_cid(_FAKE_CID)
73
+ from threadkeeper import memory_guard, process_health
74
+
75
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
76
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
77
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
78
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
79
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1004, 2500)])
80
+ calls: list[tuple[int, int]] = []
81
+ monkeypatch.setattr("os.kill", lambda pid, sig: calls.append((pid, sig)))
82
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
83
+ "before_mb": 2500, "after_mb": 2400, "freed_mb": 100,
84
+ "pid": os.getpid(), "actions": [],
85
+ })
86
+
87
+ out = memory_guard.check_once(dry_run=False, notify=False)
88
+ assert out["killed"] == [1004]
89
+ assert calls == [(1004, _sig.SIGTERM)]
90
+
91
+
92
+ def test_memory_guard_status_tool_reports_thresholds(mp_with_cid, monkeypatch):
93
+ pkg = mp_with_cid(_FAKE_CID)
94
+ from threadkeeper import memory_guard, process_health
95
+
96
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_POLL_S", 30)
97
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
98
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
99
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
100
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
101
+ monkeypatch.setattr(process_health, "scan", lambda: [
102
+ _proc(1001, 800),
103
+ _proc(1002, 1200),
104
+ _proc(1003, 2500),
105
+ ])
106
+
107
+ txt = _tool(pkg, "memory_guard_status")()
108
+ assert "state=active" in txt
109
+ assert "warn_mb=1000" in txt
110
+ assert "kill_mb=2000" in txt
111
+ assert "ok pid=1001" in txt
112
+ assert "WARN pid=1002" in txt
113
+ assert "KILL pid=1003" in txt
114
+
115
+
116
+ def test_memory_guard_check_tool_defaults_to_dry_run(mp_with_cid, monkeypatch):
117
+ pkg = mp_with_cid(_FAKE_CID)
118
+ from threadkeeper import memory_guard, process_health
119
+
120
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
121
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
122
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
123
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
124
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1005, 2500)])
125
+ killed: list[tuple[int, int]] = []
126
+ monkeypatch.setattr(
127
+ "os.kill",
128
+ lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
129
+ )
130
+
131
+ txt = _tool(pkg, "memory_guard_check")()
132
+ assert txt.startswith("dry_run")
133
+ assert "would SIGTERM pid=1005" in txt
134
+ assert killed == []
135
+
136
+
137
+ def test_scan_reports_aggregate_pressure(mp_with_cid, monkeypatch):
138
+ mp_with_cid(_FAKE_CID)
139
+ from threadkeeper import memory_guard, process_health
140
+
141
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
142
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
143
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
144
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
145
+ monkeypatch.setattr(process_health, "scan", lambda: [
146
+ _proc(1001, 800),
147
+ _proc(1002, 900),
148
+ _proc(1003, 1200),
149
+ ])
150
+
151
+ out = memory_guard.scan_over_limit()
152
+ assert out["aggregate"]["rss_mb"] == 2900
153
+ assert out["aggregate"]["warn"] is True
154
+ assert out["aggregate"]["kill"] is False
155
+ assert out["warn"] == []
156
+ assert out["kill"] == []
157
+
158
+
159
+ def test_check_apply_requests_peer_trim_on_aggregate_warn(mp_with_cid, monkeypatch):
160
+ pkg = mp_with_cid(_FAKE_CID)
161
+ from threadkeeper import memory_guard, process_health
162
+
163
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
164
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
165
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
166
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
167
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
168
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
169
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
170
+ "before_mb": 900, "after_mb": 800, "freed_mb": 100,
171
+ "pid": os.getpid(), "actions": ["fake"],
172
+ })
173
+ monkeypatch.setattr(process_health, "scan", lambda: [
174
+ _proc(os.getpid(), 900),
175
+ _proc(1002, 1200, ppid=os.getpid()),
176
+ ])
177
+
178
+ out = memory_guard.check_once(dry_run=False, notify=False)
179
+ assert sorted(out["reclaim_requests"]["requested"]) == sorted([os.getpid(), 1002])
180
+
181
+ conn = pkg["db"].get_db()
182
+ rows = conn.execute(
183
+ "SELECT target_pid FROM resource_controls WHERE action='trim'"
184
+ ).fetchall()
185
+ assert sorted(r["target_pid"] for r in rows) == sorted([os.getpid(), 1002])
186
+
187
+
188
+ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, monkeypatch):
189
+ mp_with_cid(_FAKE_CID)
190
+ from threadkeeper import memory_guard, process_health
191
+
192
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
193
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
194
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
195
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
196
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
197
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
198
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
199
+ "before_mb": 900, "after_mb": 800, "freed_mb": 100,
200
+ "pid": os.getpid(), "actions": ["fake"],
201
+ })
202
+
203
+ def scan():
204
+ return [
205
+ _proc(os.getpid(), 900),
206
+ _proc(1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
207
+ _proc(1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
208
+ ]
209
+
210
+ monkeypatch.setattr(process_health, "scan", scan)
211
+ calls: list[tuple[int, int]] = []
212
+ monkeypatch.setattr(
213
+ "os.kill",
214
+ lambda pid, sig: calls.append((pid, sig)) if sig != 0 else None,
215
+ )
216
+
217
+ out = memory_guard.check_once(dry_run=False, notify=False)
218
+ assert out["retired"] == [1001]
219
+ assert calls == [(1001, _sig.SIGTERM)]
@@ -438,3 +438,69 @@ def test_close_thread_with_auto_review_enabled_spawns(tmp_path, monkeypatch):
438
438
  assert len(calls) == 1
439
439
  assert calls[0]["thread_id"] == tid
440
440
  assert calls[0]["mode"] == "auto"
441
+
442
+
443
+ # ──────────────────────────────────────────────────────────────────────────
444
+ # compute_thread_nudge (open-thread nudge for hook-less clients)
445
+ # ──────────────────────────────────────────────────────────────────────────
446
+
447
+ def _insert_event(pkg, session_id: str, kind: str) -> None:
448
+ conn = pkg["db"].get_db()
449
+ conn.execute(
450
+ "INSERT INTO events (session_id, kind, target, summary, created_at) "
451
+ "VALUES (?,?,?,?,?)",
452
+ (session_id, kind, None, "", int(time.time())),
453
+ )
454
+ conn.commit()
455
+
456
+
457
+ def test_thread_nudge_fires_on_empty_session(tmp_path, monkeypatch):
458
+ """No activity threshold — fires as soon as a session exists with no
459
+ open_thread (mirrors the hook firing on the first prompt)."""
460
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
461
+ conn = pkg["db"].get_db()
462
+ out = pkg["nudges"].compute_thread_nudge(conn, "sess-empty")
463
+ assert out is not None
464
+ assert "thread_hint" in out
465
+ assert "open_thread(question)" in out
466
+
467
+
468
+ def test_thread_nudge_silent_after_open_thread(tmp_path, monkeypatch):
469
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
470
+ _insert_event(pkg, "sess-B", "open_thread")
471
+ conn = pkg["db"].get_db()
472
+ assert pkg["nudges"].compute_thread_nudge(conn, "sess-B") is None
473
+
474
+
475
+ def test_thread_nudge_silent_after_shown(tmp_path, monkeypatch):
476
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
477
+ _insert_event(pkg, "sess-C", "thread_hint_shown")
478
+ conn = pkg["db"].get_db()
479
+ assert pkg["nudges"].compute_thread_nudge(conn, "sess-C") is None
480
+
481
+
482
+ def test_thread_nudge_silent_without_session_id(tmp_path, monkeypatch):
483
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
484
+ conn = pkg["db"].get_db()
485
+ assert pkg["nudges"].compute_thread_nudge(conn, "") is None
486
+
487
+
488
+ def test_brief_shows_thread_hint_once_then_suppresses(tmp_path, monkeypatch):
489
+ """brief() surfaces the nudge on the first call (no env set = hook-less
490
+ client), logs thread_hint_shown, and stays quiet thereafter."""
491
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
492
+ monkeypatch.delenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", raising=False)
493
+ brief = _tool(pkg, "brief")
494
+ out1 = brief()
495
+ assert "thread_hint" in out1
496
+ out2 = brief()
497
+ assert "thread_hint" not in out2
498
+
499
+
500
+ def test_brief_suppresses_thread_hint_when_env_set(tmp_path, monkeypatch):
501
+ """SessionStart-hook path (env set) never surfaces the in-brief nudge —
502
+ the UserPromptSubmit hook owns it there."""
503
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
504
+ monkeypatch.setenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", "1")
505
+ out = _tool(pkg, "brief")()
506
+ assert "thread_hint" not in out
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import time
13
13
  import sys
14
+ import os
14
15
  from pathlib import Path
15
16
 
16
17
  import pytest
@@ -258,6 +259,42 @@ def test_run_shadow_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
258
259
  assert long_msg.strip()[:40] in kw["prompt"]
259
260
 
260
261
 
262
+ def test_run_shadow_pass_single_flight_when_child_running(tmp_path, monkeypatch):
263
+ pkg = _bootstrap(tmp_path, monkeypatch, min_chars="100")
264
+ conn = pkg["db"].get_db()
265
+ now = int(time.time())
266
+ long_msg = "Pattern: in this type of task always X. " * 10
267
+ _seed_dialog(conn, "user", long_msg, now - 5)
268
+ conn.execute(
269
+ "INSERT INTO tasks (id, pid, parent_cid, spawned_cid, cwd, prompt, "
270
+ "started_at, rss_kb, rss_updated_at) "
271
+ "VALUES (?,?,?,?,?,?,?,?,?)",
272
+ (
273
+ "tk_shadow_running",
274
+ os.getpid(),
275
+ "parent",
276
+ "child",
277
+ str(tmp_path),
278
+ pkg["shadow_review"].SHADOW_REVIEW_PROMPT,
279
+ now - 1,
280
+ 123,
281
+ now,
282
+ ),
283
+ )
284
+ conn.commit()
285
+
286
+ import threadkeeper.tools.spawn as spawn_mod
287
+
288
+ def should_not_spawn(**kwargs):
289
+ raise AssertionError("shadow pass should be single-flight")
290
+
291
+ monkeypatch.setattr(spawn_mod, "spawn", should_not_spawn)
292
+ out = pkg["shadow_review"].run_shadow_pass(force=True)
293
+ assert out == "shadow_child_running n=1"
294
+ # Cursor does not advance; retry the same window when the child exits.
295
+ assert pkg["shadow_review"]._last_shadow_ts(conn) == 0
296
+
297
+
261
298
  def test_run_shadow_pass_idempotent_after_cursor_advance(tmp_path, monkeypatch):
262
299
  """Second pass over the same data must produce no_window once cursor
263
300
  catches up."""
@@ -314,6 +351,24 @@ def test_daemon_does_not_start_in_slim_child(tmp_path, monkeypatch):
314
351
  )
315
352
 
316
353
 
354
+ def test_daemon_does_not_start_in_marked_spawned_child(tmp_path, monkeypatch):
355
+ """The cascade guard must not depend only on NO_EMBEDDINGS.
356
+
357
+ Some CLIs launch MCP servers from a config env block, so the child
358
+ process may not reliably inherit THREADKEEPER_NO_EMBEDDINGS. The explicit
359
+ THREADKEEPER_SPAWNED_CHILD marker still has to stop shadow_review.
360
+ """
361
+ monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
362
+ pkg = _bootstrap(tmp_path, monkeypatch, interval="60")
363
+ import threadkeeper.config as cfg
364
+ monkeypatch.setattr(cfg, "SEMANTIC_AVAILABLE", True)
365
+ pkg["shadow_review"]._started = False
366
+ pkg["shadow_review"].start_shadow_daemon()
367
+ assert pkg["shadow_review"]._started is False, (
368
+ "marked spawned child should refuse to start shadow daemon"
369
+ )
370
+
371
+
317
372
  def test_mcp_shadow_review_status_reports_passes(tmp_path, monkeypatch):
318
373
  pkg = _bootstrap(tmp_path, monkeypatch)
319
374
  pkg["shadow_review"]._record_shadow_pass(
@@ -1,5 +1,5 @@
1
1
  """brief() skill_hint nudge — fires when a recently-closed thread is rich
2
- enough to be worth materializing as a Claude skill under ~/.claude/skills/.
2
+ enough to be worth materializing as a mirrored reusable skill.
3
3
 
4
4
  After complex tasks, the agent should turn distilled insights into
5
5
  reusable skills, not let them sit only in notes. The nudge surfaces