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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/knowledge/multi_llm_reviewer_evaluation/multi_llm_reviewer_evaluation.md +31 -1
- data/templates/skillsets/dream/lib/dream/digester.rb +589 -0
- data/templates/skillsets/dream/lib/dream.rb +1 -0
- data/templates/skillsets/dream/skillset.json +5 -4
- data/templates/skillsets/dream/tools/dream_digest.rb +320 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ec94fadb095a49a89c0235010c80e37913aaf6781aa16d22a2e5855b1eb154d
|
|
4
|
+
data.tar.gz: 38c3b631326b87f07285f9a5f4ecdd64fc22d086b6b08de76d5b2aaf2d7d7040
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
+
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
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dream",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Memory consolidation and L2 lifecycle management. Scans for recurring patterns, manages L2 soft-archive,
|
|
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.
|
|
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-
|
|
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
|