pwn 0.5.762 → 0.5.766

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +9 -9
  3. data/documentation/Banner.md +18 -0
  4. data/documentation/Installation.md +104 -0
  5. data/documentation/Reinforcement-Learning.md +80 -0
  6. data/documentation/pwn-REPL.md +74 -0
  7. data/etc/default_cron/session_cleanup.rb +255 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +9 -2
  9. data/etc/default_skills/pwn/plugins/repl/SKILL.md +8 -0
  10. data/etc/default_skills/pwn/sessions/SKILL.md +10 -0
  11. data/lib/pwn/ai/agent/curriculum.rb +9 -2
  12. data/lib/pwn/ai/agent/learning.rb +365 -0
  13. data/lib/pwn/ai/agent/loop.rb +4 -0
  14. data/lib/pwn/ai/agent/reward.rb +12 -1
  15. data/lib/pwn/ai/agent/swarm.rb +10 -3
  16. data/lib/pwn/ai/agent/tools/learning.rb +20 -3
  17. data/lib/pwn/ai/agent/tools/sessions.rb +2 -1
  18. data/lib/pwn/ai/anthropic.rb +1 -1
  19. data/lib/pwn/ai/cli.rb +5 -2
  20. data/lib/pwn/ai/grok.rb +1 -1
  21. data/lib/pwn/ai/open_ai.rb +1 -1
  22. data/lib/pwn/ai/router.rb +56 -12
  23. data/lib/pwn/cron.rb +35 -1
  24. data/lib/pwn/migrate.rb +5 -1
  25. data/lib/pwn/plugins/repl/ai.rb +176 -31
  26. data/lib/pwn/plugins/repl/ai_console.rb +173 -16
  27. data/lib/pwn/plugins/repl/mesh.rb +73 -10
  28. data/lib/pwn/plugins/repl.rb +9 -1
  29. data/lib/pwn/sessions.rb +567 -22
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/lib/pwn/ai/agent/learning_spec.rb +229 -0
  32. data/spec/lib/pwn/ai/agent/swarm_spec.rb +10 -1
  33. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +14 -0
  34. data/spec/lib/pwn/ai/cli_spec.rb +15 -1
  35. data/spec/lib/pwn/ai/router_spec.rb +53 -0
  36. data/spec/lib/pwn/banner_spec.rb +1 -1
  37. data/spec/lib/pwn/cron_spec.rb +4 -3
  38. data/spec/lib/pwn/migrate_spec.rb +4 -4
  39. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +9 -0
  40. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +400 -7
  41. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +210 -0
  42. data/spec/lib/pwn/plugins/repl/ai_spec.rb +209 -0
  43. data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +236 -0
  44. data/spec/lib/pwn/plugins/repl_spec.rb +2 -0
  45. data/spec/lib/pwn/redaction_spec.rb +3 -2
  46. data/spec/lib/pwn/session_cleanup_default_spec.rb +72 -0
  47. data/spec/lib/pwn/session_cleanup_payload_spec.rb +593 -0
  48. data/spec/lib/pwn/sessions_spec.rb +344 -0
  49. data/third_party/pwn_rdoc.jsonl +52 -1
  50. metadata +24 -19
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20f94659af48e0dba30534ed36cfaf8942497dcc5851e346a44ef40c51abb130
4
- data.tar.gz: 8f91abcce730d25f6721d89aa6389090aa572b40ffe7449cd419a874d15d1932
3
+ metadata.gz: 48f9dad90543bf3140d52f14058c9b59c0e011daa42b8729ff4614fb01b34d60
4
+ data.tar.gz: e04116b2e14a9f728a1a583762ad9395f4858dcad190211c05988e8e5d60830b
5
5
  SHA512:
6
- metadata.gz: 8174ba5855b3cb25e41eabe9c34a57add502f6b1d52caef2809bd603e66eab5dd1a169e01e1e06feb5c745ffddaa817fbaae9748458933c35c727a5a9c18b736
7
- data.tar.gz: 111e5b05d8ad0c54f82ba0ae1836fb9ab1f8f3c1845c992d2dbacf7a241286e6ea139c9714d91e58ee80a246a119bdadf59e71d6a0f65e30fc224b96a9de2b7a
6
+ metadata.gz: e10732fcb06f209fd72fa0518241143fb9dccd448fdbf75c0d78d0da155ef0206f2bd068906e4be0b8605e1a15d73a7f1844f1a10b9f756f0cc34ff3b788495d
7
+ data.tar.gz: 734bef29b1fb747424b676a0277095175b37816b6b7ca272dd3ff720ec36872ab69a7700afc10f74ba6542c7855282a97666ae6127e1bf8ccc66c78b8a5c3b1c
data/Gemfile CHANGED
@@ -50,19 +50,19 @@ gem 'libusb', '0.8.0'
50
50
  gem 'luhn', '3.0.0'
51
51
  gem 'mail', '2.9.1'
52
52
  gem 'mcp', '1.7.0'
53
- gem 'meshtastic', '0.0.186'
53
+ gem 'meshtastic', '0.0.187'
54
54
  gem 'metasm', '1.0.6'
55
55
  gem 'mongo', '2.26.0'
56
56
  gem 'msfrpc-client', '1.1.2'
57
57
  gem 'netaddr', '2.0.6'
