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.
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/PKG-INFO +35 -16
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/README.md +34 -15
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/pyproject.toml +1 -1
- threadkeeper-0.6.0/tests/test_memory_guard.py +219 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_nudges.py +66 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_shadow_review.py +55 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_hint.py +1 -1
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skills.py +66 -1
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_slim.py +17 -2
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/_setup.py +37 -5
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/brief.py +30 -2
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/candidate_reviewer.py +5 -4
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/config.py +49 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/curator.py +5 -3
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/db.py +16 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/embeddings.py +52 -18
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/extract_daemon.py +5 -3
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/identity.py +10 -0
- threadkeeper-0.6.0/threadkeeper/memory_guard.py +487 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/nudges.py +62 -1
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/review_prompts.py +4 -4
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/server.py +1 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/shadow_review.py +56 -15
- threadkeeper-0.6.0/threadkeeper/tools/memory_guard.py +127 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/skills.py +116 -41
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/spawn.py +36 -10
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/threads.py +12 -3
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/PKG-INFO +35 -16
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/SOURCES.txt +3 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/LICENSE +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/setup.cfg +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_adapters.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_brief_sections.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_candidate_reviewer.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_core_memory.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_curator.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_delegated_search.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_dialectic.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_dialectic_tier.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_error_paths.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_extract_daemon.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_i18n_multilang.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_identity.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_lessons.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_missed_spawns.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_process_health.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_tier.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_use_parser.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_skill_watcher.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_budget.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_config.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_spawn_hint.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_threads.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_tools_smoke.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_validate_threads.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/tests/test_vec_search.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/__init__.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/_mcp.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/__init__.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/base.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_code.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_desktop.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/codex.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/copilot.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/gemini.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/adapters/vscode.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/helpers.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/i18n.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/ingest.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/lessons.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/process_health.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/search_proxy.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/skill_watcher.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/spawn_budget.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/spawn_config.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/__init__.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/concepts.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/consolidate.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/core_memory.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/correlation.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/curator.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/dialectic.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/dialog.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/distill.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/extract.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/graph.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/invariants.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/lessons.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/missed_spawns.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/peers.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/pickup.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/probes.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/process_health.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/session.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/shadow_review.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/style.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper/tools/validate.py +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/entry_points.txt +0 -0
- {threadkeeper-0.5.2 → threadkeeper-0.6.0}/threadkeeper.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
227
|
-
│ skills/
|
|
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
|
-
|
|
250
|
-
`~/.
|
|
251
|
-
|
|
252
|
-
|
|
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`,
|
|
264
|
-
|
|
265
|
-
`mark_skill_materialized`.
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
193
|
-
│ skills/
|
|
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
|
-
|
|
216
|
-
`~/.
|
|
217
|
-
|
|
218
|
-
|
|
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`,
|
|
230
|
-
|
|
231
|
-
`mark_skill_materialized`.
|
|
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.
|
|
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.
|
|
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
|
|
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
|