kairos-chain 3.29.6 → 3.30.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fde5a098059d86d9dc9f10cbf27911ff0f6e3defede0c118ac36825964e4d55
4
- data.tar.gz: a5b4283909071dba96fa4919a59ef29881f084f76a8fd8cc3335fcbb9efdb8d9
3
+ metadata.gz: 3ec94fadb095a49a89c0235010c80e37913aaf6781aa16d22a2e5855b1eb154d
4
+ data.tar.gz: 38c3b631326b87f07285f9a5f4ecdd64fc22d086b6b08de76d5b2aaf2d7d7040
5
5
  SHA512:
6
- metadata.gz: a863d1090ee8f1bffaf6a47848d01f113983d2b2efefd7d7e1e6ecec89105fbdde85851681f163be8d93d79734d6bfaf23dfa0080436ec9a15e0e2e258db2d52
7
- data.tar.gz: 93690b91d30df69e0dc634b135dc3492a436089e6af02906ed4cfaf22e8fb42f5e174fa500dfb1fc906096b3b6fbb275cf043399042a642f9847a28a471147b8
6
+ metadata.gz: 7d3d7e9d5f52b58109a795ce4f67243551ad353d18d273d4062034e09bcafe8201559ceabcef16c5ad0d0fc0bca4b8b8629c27cb49e224091ae05ffd5aa364ac
7
+ data.tar.gz: a20e8e7a2cb18d8cbcbd76f39c7fa69a9bbd55d4447c16fa289ca8227fd2537325693748e711e46e57693c6572b41f15096dc358368f89911d95eaea3c714ca8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.30.0] - 2026-06-08
8
+
9
+ ### Added — `dream_digest`: derived narrative view over L2/L1 fragments (dream SkillSet v0.3.0)
10
+
11
+ A new `dream_digest` tool in the `dream` SkillSet, providing `narrative_digest`.
12
+ Inspired by OpenAI ChatGPT "Dreaming" memory, but inverted to KairosChain
13
+ principles: synthesis is adopted while the synthesized result is demoted from a
14
+ source of truth to a regenerable DERIVED view that sits beside the immutable
15
+ fragments — never an overwrite, never authoritative (Prop 5 + Knowledge Ethos).
16
+
17
+ - Modes: `package` (content-addressed snapshot + contradiction-preserving
18
+ directive), `write` (persist LLM content with provenance), `read` (with
19
+ staleness annotations), `list`, `sweep` (staleness + age health, schedulable),
20
+ `refresh` (faithful regeneration package from recorded provenance).
21
+ - `from_tag` wiring: resolves the citable universe (the snapshot input) from a
22
+ tag, connecting `dream_scan` detection to digest generation.
23
+ - Design frozen after 3 multi-LLM design rounds (10 invariants); implementation
24
+ hardened across 3 implementation-review rounds (path-traversal confinement,
25
+ slug-collision guard, per-topic write lock, provenance re-derivation at write,
26
+ symlink-resolving confinement, fail-closed access bound).
27
+ - Tests: `test_dream_digest.rb` (74), `test_dream_digest_live.rb` (11 wiring).
28
+
29
+ ### Changed — `multi_llm_reviewer_evaluation` v1.5
30
+
31
+ - Added "Review-Loop Operation Observations": (1) a SKIPped strict reviewer is a
32
+ coverage hole, not a pass; (2) treadmill vs healthy narrowing via per-round
33
+ (a)/(b)/(c) classification + a pre-committed freeze criterion.
34
+
7
35
  ## [3.29.6] - 2026-06-05
8
36
 
9
37
  ### Changed — `skill_authoring_patterns` readability + structure (v1.4)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.29.6"
2
+ VERSION = "3.30.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multi_llm_reviewer_evaluation
3
3
  description: "Multi-LLM reviewer performance evaluation — strengths, weaknesses, value-system biases, and recommended workflows. Based on 185+ reviews (Phase 1, 2026-02 to 03) + Phase 2 Case A 4-round Codex bias study (2026-05-04)."
4
- version: "1.4"
4
+ version: "1.5"
5
5
  tags:
6
6
  - multi-llm
7
7
  - review
@@ -294,6 +294,36 @@ Deployment: Composer-2.5 or Cursor GPT-5.4
294
294
  classification (see § Reviewer Value-System Divergence) is required to separate
295
295
  blocking signal from advisory noise. Codex models in particular require this lens.
296
296
 
297
+ ## Review-Loop Operation Observations (2026-06-06, dream_digest design loop)
298
+
299
+ Two operational lessons from a 3-round design-by-invariant review loop (dream_digest,
300
+ orchestrator Opus 4.8, full roster). These concern HOW to run the loop, complementing the
301
+ per-reviewer profiles above.
302
+
303
+ ### 1. A SKIPped strict reviewer is a coverage hole, not a pass
304
+
305
+ When a strict reviewer fails to run (e.g. Codex `token_invalidated` 401 -> SKIP), the
306
+ remaining roster can hit the APPROVE threshold and report FALSE convergence. In the dream_digest
307
+ loop, the first R1 showed 3/5 APPROVE only because both Codex SKIPped on auth failure; after
308
+ `codex login` re-auth, the same artifact immediately went to REVISE, and Codex GPT-5.4 SOLO
309
+ caught an access-control aggregation leak (a genuine (a)) that no other reviewer saw. Rule:
310
+ do not treat threshold-APPROVE as convergence while the strongest (a)-finder (Codex) is absent.
311
+ Re-auth and re-run before trusting a verdict. Surface SKIPs prominently in the per-reviewer table.
312
+
313
+ ### 2. Treadmill vs healthy narrowing — distinguish by per-round (a)/(b)/(c), then pre-commit a freeze criterion
314
+
315
+ Codex may never reach APPROVE (value-system divergence). This looks like the non-convergent
316
+ treadmill (Context Graph: 24/24 REJECT, new P0 each round) but is NOT the same if findings
317
+ NARROW monotonically. In the dream_digest loop they did: R1 structural gaps -> R2 fix-incompleteness
318
+ -> R3 a single (b) one-liner (an I7xI9 recording inconsistency), all else (c). To tell the two
319
+ apart you MUST classify every finding (a)/(b)/(c) each round and watch the trend.
320
+
321
+ To stop the treadmill structurally, PRE-COMMIT a freeze criterion before the round, e.g.:
322
+ "revise only on a new (a) or an internal-contradiction (b); a request to specify the ENFORCEMENT
323
+ MECHANISM of a sound invariant is (c) -> §11 / implementation review." This converts "wait for
324
+ Codex APPROVE" (not always reachable) into "freeze when only (c)/mechanism findings remain,"
325
+ which is decidable by the orchestrator and resistant to value-divergence stalling.
326
+
297
327
  ## Refinement Source