58
- gem 'net-ldap', '0.20.0'
58
+ gem 'net-ldap', '0.21.0'
59
59
  gem 'net-openvpn', '0.8.7'
60
60
  gem 'net-smtp', '0.5.2'
61
61
  gem 'nexpose', '7.3.0'
62
62
  gem 'nokogiri', '1.19.4'
63
63
  gem 'nokogiri-diff', '0.3.0'
64
64
  gem 'oily_png', '1.2.1'
65
- gem 'open3', '0.2.1'
65
+ gem 'open3', '0.3.0'
66
66
  gem 'os', '1.1.4'
67
67
  gem 'ostruct', '0.6.3'
68
68
  gem 'packetfu', '2.0.0'
@@ -73,15 +73,15 @@ gem 'pry', '0.16.0'
73
73
  gem 'pry-doc', '1.7.0'
74
74
  gem 'rake', '13.4.2'
75
75
  gem 'rb-readline', '0.5.5'
76
- gem 'rbvmomi2', '3.10.0'
76
+ gem 'rbvmomi2', '3.11.0'
77
77
  gem 'rdoc', '7.0.4'
78
78
  gem 'rest-client', '2.1.0'
79
79
  gem 'rex', '2.0.13'
80
- gem 'rmagick', '7.1.6'
80
+ gem 'rmagick', '7.2.0'
81
81
  gem 'rqrcode', '3.2.0'
82
82
  gem 'rspec', '3.13.2'
83
83
  gem 'rtesseract', '3.1.4'
84
- gem 'rubocop', '1.91.0'
84
+ gem 'rubocop', '1.92.0'
85
85
  gem 'rubocop-rake', '0.7.1'
86
86
  gem 'rubocop-rspec', '3.10.2'
87
87
  gem 'ruby-audio', '1.6.1'
@@ -89,9 +89,9 @@ gem 'ruby-nmap', '1.0.3'
89
89
  gem 'ruby-saml', '1.18.1'
90
90
  gem 'rvm', '1.11.3.9'
91
91
  gem 'savon', '2.17.5'
92
- gem 'selenium-devtools', '0.154.0'
93
- gem 'selenium-webdriver', '4.50.0'
94
- gem 'slack-ruby-client', '3.2.0'
92
+ gem 'selenium-devtools', '0.155.0'
93
+ gem 'selenium-webdriver', '4.51.0'
94
+ gem 'slack-ruby-client', '3.3.0'
95
95
  gem 'socksify', '1.8.1'
96
96
  gem 'spreadsheet', '1.3.5'
97
97
  gem 'sqlite3', '2.9.6'
@@ -14,4 +14,22 @@ welcome-banner # REPL command: redraw a random one
14
14
 
15
15
  Add your own: drop a module in `lib/pwn/banner/` implementing `self.get`.
16
16
 
17
+ ## pwn-ai startup pane
18
+
19
+ The curses console starts with a white miniature derived directly from
20
+ `PWN::Banner::WhiteRabbit.get`, without its dedication or wordmark. The full
21
+ banner remains unchanged. The rabbit is centered and aspect-contained in the
22
+ current pane: small panes use area-filtered 3×6 character strokes packed into
23
+ ordinary 2×4 Braille text dots; when the original ASCII fits, it is displayed
24
+ verbatim, centered without enlargement. This assumes typical 1:2 terminal cells.
25
+ Small panes lose punctuation detail, and Braille dot spacing depends on the font;
26
+ physically tiny panes cannot retain a recognizable rabbit. No image protocol,
27
+ image file, graphics probe or runtime image library is used.
28
+
29
+ No games or replays are computed until the animation pane first receives focus
30
+ via Ctrl+T/Ctrl+X. That enables games for the console lifetime. Ctrl+G cycles
31
+ games while focused; leaving the pane keeps the existing automatic animation,
32
+ not the startup rabbit. Resizing, modal input priority and the mission draft
33
+ retain their existing behavior. Monochrome terminals keep the same geometry.
34
+
17
35
  [← Home](Home.md)
@@ -202,6 +202,110 @@ without a per-job crontab line. Live Policy files (`policy.json`,
202
202
  `policy_traj.jsonl`) are created on first agent turn when `ai.agent.policy`
203
203
  is on.
204
204
 
205
+ Schema `v8` seeds the enabled `session_cleanup` hygiene job, daily at 03:45
206
+ in the scheduler's local timezone. First launch also seeds it through
207
+ `PWN::Cron.install_defaults`. The bundled `etc/default_cron/session_cleanup.rb`
208
+ is installed to `~/.pwn/cron/session_cleanup.rb`; managed copies refresh on
209
+ subsequent launches/migrations, while unmarked custom helpers are preserved.
210
+ An existing job named `session_cleanup` keeps its ID, schedule, enabled state,
211
+ payload and native scheduler drop-ins. New jobs defer cleanup until their first
212
+ scheduled time, rather than deleting during initial catch-up.
213
+
214
+ Cleanup calls a session named only when its persisted metadata has
215
+ `title_source: summary`, a nonblank title and summary, and a positive integer
216
+ `summary_turns` cursor: the fields published after validated LLM title generation.
217
+ Provisional first-request titles, manual titles, persona labels, aliases and
218
+ historical embedded titles are not evidence of LLM summarization. Legacy records
219
+ without provenance are unsummarized with `missing_summary_provenance`; invalid or
220
+ incomplete metadata is unknown, never named. `/sessions list` and cleanup share
221
+ `title_source`, `is_summarized`, `classification` and `title_reason`. Classification
222
+ does not establish deletion safety: malformed records, symlinks and uncertain pin
223
+ stores remain protected. Age alone does not preserve an unsummarized session.
224
+ Version 5 backs up unreferenced unsummarized sessions before a guarded `Sessions.delete`
225
+ call that bypasses the ordinary age pin. Current sessions and every known
226
+ learning/mistake/memory reference remain protected. `/sessions list` exposes
227
+ `dependencies` as an array of files **referencing this session** (empty when
228
+ none), not files this session depends on. `dependency_errors` lists unknown,
229
+ malformed or unsafe stores; cleanup fails closed when it is nonempty.
230
+ The shared index follows defined structural fields, not ID-looking text.
231
+ Dependency-backed sessions remain visible and resumable, not archived away.
232
+ Vault/config/credential payloads and historical backups are not scanned.
233
+ Same-user interactive PWN
234
+ processes defer deletion because existing writers have no lifetime leases; this is a
235
+ conservative guard, not a cross-process session lease. Inspect without deleting:
236
+ `ruby ~/.pwn/cron/session_cleanup.rb --dry-run`.
237
+
238
+ `--apply` backs up eligible transcript/metadata bytes under
239
+ `~/.pwn/session_cleanup_archive/<run>/` (directories 0700, files 0600), outside
240
+ `/sessions list`. Atomic, fsynced backups are SHA-256 verified before deletion;
241
+ file identities/hashes, title classification, reference stores and process state
242
+ are rechecked under per-ID/transcript locks. `ledger.json` records exact IDs,
243
+ hashes, skips and completed deletions; dry-run JSON contains metadata only and
244
+ creates no archive. Backups are for recovery, not an automatic reference
245
+ resolver: referenced placeholders remain in the active list until a separately
246
+ verified archive-aware lookup policy exists. No memory, learning or mistake
247
+ records are rewritten. The process checks and advisory locks do not claim a
248
+ transactional guarantee against arbitrary non-cooperating external writers.
249
+
250
+ The cleanup-specific pins additionally preserve every session ID referenced in
251
+ retained learning, mistake and durable memory stores, including non-gold learning
252
+ and old resolved mistakes. References are schema-aware:
253
+
254
+ | Store / writer | Session reference |
255
+ | --- | --- |
256
+ | `learning.jsonl` / Learning; `policy_traj.jsonl` / Policy; `open_goal.json` / OpenGoal | Top-level `session_id` |
257
+ | `mistakes.json` / Mistakes | Each signature record's `sessions[]` and `verified_sessions[]`; signature keys are not session IDs |
258
+ | `memory.json`, `memory.archive.json` / Learning.reflect, Memory | Exact `reflect_<session ID>_<8-hex lesson digest>` keys; arbitrary memory keys, values and source strings are not links |
259
+ | `jobs/<12-hex job ID>.json` / Jobs | Top-level `session_id`; command, environment and job ID are not links |
260
+ | `swarm/*/personas.json` / Swarm | Persona-to-session map values, not persona names or swarm IDs |
261
+ | `curriculum/log.jsonl` / Curriculum | `practice` event's `data[].runs[].session_id`; other events' prose is not traversed |
262
+ | `extrospection.json` / Extrospection | `auto_extrospect` observations' second tag in `['drift', session ID]`; manual/other tags and observation data are not links |
263
+ | `engagements/*/facts.jsonl` / Swarm | Top-level `source_session` |
264
+ | `findings.jsonl` / Findings | Top-level `session_id`; exact transcript/sidecar paths in `evidence_paths[]` and `poc_artifacts[]` |
265
+ | Engagement evidence and recon ledgers / Findings, Recon | Exact transcript/sidecar paths in `path` or `evidence_path`; includes the legacy root `engagements/evidence.jsonl` |
266
+ | `artifacts/manifest.jsonl` / ArtifactRegistry | Top-level `session` and exact transcript/sidecar `source_path`; `kind: swarm-bus` uses `session` for a swarm ID, not session provenance |
267
+ | `artifacts/<session ID>/index.jsonl`, `engagement-memory.json` | Session ownership from that exact directory; index `src` may also reference a transcript/sidecar |
268
+ | `embeddings/<session ID>.db`, `sessions/<session ID>/trace.jsonl` | Ownership from that exact filename/directory, not database contents or trace tool payloads |
269
+
270
+ IDs must be complete canonical values, not substrings or whitespace-normalized
271
+ tokens. Only known evidence/source path fields pointing directly into the
272
+ configured sessions directory are path references. There is currently no
273
+ cross-session link field in transcript or title metadata schemas: listing,
274
+ recalling, quoting JSON, or mentioning a session never pins it, including when
275
+ `sessions_list` output is saved repeatedly. Self listings do not create pins.
276
+
277
+ The index also validates the JSON containers in policy, preference, sentinel,
278
+ skill-review and KPI stores, run checkpoints, mission ledgers, swarm bus/solve
279
+ records, curriculum model/cooldown maps, engagement descriptors/hosts/scans and
280
+ binary facts. These writers do **not** persist session ownership links; their
281
+ arbitrary payloads, prompts, run/job IDs and user-selected labels must not be
282
+ interpreted as such. Unknown JSON stores within the indexed directories are
283
+ reported as uncertainty rather than silently ignored. Adding a new reference
284
+ schema requires auditing its writer/callers and adding positive and negative
285
+ fixtures, not a recursive key-name or text search. Vaults, loot payloads, arbitrary
286
+ artifact content and backups remain outside this index.
287
+
288
+ Native scalar tool `step_reward` fields are recognized;
289
+ unknown transcript fields remain protected.
290
+
291
+ `/sessions list` reads all transcript files afresh, not just cleanup candidates.
292
+ In cleanup output, `unnamed` includes protected sessions; `candidate_ids` excludes
293
+ them. Active sessions hold shared OS locks on permanent per-ID cleanup lock
294
+ files; cleanup takes an exclusive nonblocking lock on the same inode before
295
+ rechecking, backing up and deleting. An active A does not retain inactive B.
296
+ CLI/REPL activation and resume transfer ownership, exit releases it, nested
297
+ Loop requests and title writers hold independent leases, and crashes release
298
+ descriptors automatically. Stale lock contents or reused PIDs confer no ownership.
299
+ Never unlink lock files: that would split the synchronization inode.
300
+
301
+ Old installed processes cannot be upgraded by refreshing the cron helper alone.
302
+ `legacy_owners` reports launchers without the held PID/start-identity capability
303
+ marker; `unknown_legacy_ownership` means their in-memory session IDs are unknown,
304
+ not that every session is active. Ask the operator to identify/quiesce those
305
+ writers and restart using the updated source or deployed version before deleting
306
+ ambiguous candidates. Do not infer active IDs from mtimes, titles or process-name
307
+ matches, and do not bypass reference/summary guards. No config migration is needed.
308
+
205
309
  From a checkout:
206
310
 
207
311
  ```bash
@@ -1,5 +1,85 @@
1
1
  # Reinforcement Learning in pwn-ai
2
2
 
3
+ ## Retained learning evidence and session cleanup
4
+
5
+ Learning outcomes can retain a versioned `evidence_ref` (`version`, `id`,
6
+ `sha256`) pointing to `~/.pwn/learning/evidence/<sha256>.json`. The existing
7
+ `session_id` remains provenance. It ceases to pin a source transcript only
8
+ after the artifact is readable, hash-valid, schema-valid, and its complete
9
+ redacted transcript matches the unchanged source. A marker alone is not enough.
10
+ Missing, modified, malformed or conflicting evidence does not authorize cleanup.
11
+ Other stores, including mistakes, reflection memory and disputed learning,
12
+ continue to pin their source sessions. Evidence files are never session-cleanup
13
+ candidates; the dependency index exposes their referenced-by edges separately.
14
+
15
+ No reliable turn-to-outcome boundary exists in legacy learning rows. Migration
16
+ therefore preserves **every persisted transcript row**, including requests,
17
+ tool actions, observations, results, timestamps and extra fields, with source
18
+ session provenance and a SHA-256 of the original bytes. It uses the existing
19
+ credential-pattern redactor, not a model or Vault, and does not summarize.
20
+ Already compacted source content cannot be reconstructed; external artifact
21
+ references stay references, and those artifacts must still be retained.
22
+
23
+ Use the existing `learning_gc_stores` tool with
24
+ `{"operation":"evidence_plan","dry_run":true}` for a read-only count/size
25
+ report. This operation does not invoke store GC. The equivalent Ruby API in
26
+ the existing `pwn` console is:
27
+
28
+ ```ruby
29
+ PWN::AI::Agent::Learning.migrate_evidence(dry_run: true)
30
+ ```
31
+
32
+ After reviewing the plan and explicitly approving writes, stop old installed
33
+ PWN processes, scheduled maintenance and external editors first. Deploy the
34
+ matching source before restarting: legacy installed writers do not participate
35
+ in the new lock. Then use `learning_gc_stores` with
36
+ `{"operation":"evidence_apply","dry_run":false}`, or
37
+ `PWN::AI::Agent::Learning.migrate_evidence(dry_run: false)`.
38
+ **Neither operation deletes sessions.** Review a separate session-cleanup
39
+ dry run afterward; named/active/uncertain sessions remain protected by that
40
+ cleanup policy. Do not infer session-count reduction from an evidence migration.
41
+
42
+ Artifacts deduplicate by content hash. Files use 0600 and new directories 0700.
43
+ Atomic, fsynced writes/readback precede the learning-index replacement. Original
44
+ learning bytes and prepared/committed ledgers live under
45
+ `~/.pwn/learning/migrations/`, outside legacy backup pruning. Core learning
46
+ writers share a reentrant advisory lock; optimistic byte checks additionally
47
+ reject observed source/index changes. These locks cannot coordinate arbitrary
48
+ external editors or old installed versions: apply requires a quiescent store.
49
+ Interrupted precommit runs leave original references intact and can be retried;
50
+ an interruption after index replacement can leave a prepared ledger with a
51
+ fully usable verified index. No partial artifact or staging file releases a pin.
52
+
53
+ Before any source deletion, an unchanged migration can be rolled back with
54
+ `PWN::AI::Agent::Learning.rollback_evidence(migration: '<ledger SHA-256 ID>')`,
55
+ or tool operation `evidence_rollback` with the same `migration` field. Rollback
56
+ verifies backup/index hashes and refuses later edits or absent/changed sources;
57
+ it never overwrites concurrent learning. Keep recovery files private. They
58
+ retain the exact original learning bytes, which can include legacy sensitive
59
+ data. This is not an evidence garbage collector or a credential-store migration.
60
+
61
+ Consumer audit: `Sessions.load` supplies verified evidence only when the legacy
62
+ source is absent, keeping all existing ID-only consumers compatible. This covers
63
+ `Learning` export_finetune, reflect, fold_judge_into_metrics,
64
+ recover_predicted_from_session, infer_success, compress_exemplar,
65
+ build_skill_from_session and requeue_conflicted; `Reward` run_verification,
66
+ record_verification, request_verification and load_trace; `Curriculum`
67
+ offline_judge and self_play; `Mistakes` previous_user/previous_assistant;
68
+ `Memory.session_turns`; `tools/sessions.rb`; and REPL session-goal lookup.
69
+ `Sessions.export` now uses that resolver too. `Curriculum.offline_judge` also
70
+ discovers retained evidence outcomes after directory cleanup (outcome timestamp
71
+ is its archived-session age). Invalid evidence raises at the resolver rather
72
+ than masquerading as an empty transcript; existing consumer error/drop reporting
73
+ continues to apply, with a metadata-only diagnostic when evidence cannot resolve.
74
+ Reward's PRM annotation writer uses `Learning.annotate_evidence` when the source
75
+ is absent: integer step rewards create a new content-hashed snapshot and replace
76
+ references under the same backup/ledger/lock protocol. Supporting content and the
77
+ old artifact remain unchanged; no session is recreated. Annotation rollback can
78
+ restore verified prior evidence references even after original-session cleanup.
79
+ DPO export already consumes self-contained preference
80
+ pairs, not session files. New module/autoload/gemspec entries are unnecessary:
81
+ the implementation extends existing paired modules.
82
+
3
83
  pwn-ai learns while you work. Most of that learning stays **in context**: the
4
84
  agent writes what happened to disk and puts the useful bits back into the next
5
85
  prompt. On a host **with a trainer and a GPU**, the same data can also train a
@@ -16,6 +16,46 @@ commands.
16
16
 
17
17
  ## Custom commands
18
18
 
19
+ ### pwn-ai session titles
20
+
21
+ `/sessions list` shows stable IDs and titles of at most seven
22
+ whitespace-separated words, matching the SESSION pane. The first request seeds
23
+ a provisional title; the currently active LLM provider and selected model produce a concise conversation title
24
+ after completed turns, with a deterministic seven-word fallback cap. The limit
25
+ also applies to explicit titles. It does not shorten requests or apply to the
26
+ rolling conversation summary.
27
+
28
+ Title generation runs asynchronously after completed turns, never during drawing
29
+ or keystrokes. It uses the previous overall summary plus new conversation turns,
30
+ without changing the active provider or falling back to a different model. Missing
31
+ credentials or failed/invalid responses retain the prior or provisional title;
32
+ background title work never starts interactive authentication. Artifact summaries
33
+ retain their separate local-only routing policy.
34
+
35
+ Existing overlong titles are normalized when read, without rewriting transcripts
36
+ or metadata merely to list them. `/sessions resume <title-or-id>`
37
+ accepts the exact displayed title, optionally quoted. Internal quotes
38
+ and Unicode whitespace are supported. If shortened titles collide, use the
39
+ stable ID shown in the ambiguity message; IDs and transcript filenames do not
40
+ change.
41
+ Previously displayed titles remain resume aliases when a background summary
42
+ renames the session, so a name copied from the list does not expire while typing.
43
+ Aliases shared by multiple sessions are ambiguous too; use a stable ID.
44
+
45
+ `dependencies` means **referenced by**: files containing defined structural
46
+ references to that session. Transcript prose, prompts, tool results and quoted
47
+ JSON—including previous `/sessions list` output—are not dependencies. An empty
48
+ array means no known structural references; `dependency_errors` separately
49
+ reports uncertainty and blocks scheduled cleanup. Dependency-backed sessions
50
+ stay visible and resumable. See Installation's session-cleanup schema table.
51
+ `title_source`, `is_summarized`, `classification` and `title_reason` expose the
52
+ same strict provenance used by cleanup. Only persisted `summary` provenance with
53
+ a nonblank summary/title and positive integer summary cursor counts as named.
54
+ Provisional, manual, persona, historical and alias titles do not prove LLM work;
55
+ legacy records explicitly report missing provenance. Unknown/malformed metadata
56
+ is protected without being called summarized. Neither listing nor cleanup calls
57
+ the LLM to manufacture provenance.
58
+
19
59
  | Command | Implemented in | Purpose |
20
60
  |---|---|---|
21
61
  | `pwn-ai` | `Agent::Loop` | Enter the AI agent TUI |
@@ -60,6 +100,40 @@ History includes messages and slash commands, skips blank lines and consecutive
60
100
  duplicates, and keeps the latest 100 entries in memory for the current pwn-mesh
61
101
  session only; it is not written to disk.
62
102
 
103
+ ### Message transport labels
104
+
105
+ Each CONVERSATION header ends with its direction and the transport used for
106
+ that individual message: `· RX via MQTT`, `· TX via Serial`, `· RX via Bluetooth`,
107
+ or `· TX via TCP`. In `auto` mode this is the resolved connection, not the
108
+ configured `auto` value. Received messages retain the subscribing transport's
109
+ origin through queued rendering, even if the current transport changes.
110
+ Missing origin metadata is shown as `Unknown`, not guessed from the current pin.
111
+
112
+ Headers use `timestamp encryption · SHORT NAME · source path · destination path · direction via transport`,
113
+ with exactly one space on either side of each `·`. RX uses the sender's
114
+ `User.short_name` (`shortName` in JSON); TX uses the sending local node's User,
115
+ never the destination's name. Device connections use their NodeInfo/NODEINFO
116
+ inventory. MQTT learns NODEINFO announcements in a bounded, connection-local
117
+ inventory (up to 1000 users), including the sending identity if advertised.
118
+ Names are captured with each message, so later inventory or binding changes do
119
+ not rename queued messages. Missing or blank short names omit that field and
120
+ its separator; long names, node-ID fragments and gateway names are not substitutes.
121
+ ANSI CSI sequences are removed, control/newline characters and literal `·` in
122
+ names become spaces, and surrounding Unicode whitespace is trimmed. Other
123
+ Unicode text and internal spaces are retained. Names are labels, not authenticated identities.
124
+
125
+ For example (placeholder node IDs):
126
+
127
+ ```text
128
+ 2026-01-02 03:04:05+0000 🔍 · PEER · US/CA/2/e/LongFast/!aabbccdd · US/CA/2/e/LongFast/# · RX via MQTT
129
+ 2026-01-02 03:04:06+0000 🔍 · SELF · US/CA/2/e/LongFast/!bbccddee (ME) · US/CA/2/e/LongFast/# · TX via Serial
130
+ ```
131
+
132
+ The timestamp, encryption indicator, source/destination paths, channel and
133
+ `(ME)` marker keep their existing meaning. The suffix identifies the local
134
+ client connection, not every RF or broker hop. A TX header means local transport
135
+ submission; it does not confirm over-the-air delivery or peer receipt.
136
+
63
137
  ### Encrypted DMs and key discovery
64
138
 
65
139
  On serial, Bluetooth, or TCP, a DM checks the destination's public key in the
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ # PWN managed default: session_cleanup v6
4
+ # No provider calls or broad store GC. Unsummarized candidates are backed up first.
5
+ require 'pwn'
6
+ require 'json'
7
+ require 'fileutils'
8
+ require 'time'
9
+ require 'digest'
10
+ require 'securerandom'
11
+
12
+ module PWN
13
+ # Provenance-based session cleanup, shared by seeded cron payloads.
14
+ module SessionCleanup
15
+ VERSION = 6
16
+
17
+ def self.classify(path, id)
18
+ return :unknown unless path == File.join(PWN::Sessions.sessions_dir, "#{id}.jsonl")
19
+
20
+ PWN::Sessions.title_status(session_id: id)[:classification]
21
+ end
22
+
23
+ # Compatibility discovery only: upgraded launchers never block unrelated
24
+ # sessions. Legacy in-memory IDs cannot safely be inferred from recent mtimes.
25
+ def self.legacy_owners(proc_root)
26
+ return [{ error: 'process_inventory_unavailable' }] unless File.directory?(proc_root)
27
+
28
+ Dir.glob(File.join(proc_root, '[0-9]*')).filter_map do |dir|
29
+ next if File.basename(dir).to_i == Process.pid
30
+ next unless File.stat(dir).uid == Process.uid
31
+
32
+ argv = File.binread(File.join(dir, 'cmdline')).split("\0")
33
+ next unless pwn_launcher?(argv, dir)
34
+ next if tracked_owner?(dir)
35
+
36
+ { pid: File.basename(dir).to_i, reason: 'legacy_session_ownership_unknown' }
37
+ rescue Errno::ENOENT, Errno::ESRCH
38
+ nil
39
+ rescue IOError, SystemCallError
40
+ { pid: File.basename(dir).to_i, reason: 'ownership_unreadable' }
41
+ end
42
+ rescue IOError, SystemCallError
43
+ [{ error: 'process_inventory_unreadable' }]
44
+ end
45
+
46
+ def self.pwn_launcher?(argv, dir)
47
+ executable = %r{\A(?:[^\s]*/)?pwn(?:-[a-zA-Z0-9_-]+)?\z}
48
+ return true if argv.first.to_s.match?(executable)
49
+ return false unless argv.first.to_s.match?(%r{\A(?:[^\s]*/)?ruby[\d.]*(?:\s|\z)})
50
+ return false unless File.basename(File.readlink(File.join(dir, 'exe'))).match?(/\Aruby[\d.]*\z/)
51
+ return true if argv.first.to_s.match?(%r{\A\S*ruby[\d.]*\s+(?:[^\s]*/)?pwn(?:-[a-zA-Z0-9_-]+)?\z})
52
+
53
+ args = argv.drop(1)
54
+ while args.first&.start_with?('-')
55
+ option = args.shift
56
+ break if option == '--'
57
+ break args.clear if option.start_with?('-e')
58
+
59
+ args.shift if %w[-I -r -C -E -F].include?(option)
60
+ end
61
+ args.first.to_s.match?(executable)
62
+ end
63
+
64
+ def self.tracked_owner?(dir)
65
+ return false unless File.file?(File.join(dir, 'stat'))
66
+
67
+ start = File.read(File.join(dir, 'stat')).split(') ').last.split[19]
68
+ # fdinfo proves this process holds the locked open-file description, not
69
+ # merely an unlocked descriptor to another process's marker. Forked tool
70
+ # workers inherit that capability and the precise session leases; they
71
+ # must not be mistaken for untracked legacy launchers. A reused PID alone
72
+ # has neither descriptor. Marker filenames bind the original PID/start.
73
+ tracked = Dir.glob(File.join(dir, 'fd', '*')).any? do |fd|
74
+ marker = File.readlink(fd)
75
+ next false unless File.basename(marker).match?(/\A\.activity-owner-\d+-\d+\.lock\z/)
76
+
77
+ info = File.read(File.join(dir, 'fdinfo', File.basename(fd)))
78
+ info.match?(/^lock:\s+\d+:\s+FLOCK\s+ADVISORY\s+WRITE\s/)
79
+ rescue Errno::ENOENT
80
+ false
81
+ end
82
+ tracked && File.read(File.join(dir, 'stat')).split(') ').last.split[19] == start
83
+ end
84
+
85
+ def self.pins_known?
86
+ referenced_session_ids
87
+ true
88
+ rescue StandardError
89
+ false
90
+ end
91
+
92
+ # Cleanup preserves evidence referenced by all retained learning, mistake
93
+ # and durable memory records, not just the ordinary gold/open/hot pins.
94
+ def self.referenced_session_ids
95
+ references = PWN::Sessions.dependency_index
96
+ raise 'Uncertain dependency stores' unless references[:errors].empty?
97
+
98
+ references[:dependencies].keys
99
+ end
100
+
101
+ # Fingerprints include inode identity and nanosecond timestamps, not bodies.
102
+ def self.snapshot(path)
103
+ [path, path.sub(/\.jsonl\z/, '.meta.json')].to_h do |file|
104
+ raise 'Refusing symlink' if File.symlink?(file)
105
+ next [File.basename(file), nil] unless File.exist?(file)
106
+
107
+ stat = File.stat(file)
108
+ raise 'Not a regular file' unless stat.file?
109
+
110
+ [File.basename(file), { sha256: Digest::SHA256.file(file).hexdigest, size: stat.size, inode: stat.ino, device: stat.dev, mtime: stat.mtime.to_r.to_s, ctime: stat.ctime.to_r.to_s }]
111
+ end
112
+ end
113
+
114
+ def self.publish(path, bytes)
115
+ temporary = "#{path}.#{SecureRandom.hex(8)}.tmp"
116
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
117
+ file.write(bytes)
118
+ file.flush
119
+ file.fsync
120
+ end
121
+ File.rename(temporary, path)
122
+ File.open(File.dirname(path), File::RDONLY, &:fsync)
123
+ ensure
124
+ File.unlink(temporary) if temporary && File.exist?(temporary)
125
+ end
126
+
127
+ def self.backup(path, expected, result)
128
+ unless result[:archive_dir]
129
+ base = File.join(Dir.home, '.pwn', 'session_cleanup_archive')
130
+ raise 'Unsafe archive directory' if File.symlink?(base)
131
+
132
+ FileUtils.mkdir_p(base, mode: 0o700)
133
+ raise 'Unsafe archive permissions' unless File.stat(base).uid == Process.uid && (File.stat(base).mode & 0o777) == 0o700
134
+
135
+ result[:archive_dir] = File.join(base, "#{Time.now.utc.strftime('%Y%m%dT%H%M%S')}-#{SecureRandom.hex(8)}")
136
+ Dir.mkdir(result[:archive_dir], 0o700)
137
+ end
138
+ expected.each do |name, record|
139
+ next unless record
140
+
141
+ source = File.join(File.dirname(path), name)
142
+ bytes = File.binread(source)
143
+ raise 'Changed before backup' unless Digest::SHA256.hexdigest(bytes) == record[:sha256]
144
+
145
+ target = File.join(result[:archive_dir], name)
146
+ publish(target, bytes)
147
+ raise 'Backup verification failed' unless Digest::SHA256.file(target).hexdigest == record[:sha256]
148
+ end
149
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
150
+ end
151
+
152
+ def self.run(dry_run: true, current_session_id: nil, proc_root: '/proc')
153
+ raise 'Session cleanup requires the updated Sessions activity API; load the updated source or deploy it before running this helper' unless PWN::Sessions.respond_to?(:activate)
154
+
155
+ root = PWN::Sessions.sessions_dir
156
+ raise 'Refusing symlinked session directory' if File.symlink?(root)
157
+
158
+ current = current_session_id || (PWN::Env.dig(:ai, :session_id) if defined?(PWN::Env))
159
+ legacy = legacy_owners(proc_root)
160
+ result = { version: VERSION, dry_run: dry_run, scanned: 0, unnamed: 0, candidate_ids: [], deleted_ids: [], records: [], skipped: Hash.new(0), legacy_owners: legacy }
161
+ unless pins_known?
162
+ result[:skipped][:unknown_pins] = 1
163
+ result[:dependency_errors] = PWN::Sessions.dependency_index[:errors]
164
+ return result
165
+ end
166
+ dependencies = PWN::Sessions.dependency_index[:dependencies]
167
+ pins = referenced_session_ids
168
+ Dir.glob(File.join(root, '*.jsonl')).each do |path|
169
+ id = File.basename(path, '.jsonl')
170
+ result[:scanned] += 1
171
+ state = classify(path, id)
172
+ record = PWN::Sessions.title_status(session_id: id).merge(id: id, classification: state, dependencies: dependencies.fetch(id, []))
173
+ result[:records] << record
174
+ unless state == :unnamed
175
+ result[:skipped][state] += 1
176
+ next
177
+ end
178
+ expected = snapshot(path)
179
+ record[:files] = expected
180
+ result[:unnamed] += 1
181
+ if PWN::Sessions.active?(session_id: id)
182
+ record[:skip] = :active
183
+ result[:skipped][:active] += 1
184
+ next
185
+ end
186
+ if id == current || pins.include?(id)
187
+ reason = id == current ? :current : :referenced
188
+ record[:skip] = reason
189
+ result[:skipped][reason] += 1
190
+ next
191
+ end
192
+ result[:candidate_ids] << id
193
+ record[:apply_blocked_by] = :unknown_legacy_ownership unless legacy.empty?
194
+ next if dry_run
195
+
196
+ unless legacy.empty? && legacy_owners(proc_root).empty?
197
+ record[:skip] = :unknown_legacy_ownership
198
+ result[:skipped][:unknown_legacy_ownership] += 1
199
+ next
200
+ end
201
+ # Never unlink lock files: waiters must keep sharing the same inode.
202
+ lock_path = File.join(root, ".#{id}.cleanup.lock")
203
+ raise 'Refusing symlinked cleanup lock' if File.symlink?(lock_path)
204
+
205
+ File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
206
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
207
+ result[:skipped][:locked] += 1
208
+ record[:skip] = :locked
209
+ next
210
+ end
211
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |transcript|
212
+ unless transcript.flock(File::LOCK_EX | File::LOCK_NB)
213
+ result[:skipped][:locked] += 1
214
+ record[:skip] = :locked
215
+ next
216
+ end
217
+ # Activation and deletion serialize on the same per-session inode.
218
+ # Legacy ownership uncertainty still requires operator migration.
219
+ unless File.identical?(transcript, path) && classify(path, id) == :unnamed && snapshot(path) == expected && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
220
+ result[:skipped][:changed] += 1
221
+ record[:skip] = :changed
222
+ next
223
+ end
224
+ backup(path, expected, result)
225
+ unless snapshot(path) == expected && classify(path, id) == :unnamed && legacy_owners(proc_root).empty? && pins_known? && !referenced_session_ids.include?(id)
226
+ result[:skipped][:changed] += 1
227
+ record[:skip] = :changed
228
+ next
229
+ end
230
+ # Only verified, archived, unreferenced unsummarized sessions;
231
+ # current and all known reference pins checked above.
232
+ # The normal delete API's age pin is deliberately bypassed here.
233
+ PWN::Sessions.delete(session_id: id, current_session_id: current, force: true, cleanup_lock: lock)
234
+ raise 'Deletion did not remove both session records' if File.exist?(path) || File.exist?(path.sub(/\.jsonl\z/, '.meta.json'))
235
+
236
+ result[:deleted_ids] << id
237
+ record[:deleted] = true
238
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result))
239
+ end
240
+ end
241
+ rescue StandardError => e
242
+ result[:skipped][:error] += 1
243
+ record[:error] = e.class.name if record
244
+ end
245
+ publish(File.join(result[:archive_dir], 'ledger.json'), JSON.pretty_generate(result)) if result[:archive_dir]
246
+ result
247
+ end
248
+ end
249
+ end
250
+
251
+ if $PROGRAM_NAME == __FILE__
252
+ raise 'Use --dry-run or --apply' unless ARGV.length == 1 && %w[--dry-run --apply].include?(ARGV.first)
253
+
254
+ puts JSON.generate(PWN::SessionCleanup.run(dry_run: ARGV.first == '--dry-run'))
255
+ end
@@ -28,11 +28,17 @@ Class methods take `(opts = {})` and read `opts`.
28
28
 
29
29
  ```ruby