298
328
 
299
329
  Profiles in this knowledge are refined from accumulated L2 contexts named with prefix
@@ -0,0 +1,589 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'yaml'
6
+ require 'json'
7
+ require 'time'
8
+ require 'date'
9
+
10
+ module KairosMcp
11
+ module SkillSets
12
+ module Dream
13
+ # Digester — generates and reads dream_digest derived views.
14
+ #
15
+ # Implements the frozen design (dream_digest_design_v0.4):
16
+ # - I1/I8/I10: digests live in a NON-CANONICAL derived tier, per-topic, never authoritative.
17
+ # - I2/I7: generation captures a content-addressed snapshot of the entire READ set.
18
+ # - I4: provenance references the snapshot; an empty topic emits no digest.
19
+ # - I5: post-generation source drift is labelled (stale), never corrected.
20
+ # - I6: the snapshot fixes the citable universe as INPUT (the LLM phrases, it does not select).
21
+ # - I9: the access bound is the most restrictive among ALL read sources (bound computed
22
+ # and recorded here; full enforcement mechanism is deferred to §11 / implementation review).
23
+ #
24
+ # Synthesis CONTENT is generated outside this class (by an LLM), mirroring dream_propose:
25
+ # #package builds the snapshot + a contradiction-preserving directive;
26
+ # #write persists LLM-provided content with provenance;
27
+ # #read returns content with staleness annotations.
28
+ class Digester
29
+ DIGEST_DIR_NAME = 'dream/digest'
30
+ LOCK_FILE = '.dream_digest_lock'
31
+
32
+ class IdentifierError < StandardError; end
33
+
34
+ def initialize(config: {})
35
+ @config = config || {}
36
+ end
37
+
38
+ # Build a content-addressed snapshot + generation directive for a topic. (I2/I6/I7)
39
+ #
40
+ # The citable universe (I6) is fixed here, as an INPUT, either explicitly via `sources`
41
+ # or — wiring dream_scan detection into the digest — by resolving every live fragment
42
+ # carrying `from_tag`. Explicit sources take precedence; from_tag is the auto path.
43
+ #
44
+ # @param topic [String]
45
+ # @param sources [Array<Hash>] each: {"layer"=>"l2"|"l1", "session_id"=>.., "name"=>..}
46
+ # @param from_tag [String, nil] resolve sources = all live fragments carrying this tag
47
+ # @param include_l1 [Boolean] when resolving from_tag, also include L1 entries tagged with it
48
+ # @param directive_id [String, nil]
49
+ # @return [Hash] { topic:, snapshot: [...], access_bound:, directive:, directive_id:, status:, resolved_from: }
50
+ def package(topic:, sources: nil, from_tag: nil, include_l1: true, directive_id: nil)
51
+ resolved_from = nil
52
+ src = Array(sources)
53
+ if src.empty? && from_tag
54
+ src = resolve_sources_by_tag(from_tag, include_l1: include_l1)
55
+ resolved_from = "tag:#{from_tag}"
56
+ end
57
+
58
+ read_set = build_read_set(src)
59
+ {
60
+ topic: topic,
61
+ directive_id: directive_id || default_directive_id,
62
+ snapshot: read_set,
63
+ access_bound: access_bound(read_set),
64
+ directive: generation_directive(topic, read_set),
65
+ resolved_from: resolved_from,
66
+ status: read_set.empty? ? 'no_sources' : 'needs_content'
67
+ }
68
+ end
69
+
70
+ # Resolve the citation set from a tag (dream_scan -> dream_digest wiring). (I6 input)
71
+ # Returns live (non-archived) L2 contexts carrying the tag, plus tagged L1 entries.
72
+ #
73
+ # @param tag [String]
74
+ # @param include_l1 [Boolean]
75
+ # @return [Array<Hash>] source descriptors for #build_read_set
76
+ def resolve_sources_by_tag(tag, include_l1: true)
77
+ out = []
78
+ cm = context_manager
79
+ if cm
80
+ cm.list_sessions.each do |session|
81
+ sid = session[:session_id]
82
+ cm.list_contexts_in_session(sid).each do |ctx|
83
+ # Route through l2_path so the same safe_seg?/confine guard applies here as in
84
+ # build_read_set (defense in depth even though listings come from the store).
85
+ path = l2_path(sid, ctx[:name])
86
+ next unless path && File.exist?(path)
87
+
88
+ content = safe_read(path)
89
+ next if content.nil?
90
+ next if frontmatter_value(content, 'status') == 'soft-archived' # don't cite stubs as live
91
+ next unless tag_match?(frontmatter_tags(content), tag)
92
+
93
+ out << { 'layer' => 'l2', 'session_id' => sid, 'name' => ctx[:name] }
94
+ end
95
+ end
96
+ end
97
+
98
+ if include_l1 && (kp = knowledge_provider)
99
+ kp.list.each do |entry|
100
+ next unless tag_match?(Array(entry[:tags]), tag)
101
+
102
+ out << { 'layer' => 'l1', 'name' => entry[:name] }
103
+ end
104
+ end
105
+ out
106
+ end
107
+
108
+ # Persist an LLM-generated digest with provenance. (I1/I4/I8/I10)
109
+ #
110
+ # @param topic [String]
111
+ # @param snapshot [Array<Hash>] the READ set from #package (citable universe, I6)
112
+ # @param content [String] LLM-generated narrative (cites only snapshot sources)
113
+ # @param directive_id [String]
114
+ # @return [Hash] { success:, topic:, output_hash:, provenance_count:, access_bound:, path: }
115
+ def write(topic:, snapshot:, content:, directive_id: nil, resolved_from: nil)
116
+ slug = slugify(topic)
117
+ raise 'empty topic slug' if slug.empty?
118
+ raise 'no provenance: refusing to emit a sourceless digest (I4)' if Array(snapshot).empty?
119
+ raise 'empty content' if content.nil? || content.strip.empty?
120
+
121
+ effective_directive = directive_id || default_directive_id
122
+ output_hash = Digest::SHA256.hexdigest(content)
123
+ dir = topic_dir(topic)
124
+ FileUtils.mkdir_p(dir)
125
+ path = File.join(dir, "#{slug}.md")
126
+ result = nil
127
+
128
+ # Hold the per-topic lock across BOTH snapshot capture and commit, so the recorded
129
+ # provenance hashes reflect source state at (or very near) commit time and concurrent
130
+ # same-topic writers cannot interleave (I3-style integrity).
131
+ with_topic_lock(dir) do
132
+ # Re-derive from CURRENT sources; never trust caller hashes/access (I4/I9). The
133
+ # caller's snapshot only fixes WHICH sources are citable (I6).
134
+ read_set = build_read_set(sources_from_provenance(snapshot))
135
+ raise 'no resolvable sources at write time (I4)' if read_set.empty?
136
+
137
+ bound = access_bound(read_set)
138
+ generated_at = Time.now.utc.iso8601
139
+ provenance = read_set.map { |s| s.slice('layer', 'ref', 'content_hash') }
140
+
141
+ frontmatter = {
142
+ 'topic' => topic, 'kind' => 'dream_digest', 'status' => 'fresh',
143
+ 'derived' => true, 'authoritative' => false, 'generated_at' => generated_at,
144
+ 'directive_id' => effective_directive, 'access_bound' => bound,
145
+ 'output_hash' => output_hash, 'provenance' => provenance
146
+ }
147
+ frontmatter['resolved_from'] = resolved_from if resolved_from
148
+ body = "---\n#{YAML.dump(frontmatter).sub(/\A---\n/, '')}---\n\n#{content.strip}\n"
149
+
150
+ guard_slug_collision!(path, topic) # I10: never silently overwrite a different topic
151
+ tmp = "#{path}.#{Process.pid}.#{rand(1 << 32).to_s(16)}.tmp" # unique per writer (I3)
152
+ begin
153
+ File.write(tmp, body)
154
+ File.rename(tmp, path) # POSIX atomic
155
+ ensure
156
+ File.delete(tmp) if File.exist?(tmp) # no orphan tmp on failure
157
+ end
158
+
159
+ result = {
160
+ success: true, topic: topic, path: path, output_hash: output_hash,
161
+ directive_id: effective_directive, provenance: provenance,
162
+ provenance_count: read_set.size, access_bound: bound, generated_at: generated_at
163
+ }
164
+ end
165
+ result
166
+ end
167
+
168
+ # Read a digest with staleness annotations. (I5)
169
+ #
170
+ # @return [Hash] { found:, topic:, content:, stale:, drifted:, access_bound:, age_days:, ... }
171
+ def read(topic:)
172
+ path = File.join(topic_dir(topic), "#{slugify(topic)}.md")
173
+ return { found: false, topic: topic } unless File.exist?(path)
174
+
175
+ raw = File.read(path)
176
+ meta = extract_frontmatter(raw)
177
+ body = raw.sub(/\A---\n.*?\n---\n/m, '').strip
178
+
179
+ drift = drifted_sources(meta['provenance'] || [])
180
+ {
181
+ found: true,
182
+ topic: topic,
183
+ path: path,
184
+ content: body,
185
+ access_bound: meta['access_bound'],
186
+ generated_at: meta['generated_at'],
187
+ age_days: age_days(meta['generated_at']),
188
+ directive_id: meta['directive_id'],
189
+ resolved_from: meta['resolved_from'],
190
+ provenance_count: Array(meta['provenance']).size,
191
+ drifted: drift,
192
+ stale: !drift.empty?
193
+ }
194
+ end
195
+
196
+ # Sweep all digests for staleness + age. Schedulable health view. (I5 + freshness)
197
+ # The EXTERNAL trigger (cron / Claude Code hook / autonomous loop) is a separate layer;
198
+ # this method only reports — it does not regenerate or schedule anything itself.
199
+ #
200
+ # @param stale_after_days [Integer, nil] also flag digests older than this as 'aged'
201
+ # @return [Array<Hash>] one entry per digest, sorted stale-first then oldest-first
202
+ def sweep(stale_after_days: nil)
203
+ list.map do |slug|
204
+ r = read(topic: slug)
205
+ next nil unless r[:found]
206
+
207
+ aged = stale_after_days && r[:age_days] && r[:age_days] >= stale_after_days
208
+ {
209
+ topic: slug,
210
+ stale: r[:stale],
211
+ drifted_count: Array(r[:drifted]).size,
212
+ age_days: r[:age_days],
213
+ aged: !!aged,
214
+ needs_refresh: r[:stale] || !!aged,
215
+ access_bound: r[:access_bound],
216
+ generated_at: r[:generated_at]
217
+ }
218
+ end.compact.sort_by { |e| [e[:needs_refresh] ? 0 : 1, -(e[:age_days] || 0)] }
219
+ end
220
+
221
+ # Produce a FRESH regeneration package for an existing digest, faithfully from its
222
+ # recorded provenance re-read at current content (I6: same citable universe, new content).
223
+ # Returns a package-shaped hash for the LLM to regenerate, then #write. Re-synthesis of a
224
+ # DERIVED view — never an overwrite of sources (I1/I5).
225
+ #
226
+ # @param topic [String]
227
+ # @return [Hash] package-shaped { topic:, snapshot:, directive:, dropped:, status:, ... }
228
+ def refresh(topic:)
229
+ existing = read(topic: topic)
230
+ return { found: false, topic: topic } unless existing[:found]
231
+
232
+ path = File.join(topic_dir(topic), "#{slugify(topic)}.md")
233
+ prior_prov = Array(extract_frontmatter(File.read(path))['provenance'])
234
+ sources = sources_from_provenance(prior_prov)
235
+
236
+ read_set = build_read_set(sources)
237
+ resolved_refs = read_set.map { |s| s['ref'] }
238
+ dropped = prior_prov.map { |p| stringify(p)['ref'] } - resolved_refs
239
+
240
+ {
241
+ found: true,
242
+ topic: topic,
243
+ directive_id: existing[:directive_id] || default_directive_id,
244
+ snapshot: read_set,
245
+ access_bound: access_bound(read_set),
246
+ directive: generation_directive(topic, read_set),
247
+ dropped: dropped, # sources no longer resolvable, omitted per I4
248
+ resolved_from: existing[:resolved_from], # carry origin forward across refresh
249
+ prior_age_days: existing[:age_days],
250
+ status: read_set.empty? ? 'no_sources' : 'needs_content'
251
+ }
252
+ end
253
+
254
+ # Staleness check without returning full content. (I5)
255
+ def staleness(topic:)
256
+ r = read(topic: topic)
257
+ return { found: false, topic: topic } unless r[:found]
258
+
259
+ { found: true, topic: topic, stale: r[:stale], drifted: r[:drifted],
260
+ provenance_count: r[:provenance_count] }
261
+ end
262
+
263
+ # List existing digests.
264
+ def list
265
+ base = digest_base
266
+ return [] unless Dir.exist?(base)
267
+
268
+ Dir.children(base).select { |c| Dir.exist?(File.join(base, c)) }.sort
269
+ end
270
+
271
+ private
272
+
273
+ # ---- READ set / snapshot --------------------------------------------------
274
+
275
+ def build_read_set(sources)
276
+ seen = {}
277
+ Array(sources).each_with_object([]) do |s, acc|
278
+ layer = (s['layer'] || s[:layer] || 'l2').to_s.downcase
279
+ path = source_path(layer, s)
280
+ next unless path && File.exist?(path)
281
+
282
+ content = safe_read(path)
283
+ next if content.nil? # unreadable -> treat as unresolvable (I4 drop), do not raise
284
+
285
+ ref = source_ref(layer, s)
286
+ key = "#{layer}|#{ref}"
287
+ next if seen[key] # dedup: a source cited twice contributes one provenance entry
288
+
289
+ seen[key] = true
290
+ acc << {
291
+ 'layer' => layer,
292
+ 'ref' => ref,
293
+ 'path' => path,
294
+ 'content_hash' => Digest::SHA256.hexdigest(content),
295
+ 'access' => extract_access(content)
296
+ }
297
+ end
298
+ end
299
+
300
+ # Reconstruct source descriptors from recorded provenance / a package snapshot (for
301
+ # #refresh and #write). Reads only layer + ref, so caller-supplied hashes are never trusted.
302
+ def sources_from_provenance(provenance)
303
+ Array(provenance).filter_map do |p|
304
+ h = stringify(p)
305
+ case h['layer']
306
+ when 'l2'
307
+ sid, name = h['ref'].to_s.split('/', 2)
308
+ next nil unless sid && name
309
+
310
+ { 'layer' => 'l2', 'session_id' => sid, 'name' => name }
311
+ when 'l1'
312
+ { 'layer' => 'l1', 'name' => h['ref'] }
313
+ end
314
+ end
315
+ end
316
+
317
+ # Whole days since an ISO8601 timestamp (nil if unparseable). Time.now is fine in the gem.
318
+ def age_days(iso)
319
+ return nil if iso.nil? || iso.to_s.empty?
320
+
321
+ ((Time.now - Time.parse(iso.to_s)) / 86_400).floor
322
+ rescue StandardError
323
+ nil
324
+ end
325
+
326
+ def drifted_sources(provenance)
327
+ Array(provenance).filter_map do |p|
328
+ h = stringify(p)
329
+ path = path_for_ref(h['layer'], h['ref'])
330
+ content = (path && File.exist?(path)) ? safe_read(path) : nil
331
+ current = content && Digest::SHA256.hexdigest(content)
332
+ # A missing/unreadable source, or a hash mismatch, is drift (I5).
333
+ if current.nil? || current != h['content_hash']
334
+ reason = path.nil? || content.nil? ? 'missing' : 'hash_changed'
335
+ { 'ref' => h['ref'], 'reason' => reason }
336
+ end
337
+ end
338
+ end
339
+
340
+ # Read a file, returning nil on any I/O error instead of raising (callers treat nil as
341
+ # unresolvable / missing — keeps package/read/refresh from throwing out of the Digester).
342
+ def safe_read(path)
343
+ File.read(path)
344
+ rescue StandardError
345
+ nil
346
+ end
347
+
348
+ # ---- Access bound (I9) ----------------------------------------------------
349
+
350
+ # Most restrictive access label among the READ set. v0.1 reads a `visibility`/`access`
351
+ # frontmatter key if present; absent it, defaults to 'default'. Full ACL enforcement
352
+ # (live re-evaluation, principal binding) is a §11 / implementation-review mechanism.
353
+ ACCESS_ORDER = { 'private' => 3, 'restricted' => 2, 'default' => 1, 'public' => 0 }.freeze
354
+
355
+ def access_bound(read_set)
356
+ labels = Array(read_set).map { |s| (s['access'] || s[:access] || 'default').to_s }
357
+ labels << 'default' if labels.empty?
358
+ # Unknown labels are treated as MOST restrictive (fail-closed), never downgraded.
359
+ max_known = ACCESS_ORDER.values.max
360
+ winner = labels.max_by { |l| ACCESS_ORDER.fetch(l, max_known) }
361
+ # Normalize an unknown winning label to a known most-restrictive level so the stored
362
+ # bound is always a recognized value downstream consumers can compare.
363
+ ACCESS_ORDER.key?(winner) ? winner : 'private'
364
+ end
365
+
366
+ def extract_access(content)
367
+ meta = extract_frontmatter(content)
368
+ (meta['visibility'] || meta['access'] || 'default').to_s
369
+ end
370
+
371
+ # ---- Generation directive (I3/I6) -----------------------------------------
372
+
373
+ def generation_directive(topic, read_set)
374
+ refs = read_set.map { |s| s['ref'] }.join(', ')
375
+ <<~DIRECTIVE
376
+ Synthesize a narrative DIGEST for topic "#{topic}" from the source set below.
377
+
378
+ HARD CONSTRAINTS (do not violate):
379
+ - Cite ONLY these sources; do not introduce facts not grounded in them (I6 citable universe): #{refs}
380
+ - Every assertion must be traceable to at least one source; drop anything you cannot ground (I4).
381
+ - When sources DISAGREE, do NOT pick a winner and do NOT merge into one claim.
382
+ Surface the disagreement as coexisting positions with an inline annotation
383
+ (e.g. "Source A holds X; Source B holds Y; unresolved.") (I3, flat-annotation grade).
384
+ - This is a DERIVED overview, never authoritative; it must read as a projection of the
385
+ fragments, not as a new source of truth (I1).
386
+
387
+ Output: prose only (no YAML frontmatter — the tool adds provenance).
388
+ DIRECTIVE
389
+ end
390
+
391
+ def default_directive_id
392
+ 'dream_digest.synthesis.v1'
393
+ end
394
+
395
+ # ---- Paths ----------------------------------------------------------------
396
+
397
+ def digest_base
398
+ File.join(kairos_dir, DIGEST_DIR_NAME)
399
+ end
400
+
401
+ def topic_dir(topic)
402
+ File.join(digest_base, slugify(topic))
403
+ end
404
+
405
+ # Serialize same-topic writes (I3-style integrity): concurrent generations of the same
406
+ # topic must not clobber each other. Unlike a global lock, this is per-topic-dir.
407
+ def with_topic_lock(dir)
408
+ FileUtils.mkdir_p(dir)
409
+ lock_path = File.join(dir, LOCK_FILE)
410
+ File.open(lock_path, File::CREAT | File::RDWR) do |f|
411
+ f.flock(File::LOCK_EX)
412
+ begin
413
+ yield
414
+ ensure
415
+ f.flock(File::LOCK_UN)
416
+ end
417
+ end
418
+ end
419
+
420
+ # I10: a digest file is per-topic. Same topic overwriting itself is regeneration (allowed);
421
+ # a DIFFERENT topic mapping to the same slug must fail loudly, never silently overwrite.
422
+ def guard_slug_collision!(path, topic)
423
+ return unless File.exist?(path)
424
+
425
+ existing = extract_frontmatter(safe_read(path).to_s)['topic']
426
+ return if existing == topic # same topic -> regeneration is allowed
427
+
428
+ # Different topic, OR an existing file whose topic cannot be determined (corrupt/
429
+ # unreadable): refuse to overwrite. Fail-closed so I10 holds even on damaged state.
430
+ detail = existing.nil? ? 'an existing file with no identifiable topic (corrupt?)' : "existing #{existing.inspect}"
431
+ raise IdentifierError,
432
+ "slug collision: topic #{topic.inspect} would overwrite #{detail} " \
433
+ "at #{File.basename(path)}; rename the topic or remove the file."
434
+ end
435
+
436
+ def source_path(layer, s)
437
+ case layer
438
+ when 'l2'
439
+ l2_path(s['session_id'] || s[:session_id], s['name'] || s[:name])
440
+ when 'l1'
441
+ l1_path(s['name'] || s[:name])
442
+ end
443
+ end
444
+
445
+ def path_for_ref(layer, ref)
446
+ case layer
447
+ when 'l2'
448
+ # name cannot contain '/' (safe_seg?), so split is unambiguous.
449
+ sid, name = ref.to_s.split('/', 2)
450
+ l2_path(sid, name)
451
+ when 'l1'
452
+ l1_path(ref)
453
+ end
454
+ end
455
+
456
+ def l2_path(sid, name)
457
+ return nil unless safe_seg?(sid) && safe_seg?(name)
458
+
459
+ confine(context_dir, File.join(context_dir, sid, name, "#{name}.md"))
460
+ end
461
+
462
+ def l1_path(name)
463
+ return nil unless safe_seg?(name)
464
+
465
+ # Pick the existing form FIRST, then confine the chosen path. (confine resolves symlinks
466
+ # via realpath, which only works on existing paths; confining a nonexistent form would
467
+ # mismatch a symlinked base such as macOS /var -> /private/var.)
468
+ dir_form = File.join(knowledge_dir, name, "#{name}.md")
469
+ flat_form = File.join(knowledge_dir, "#{name}.md")
470
+ chosen = File.exist?(dir_form) ? dir_form : (File.exist?(flat_form) ? flat_form : nil)
471
+ chosen && confine(knowledge_dir, chosen)
472
+ end
473
+
474
+ # Reject identifiers that could escape the data tree or break the "sid/name" ref split.
475
+ def safe_seg?(value)
476
+ s = value.to_s
477
+ return false if s.empty?
478
+ return false if s.include?('/') || s.include?('\\') || s.include?("\0")
479
+ return false if ['.', '..'].include?(s)
480
+
481
+ true
482
+ end
483
+
484
+ # Return path only if it stays within base; else nil (defense in depth). Uses realpath
485
+ # when the target exists so a symlink under the data tree cannot escape it; falls back to
486
+ # lexical expand_path for not-yet-existing paths (which cannot follow a link until created).
487
+ def confine(base, path)
488
+ b = real_or_expand(base)
489
+ p = real_or_expand(path)
490
+ (p == b || p.start_with?("#{b}#{File::SEPARATOR}")) ? path : nil
491
+ end
492
+
493
+ def real_or_expand(x)
494
+ File.realpath(x)
495
+ rescue StandardError
496
+ File.expand_path(x)
497
+ end
498
+
499
+ def source_ref(layer, s)
500
+ case layer
501
+ when 'l2' then "#{s['session_id'] || s[:session_id]}/#{s['name'] || s[:name]}"
502
+ when 'l1' then (s['name'] || s[:name]).to_s
503
+ end
504
+ end
505
+
506
+ # ---- Env helpers ----------------------------------------------------------
507
+
508
+ # The .kairos data root. KairosMcp exposes `data_dir` (context_dir/knowledge_dir hang off
509
+ # it); `kairos_dir` is NOT a real accessor, so data_dir is the correct, test-isolatable base.
510
+ def kairos_dir
511
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:data_dir) && KairosMcp.data_dir
512
+ KairosMcp.data_dir
513
+ elsif defined?(KairosMcp) && KairosMcp.respond_to?(:kairos_dir)
514
+ KairosMcp.kairos_dir
515
+ else
516
+ File.join(Dir.pwd, '.kairos')
517
+ end
518
+ end
519
+
520
+ def context_dir
521
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:context_dir)
522
+ KairosMcp.context_dir
523
+ else
524
+ File.join(kairos_dir, 'context')
525
+ end
526
+ end
527
+
528
+ def knowledge_dir
529
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:knowledge_dir)
530
+ KairosMcp.knowledge_dir
531
+ else
532
+ File.join(kairos_dir, 'knowledge')
533
+ end
534
+ end
535
+
536
+ # ---- Misc -----------------------------------------------------------------
537
+
538
+ def slugify(topic)
539
+ topic.to_s.downcase.strip.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
540
+ end
541
+
542
+ def extract_frontmatter(content)
543
+ if content =~ /\A---\n(.*?)\n---/m
544
+ # Permit Time/Date: a digest's generated_at (or a hand-edited timestamp) may load as
545
+ # Time; without this the whole frontmatter would silently fail to parse and drop to {}.
546
+ YAML.safe_load($1, permitted_classes: [Symbol, Time, Date]) || {}
547
+ else
548
+ {}
549
+ end
550
+ rescue StandardError
551
+ {}
552
+ end
553
+
554
+ def frontmatter_tags(content)
555
+ meta = extract_frontmatter(content)
556
+ Array(meta['tags'] || meta[:tags])
557
+ end
558
+
559
+ def frontmatter_value(content, key)
560
+ meta = extract_frontmatter(content)
561
+ meta[key] || meta[key.to_sym]
562
+ end
563
+
564
+ # Tag match tolerant to hyphen/underscore variants (mirrors Scanner normalization).
565
+ def tag_match?(tags, tag)
566
+ norm = ->(t) { t.to_s.downcase.tr('-', '_') }
567
+ target = norm.call(tag)
568
+ Array(tags).any? { |t| norm.call(t) == target }
569
+ end
570
+
571
+ def context_manager
572
+ return nil unless defined?(KairosMcp::ContextManager)
573
+
574
+ KairosMcp::ContextManager.new
575
+ end
576
+
577
+ def knowledge_provider
578
+ return nil unless defined?(KairosMcp::KnowledgeProvider)
579
+
580
+ KairosMcp::KnowledgeProvider.new
581
+ end
582
+
583
+ def stringify(hash)
584
+ (hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
585
+ end
586
+ end
587
+ end
588
+ end
589
+ end
@@ -8,6 +8,7 @@ require 'time'
8
8
  require_relative 'dream/scanner'
9
9
  require_relative 'dream/proposer'
10
10
  require_relative 'dream/archiver'
11
+ require_relative 'dream/digester'
11
12
 
12
13
  module Dream
13
14
  SKILLSET_ROOT = File.expand_path('..', __dir__)
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "dream",
3
- "version": "0.2.1",
4
- "description": "Memory consolidation and L2 lifecycle management. Scans for recurring patterns, manages L2 soft-archive, and packages promotion proposals.",
3
+ "version": "0.3.0",
4
+ "description": "Memory consolidation and L2 lifecycle management. Scans for recurring patterns, manages L2 soft-archive, packages promotion proposals, and generates derived per-topic narrative digests.",
5
5
  "author": "Masaomi Hatakeyama",
6
6
  "layer": "L1",
7
7
  "depends_on": [],
8
- "provides": ["pattern_detection", "promotion_discovery", "knowledge_health_scan", "l2_soft_archive", "l2_recall"],
8
+ "provides": ["pattern_detection", "promotion_discovery", "knowledge_health_scan", "l2_soft_archive", "l2_recall", "narrative_digest"],
9
9
  "tool_classes": [
10
10
  "KairosMcp::SkillSets::Dream::Tools::DreamScan",
11
11
  "KairosMcp::SkillSets::Dream::Tools::DreamPropose",
12
12
  "KairosMcp::SkillSets::Dream::Tools::DreamArchive",
13
- "KairosMcp::SkillSets::Dream::Tools::DreamRecall"
13
+ "KairosMcp::SkillSets::Dream::Tools::DreamRecall",
14
+ "KairosMcp::SkillSets::Dream::Tools::DreamDigest"
14
15
  ],
15
16
  "config_files": ["config/dream.yml"],
16
17
  "knowledge_dirs": ["knowledge/dream_trigger_policy"],
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ module SkillSets
5
+ module Dream
6
+ module Tools
7
+ # dream_digest — derived narrative view over immutable L2/L1 fragments.
8
+ #
9
+ # Implements dream_digest_design_v0.4 (FROZEN). A digest is a DERIVED, per-topic,
10
+ # access-bounded projection that sits BESIDE the fragments and never replaces them.
11
+ # Synthesis content is generated by an LLM (like dream_propose): `package` builds the
12
+ # snapshot + a contradiction-preserving directive, `write` persists the LLM output with
13
+ # provenance, `read` returns it with staleness annotations.
14
+ class DreamDigest < KairosMcp::Tools::BaseTool
15
+ def name
16
+ 'dream_digest'
17
+ end
18
+
19
+ def description
20
+ 'Generate/read a DERIVED per-topic narrative digest over L2/L1 fragments. ' \
21
+ 'Never authoritative, never overwrites sources, preserves contradictions, ' \
22
+ 'access-bounded by its sources. Modes: package (snapshot+directive), write (persist ' \
23
+ 'LLM content with provenance), read (with staleness), list.'
24
+ end
25
+
26
+ def category
27
+ :knowledge
28
+ end
29
+
30
+ def usecase_tags
31
+ %w[dream digest narrative synthesis derived-view provenance staleness consolidation]
32
+ end
33
+
34
+ def related_tools
35
+ %w[dream_scan dream_propose dream_archive knowledge_get context_save]
36
+ end
37
+
38
+ def input_schema
39
+ {
40
+ type: 'object',
41
+ properties: {
42
+ mode: {
43
+ type: 'string',
44
+ enum: %w[package write read list sweep refresh],
45
+ description: 'package (default): snapshot+directive. write: persist LLM content. read: digest+staleness. list: existing digests. sweep: staleness+age health over all digests (schedulable). refresh: fresh regeneration package for a stale topic.'
46
+ },
47
+ topic: { type: 'string', description: 'Topic identifier (per-topic partition, I10).' },
48
+ sources: {
49
+ type: 'array',
50
+ description: '[package] Explicit READ set. Each: {layer: l2|l1, session_id, name}. Fixes the citable universe (I6). Takes precedence over from_tag.',
51
+ items: {
52
+ type: 'object',
53
+ properties: {
54
+ layer: { type: 'string', enum: %w[l2 l1] },
55
+ session_id: { type: 'string' },
56
+ name: { type: 'string' }
57
+ },
58
+ required: %w[layer]
59
+ }
60
+ },
61
+ from_tag: {
62
+ type: 'string',
63
+ description: '[package] dream_scan->dream_digest wiring: resolve the citable universe (I6) as all live fragments carrying this tag. Used when sources is omitted.'
64
+ },
65
+ include_l1: {
66
+ type: 'boolean',
67
+ description: '[package] When using from_tag, also include L1 entries tagged with it. Default: true.'
68
+ },
69
+ snapshot: {
70
+ type: 'array',
71
+ description: '[write] The snapshot returned by package (the citable universe). Pass through unchanged.',
72
+ items: { type: 'object' }
73
+ },
74
+ content: { type: 'string', description: '[write] LLM-generated narrative grounded in the snapshot.' },
75
+ resolved_from: { type: 'string', description: '[write] Optional provenance origin (e.g. "tag:wireup") from package, stored for refresh.' },
76
+ stale_after_days: { type: 'integer', description: '[sweep] Also flag digests older than this many days as aged.' },
77
+ directive_id: { type: 'string', description: 'Generation directive identity (I7).' }
78
+ },
79
+ required: []
80
+ }
81
+ end
82
+
83
+ def call(arguments)
84
+ mode = arguments['mode'] || 'package'
85
+ digester = KairosMcp::SkillSets::Dream::Digester.new(config: load_dream_config)
86
+
87
+ case mode
88
+ when 'package' then call_package(digester, arguments)
89
+ when 'write' then call_write(digester, arguments)
90
+ when 'read' then call_read(digester, arguments)
91
+ when 'list' then text_content(JSON.pretty_generate(digests: digester.list))
92
+ when 'sweep' then call_sweep(digester, arguments)
93
+ when 'refresh' then call_refresh(digester, arguments)
94
+ else
95
+ text_content(JSON.pretty_generate(error: "unknown mode: #{mode}"))
96
+ end
97
+ rescue StandardError => e
98
+ text_content(JSON.pretty_generate(error: e.message, backtrace: e.backtrace&.first(5)))
99
+ end
100
+
101
+ private
102
+
103
+ def call_package(digester, arguments)
104
+ topic = arguments['topic']
105
+ return text_content(JSON.pretty_generate(error: 'package requires topic')) if topic.nil? || topic.empty?
106
+
107
+ result = digester.package(
108
+ topic: topic,
109
+ sources: arguments['sources'],
110
+ from_tag: arguments['from_tag'],
111
+ include_l1: arguments.fetch('include_l1', true),
112
+ directive_id: arguments['directive_id']
113
+ )
114
+ text_content(format_package(result))
115
+ end
116
+
117
+ def call_write(digester, arguments)
118
+ # I2: writing a derived digest does not modify L2/L1; gate on L2 modify only if the
119
+ # host enforces it, but a digest write never touches source content.
120
+ topic = arguments['topic']
121
+ return text_content(JSON.pretty_generate(error: 'write requires topic')) if topic.nil? || topic.empty?
122
+
123
+ result = digester.write(
124
+ topic: topic,
125
+ snapshot: arguments['snapshot'] || [],
126
+ content: arguments['content'],
127
+ directive_id: arguments['directive_id'],
128
+ resolved_from: arguments['resolved_from']
129
+ )
130
+ record_generation_event(result)
131
+ text_content(format_write(result))
132
+ end
133
+
134
+ def call_read(digester, arguments)
135
+ topic = arguments['topic']
136
+ return text_content(JSON.pretty_generate(error: 'read requires topic')) if topic.nil? || topic.empty?
137
+
138
+ text_content(format_read(digester.read(topic: topic)))
139
+ end
140
+
141
+ def call_sweep(digester, arguments)
142
+ rows = digester.sweep(stale_after_days: arguments['stale_after_days'])
143
+ text_content(format_sweep(rows))
144
+ end
145
+
146
+ def call_refresh(digester, arguments)
147
+ topic = arguments['topic']
148
+ return text_content(JSON.pretty_generate(error: 'refresh requires topic')) if topic.nil? || topic.empty?
149
+
150
+ r = digester.refresh(topic: topic)
151
+ return text_content("## Dream Digest — refresh: not found (topic: #{topic})") unless r[:found]
152
+
153
+ text_content(format_refresh(r))
154
+ end
155
+
156
+ # I7: record the generation EVENT (the derived artifact itself need not be immutable).
157
+ # The snapshot is recorded with content hashes (not refs alone) and the EFFECTIVE
158
+ # directive id, both taken from the write result so the record is canonical.
159
+ def record_generation_event(result)
160
+ return unless result[:success]
161
+ return unless defined?(KairosMcp::KairosChain::Chain)
162
+
163
+ chain = KairosMcp::KairosChain::Chain.new
164
+ chain.add_block([{
165
+ type: 'dream_digest_generation',
166
+ topic: result[:topic],
167
+ directive_id: result[:directive_id],
168
+ output_hash: result[:output_hash],
169
+ access_bound: result[:access_bound],
170
+ provenance: Array(result[:provenance]), # {layer, ref, content_hash} per source (I7)
171
+ provenance_count: result[:provenance_count],
172
+ generated_at: result[:generated_at]
173
+ }.to_json])
174
+ rescue StandardError => e
175
+ warn "[DreamDigest] Failed to record to blockchain: #{e.message}"
176
+ end
177
+
178
+ def load_dream_config
179
+ candidates = [dream_user_config_path, dream_template_config_path].compact
180
+ path = candidates.find { |p| File.exist?(p) }
181
+ return {} unless path
182
+
183
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol]) || {}
184
+ end
185
+
186
+ def dream_user_config_path
187
+ if defined?(KairosMcp) && KairosMcp.respond_to?(:kairos_dir)
188
+ File.join(KairosMcp.kairos_dir, 'skillsets', 'dream', 'config', 'dream.yml')
189
+ else
190
+ File.join(Dir.pwd, '.kairos', 'skillsets', 'dream', 'config', 'dream.yml')
191
+ end
192
+ end
193
+
194
+ def dream_template_config_path
195
+ File.expand_path('../../config/dream.yml', __dir__)
196
+ end
197
+
198
+ # ---- formatting -----------------------------------------------------------
199
+
200
+ def format_package(r)
201
+ lines = []
202
+ lines << "## Dream Digest — Package (topic: #{r[:topic]})"
203
+ lines << ""
204
+ lines << "**Status**: #{r[:status]}"
205
+ lines << "**Access bound**: #{r[:access_bound]} (I9)"
206
+ lines << "**Directive id**: #{r[:directive_id]}"
207
+ lines << "**Resolved from**: #{r[:resolved_from]}" if r[:resolved_from]
208
+ lines << ""
209
+ if r[:snapshot].empty?
210
+ lines << "_No resolvable sources — nothing to digest (I4)._"
211
+ return lines.join("\n")
212
+ end
213
+ lines << "### READ set / citable universe (#{r[:snapshot].size}) — I6"
214
+ r[:snapshot].each do |s|
215
+ lines << "- **#{s['ref']}** [#{s['layer']}] hash=`#{s['content_hash'][0, 12]}…` access=#{s['access']}"
216
+ end
217
+ lines << ""
218
+ lines << "### Generation directive (give to LLM, then call mode=write with snapshot+content)"
219
+ lines << "```"
220
+ lines << r[:directive].strip
221
+ lines << "```"
222
+ lines << ""
223
+ lines << "### Next step"
224
+ lines << "```"
225
+ lines << "dream_digest(mode: \"write\", topic: #{r[:topic].inspect},"
226
+ lines << " directive_id: #{r[:directive_id].inspect},"
227
+ lines << " snapshot: <the snapshot array above>, content: \"<LLM narrative>\")"
228
+ lines << "```"
229
+ lines.join("\n")
230
+ end
231
+
232
+ def format_write(r)
233
+ lines = []
234
+ lines << "## Dream Digest — Written (topic: #{r[:topic]})"
235
+ lines << ""
236
+ lines << "**Path**: #{r[:path]}"
237
+ lines << "**Output hash**: `#{r[:output_hash]}`"
238
+ lines << "**Provenance count**: #{r[:provenance_count]}"
239
+ lines << "**Access bound**: #{r[:access_bound]} (I9)"
240
+ lines << "**Generated at**: #{r[:generated_at]}"
241
+ lines << ""
242
+ lines << "_Derived view (I1/I8): not authoritative, regenerable, no write-back to L2/L1._"
243
+ lines.join("\n")
244
+ end
245
+
246
+ def format_read(r)
247
+ return "## Dream Digest — not found (topic: #{r[:topic]})" unless r[:found]
248
+
249
+ lines = []
250
+ lines << "## Dream Digest (topic: #{r[:topic]})"
251
+ lines << ""
252
+ stale_marker = r[:stale] ? "⚠️ STALE" : "fresh"
253
+ lines << "**Status**: #{stale_marker}"
254
+ lines << "**Access bound**: #{r[:access_bound]} (I9)"
255
+ lines << "**Generated at**: #{r[:generated_at]} (#{r[:age_days]}d ago)"
256
+ lines << "**Provenance count**: #{r[:provenance_count]}"
257
+ if r[:stale]
258
+ lines << ""
259
+ lines << "### Drifted sources (I5 — labelled, not corrected; regenerate to refresh)"
260
+ r[:drifted].each { |d| lines << "- **#{d['ref']}**: #{d['reason']}" }
261
+ end
262
+ lines << ""
263
+ lines << "---"
264
+ lines << ""
265
+ lines << r[:content]
266
+ lines.join("\n")
267
+ end
268
+
269
+ def format_sweep(rows)
270
+ lines = []
271
+ lines << "## Dream Digest — Sweep (#{rows.size} digest(s))"
272
+ lines << ""
273
+ if rows.empty?
274
+ lines << "_No digests yet._"
275
+ return lines.join("\n")
276
+ end
277
+ needs = rows.count { |r| r[:needs_refresh] }
278
+ lines << "**Needs refresh**: #{needs} / #{rows.size}"
279
+ lines << ""
280
+ lines << "| topic | status | drifted | age(d) | access |"
281
+ lines << "|---|---|---|---|---|"
282
+ rows.each do |r|
283
+ status = r[:stale] ? "⚠️ stale" : (r[:aged] ? "aged" : "fresh")
284
+ lines << "| #{r[:topic]} | #{status} | #{r[:drifted_count]} | #{r[:age_days]} | #{r[:access_bound]} |"
285
+ end
286
+ lines << ""
287
+ lines << "_Scheduling note: an external trigger (cron / Claude Code hook / autonomous loop) "
288
+ lines << "calls sweep, then `refresh` + `write` per needs_refresh topic. dream does not "
289
+ lines << "schedule itself (separate layer)._"
290
+ lines.join("\n")
291
+ end
292
+
293
+ def format_refresh(r)
294
+ lines = []
295
+ lines << "## Dream Digest — Refresh package (topic: #{r[:topic]})"
296
+ lines << ""
297
+ lines << "**Status**: #{r[:status]} (prior age: #{r[:prior_age_days]}d)"
298
+ lines << "**Access bound**: #{r[:access_bound]} (I9)"
299
+ lines << "**Dropped (no longer resolvable, omitted per I4)**: #{r[:dropped].empty? ? 'none' : r[:dropped].join(', ')}"
300
+ lines << ""
301
+ if r[:snapshot].empty?
302
+ lines << "_All sources gone — nothing to regenerate (I4). Consider deleting the stale digest._"
303
+ return lines.join("\n")
304
+ end
305
+ lines << "### Fresh READ set / citable universe (#{r[:snapshot].size}) — I6"
306
+ r[:snapshot].each do |s|
307
+ lines << "- **#{s['ref']}** [#{s['layer']}] hash=`#{s['content_hash'][0, 12]}…`"
308
+ end
309
+ lines << ""
310
+ lines << "### Regeneration directive (give to LLM, then mode=write with this snapshot)"
311
+ lines << "```"
312
+ lines << r[:directive].strip
313
+ lines << "```"
314
+ lines.join("\n")
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.29.6
4
+ version: 3.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -366,10 +366,12 @@ files:
366
366
  - templates/skillsets/dream/knowledge/dream_trigger_policy/dream_trigger_policy.md
367
367
  - templates/skillsets/dream/lib/dream.rb
368
368
  - templates/skillsets/dream/lib/dream/archiver.rb
369
+ - templates/skillsets/dream/lib/dream/digester.rb
369
370
  - templates/skillsets/dream/lib/dream/proposer.rb
370
371
  - templates/skillsets/dream/lib/dream/scanner.rb
371
372
  - templates/skillsets/dream/skillset.json
372
373
  - templates/skillsets/dream/tools/dream_archive.rb
374
+ - templates/skillsets/dream/tools/dream_digest.rb
373
375
  - templates/skillsets/dream/tools/dream_propose.rb
374
376
  - templates/skillsets/dream/tools/dream_recall.rb
375
377
  - templates/skillsets/dream/tools/dream_scan.rb