30
30
  PWN::AI::Agent::Learning.help
31
- PWN::AI::Agent::Learning.note_outcome(opts)
31
+ PWN::AI::Agent::Learning.evidence_transcript(opts)
32
32
  ```
33
33
 
34
34
  ## Public methods
35
35
 
36
+ - `evidence_transcript`
37
+ - `session_evidence`
38
+ - `evidence_complete`
39
+ - `migrate_evidence`
40
+ - `annotate_evidence`
41
+ - `rollback_evidence`
36
42
  - `note_outcome`
37
43
  - `consistency_check`
38
44
  - `disputed_save`
@@ -63,6 +69,7 @@ PWN::AI::Agent::Learning.note_outcome(opts)
63
69
  - `authors`
64
70
  - `help`
65
71
  - `compact!`
72
+ - `evidence_complete?`
66
73
  - `gc_stores!`
67
74
  - `lean!`
68
75
  - `prune_outcomes!`
@@ -74,5 +81,5 @@ PWN::AI::Agent::Learning.note_outcome(opts)
74
81
 
75
82
  ## Verification
76
83
 
77
- `PWN::AI::Agent::Learning.respond_to?(:note_outcome)` after the
84
+ `PWN::AI::Agent::Learning.respond_to?(:evidence_transcript)` after the
78
85
  module is loaded. Read the source for parameter names.