kairos-chain 3.93.0 → 3.94.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 +26 -0
- data/lib/kairos_mcp/kairos_chain/landed_probe.rb +62 -0
- data/lib/kairos_mcp/knowledge_provider.rb +473 -15
- data/lib/kairos_mcp/l0_proposal_store.rb +82 -0
- data/lib/kairos_mcp/l1_history.rb +257 -0
- data/lib/kairos_mcp/safe_evolver.rb +399 -126
- data/lib/kairos_mcp/storage/importer.rb +50 -0
- data/lib/kairos_mcp/tools/chain_import.rb +15 -3
- data/lib/kairos_mcp/tools/knowledge_update.rb +37 -3
- data/lib/kairos_mcp/tools/skills_evolve.rb +34 -8
- data/lib/kairos_mcp/tools/skills_rollback.rb +94 -6
- data/lib/kairos_mcp/version.rb +1 -1
- data/lib/kairos_mcp/version_manager.rb +29 -14
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05055bfa3f7c4377e2e88011754c85d8d87c690e07f19837c4dca5a0c1cd033c
|
|
4
|
+
data.tar.gz: 48b9c8a70d27304e21293440f121ec069cb0c7ec47ed41b01573cdd2e3c80505
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08ee4ff5f61407bdcc390eb8c4cda6c286472672a6549ded81d5d0b1c7cf29f8203a472f9cfa5d4cfc811b1b8d923d4ee77ee6746decad1893a33c01187ecc1e'
|
|
7
|
+
data.tar.gz: 0b94dde53ddce2444defb9933515dda0d4e754bb0e6053129927d82032bdb9b73383b5d67234a9b57f3e003e82e1b004c17c94f6e551a45ba2fc5dca412bd340
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.94.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
### Added — L0 changes proposed for later approval, L0 restores on the record, L1 body history
|
|
12
|
+
|
|
13
|
+
- **L0 proposals.** `skills_evolve` keeps a pending L0 change as (base, result,
|
|
14
|
+
content) without evaluating it; `command: "pending"` lists the proposals and
|
|
15
|
+
`apply` takes a `proposal_id`. Applying writes exactly the stored bytes under
|
|
16
|
+
an exclusive lock, only while the base is still the L0 in force and the
|
|
17
|
+
content hashes to its result. A failed load, an interrupt or a killed thread
|
|
18
|
+
never leaves an unrecorded L0 on disk.
|
|
19
|
+
- **L0 restore on the record.** `skills_rollback` restores a snapshot only if
|
|
20
|
+
its hash appears in a transition recorded on the chain. Without
|
|
21
|
+
`approved: true` it writes nothing and returns a pending proposal; with
|
|
22
|
+
`proposal_id` the approval applies exactly the proposal that was shown.
|
|
23
|
+
- **L1 body history.** `knowledge_update` gains `history`, `restore` and
|
|
24
|
+
`erase_body` (the last is irreversible and needs `approved: true`). Bodies
|
|
25
|
+
are kept by content address from a recorded activation point, and restore and
|
|
26
|
+
erasure decide from the chain whether their record landed.
|
|
27
|
+
|
|
28
|
+
### Changed — read before upgrading
|
|
29
|
+
|
|
30
|
+
- `VersionManager.rollback` is removed; nothing shipped calls it. Code that
|
|
31
|
+
called it should use `skills_rollback` with a `proposal_id`.
|
|
32
|
+
- `chain_import` never overwrites an existing chain: it is refused when the
|
|
33
|
+
target already holds chain data. Import into a new data directory instead.
|
|
34
|
+
|
|
9
35
|
## [3.93.0] - 2026-10-19
|
|
10
36
|
|
|
11
37
|
### Added — `proactive_tutorial`, an opt-in mode that suggests KairosChain's own workflow
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'chain'
|
|
5
|
+
|
|
6
|
+
module KairosMcp
|
|
7
|
+
module KairosChain
|
|
8
|
+
# Answers "did this exact entry reach the ledger?" after an append did not
|
|
9
|
+
# return normally.
|
|
10
|
+
#
|
|
11
|
+
# Chain#add_block raises Storage::Error when it cannot tell whether the
|
|
12
|
+
# write landed, and a thread can be interrupted after the write but before
|
|
13
|
+
# add_block returns. Either way the caller holds a change it must keep
|
|
14
|
+
# (the record is on the chain) or undo (it is not). Looking only at the last
|
|
15
|
+
# block answers wrongly once anything else has been appended since, so the
|
|
16
|
+
# caller takes a mark before appending and the probe scans every block after
|
|
17
|
+
# that mark for an entry equal to the one the caller tried to append.
|
|
18
|
+
module LandedProbe
|
|
19
|
+
# The ledger length now, i.e. the index from which the caller's block can
|
|
20
|
+
# appear. nil when the ledger cannot be read; every later status is then
|
|
21
|
+
# :unknown.
|
|
22
|
+
def self.mark(storage_backend: nil)
|
|
23
|
+
chain = Chain.new(storage_backend: storage_backend)
|
|
24
|
+
case chain.load_state
|
|
25
|
+
when :readable then chain.chain.length
|
|
26
|
+
when :absent then 0
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# :landed -- a block after the mark carries an entry equal to entry_json
|
|
33
|
+
# :absent -- the ledger is readable (or absent) and no such entry is there
|
|
34
|
+
# :unknown -- the mark or the ledger could not be read
|
|
35
|
+
#
|
|
36
|
+
# Equality is on the parsed entry, so a backend that re-serialises the JSON
|
|
37
|
+
# still matches. Only StandardError is absorbed: an interrupt during the
|
|
38
|
+
# probe propagates, and callers run their undo in an ensure for that case.
|
|
39
|
+
def self.status(mark, entry_json, storage_backend: nil)
|
|
40
|
+
return :unknown if mark.nil?
|
|
41
|
+
|
|
42
|
+
expected = JSON.parse(entry_json)
|
|
43
|
+
chain = Chain.new(storage_backend: storage_backend)
|
|
44
|
+
return :absent if chain.load_state == :absent
|
|
45
|
+
return :unknown unless chain.load_state == :readable
|
|
46
|
+
|
|
47
|
+
found = chain.chain.drop(mark).any? do |block|
|
|
48
|
+
Array(block.data).any? do |raw|
|
|
49
|
+
begin
|
|
50
|
+
JSON.parse(raw.to_s) == expected
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
found ? :landed : :absent
|
|
57
|
+
rescue StandardError
|
|
58
|
+
:unknown
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
|
+
require 'json'
|
|
4
5
|
require 'fileutils'
|
|
5
6
|
require 'yaml'
|
|
7
|
+
require 'securerandom'
|
|
6
8
|
require_relative 'anthropic_skill_parser'
|
|
7
9
|
require_relative 'path_containment'
|
|
8
10
|
require_relative 'skillset_manager'
|
|
9
11
|
require_relative 'kairos_chain/chain'
|
|
12
|
+
require_relative 'kairos_chain/landed_probe'
|
|
13
|
+
require_relative 'l1_history'
|
|
10
14
|
require_relative 'vector_search/provider'
|
|
11
15
|
require_relative '../kairos_mcp'
|
|
12
16
|
|
|
@@ -189,6 +193,10 @@ module KairosMcp
|
|
|
189
193
|
return { success: false, error: "Knowledge '#{name}' already exists" }
|
|
190
194
|
end
|
|
191
195
|
|
|
196
|
+
# C2: store the body before the file write and before the chain record
|
|
197
|
+
refusal = keep_bodies!(content)
|
|
198
|
+
return refusal if refusal
|
|
199
|
+
|
|
192
200
|
skill = AnthropicSkillParser.create(@knowledge_dir, name, content, create_subdirs: create_subdirs)
|
|
193
201
|
|
|
194
202
|
# Record hash reference to blockchain
|
|
@@ -232,6 +240,10 @@ module KairosMcp
|
|
|
232
240
|
return { success: false, error: "No changes detected" }
|
|
233
241
|
end
|
|
234
242
|
|
|
243
|
+
# C2: both bodies kept before anything is written or recorded
|
|
244
|
+
refusal = keep_bodies!(prev_content, new_content)
|
|
245
|
+
return refusal if refusal
|
|
246
|
+
|
|
235
247
|
# Update the file
|
|
236
248
|
updated_skill = AnthropicSkillParser.update(skill.base_path, new_content)
|
|
237
249
|
|
|
@@ -269,6 +281,10 @@ module KairosMcp
|
|
|
269
281
|
prev_content = File.read(skill.md_file_path)
|
|
270
282
|
prev_hash = Digest::SHA256.hexdigest(prev_content)
|
|
271
283
|
|
|
284
|
+
# C2: keep the previous body before the directory goes away
|
|
285
|
+
refusal = keep_bodies!(prev_content)
|
|
286
|
+
return refusal if refusal
|
|
287
|
+
|
|
272
288
|
# Delete the directory
|
|
273
289
|
FileUtils.rm_rf(skill.base_path)
|
|
274
290
|
|
|
@@ -290,6 +306,367 @@ module KairosMcp
|
|
|
290
306
|
{ success: true, deleted: name, prev_hash: prev_hash }
|
|
291
307
|
end
|
|
292
308
|
|
|
309
|
+
# C3: return an entry to a body already kept in history
|
|
310
|
+
def restore(name, content_hash, reason: nil)
|
|
311
|
+
unless content_hash.to_s.match?(L1History::HASH_PATTERN)
|
|
312
|
+
return { success: false, error: "invalid content hash: #{content_hash}" }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# A quarantine left by an interrupted erasure is settled first, so a body
|
|
316
|
+
# whose erasure is on the chain is not restored from its quarantine.
|
|
317
|
+
settle_quarantine!
|
|
318
|
+
|
|
319
|
+
# The body must be present before anything else is decided
|
|
320
|
+
body = history_store.retrievable?(content_hash) ? history_store.fetch(content_hash) : nil
|
|
321
|
+
return { success: false, error: "body not retrievable: #{content_hash}" } if body.nil?
|
|
322
|
+
|
|
323
|
+
skill = get(name)
|
|
324
|
+
|
|
325
|
+
if skill
|
|
326
|
+
return not_owned(name) unless owned?(skill)
|
|
327
|
+
|
|
328
|
+
prev_content = File.read(skill.md_file_path)
|
|
329
|
+
prev_hash = Digest::SHA256.hexdigest(prev_content)
|
|
330
|
+
if prev_hash == content_hash
|
|
331
|
+
return { success: false, error: "#{name} already holds the content of hash #{content_hash}" }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# C2: previous body and restored body, both before the write
|
|
335
|
+
refusal = keep_bodies!(prev_content, body)
|
|
336
|
+
return refusal if refusal
|
|
337
|
+
|
|
338
|
+
tracked_action = 'update'
|
|
339
|
+
else
|
|
340
|
+
# Same name checks as create, in the same order (safe_segment first:
|
|
341
|
+
# it is the check that tolerates a non-String argument).
|
|
342
|
+
unless PathContainment.safe_segment?(name)
|
|
343
|
+
return { success: false, error: "Invalid knowledge name #{name.inspect}: not a single path segment" }
|
|
344
|
+
end
|
|
345
|
+
return { success: false, error: "Invalid knowledge name '#{name}': reserved by the knowledge store" } if reserved_name?(name)
|
|
346
|
+
|
|
347
|
+
skill_dir = File.join(@knowledge_dir, name)
|
|
348
|
+
unless PathContainment.contained?(@knowledge_dir, skill_dir)
|
|
349
|
+
return { success: false, error: "Invalid knowledge name '#{name}': resolves outside the knowledge directory" }
|
|
350
|
+
end
|
|
351
|
+
return { success: false, error: "Knowledge '#{name}' already exists" } if File.exist?(skill_dir)
|
|
352
|
+
|
|
353
|
+
refusal = keep_bodies!(body)
|
|
354
|
+
return refusal if refusal
|
|
355
|
+
|
|
356
|
+
prev_hash = nil
|
|
357
|
+
tracked_action = 'create'
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# restore_id makes the record unique, so the landed check below finds
|
|
361
|
+
# this restore and not an identical one made in the same second.
|
|
362
|
+
entry = hash_reference_entry(
|
|
363
|
+
name: name,
|
|
364
|
+
action: 'restore',
|
|
365
|
+
prev_hash: prev_hash,
|
|
366
|
+
next_hash: content_hash,
|
|
367
|
+
reason: "returned to content of hash #{content_hash}#{reason ? ": #{reason}" : ''}",
|
|
368
|
+
extra: { restored_from: content_hash, restore_id: SecureRandom.hex(8) }
|
|
369
|
+
)
|
|
370
|
+
mark = KairosChain::LandedProbe.mark(storage_backend: storage_backend)
|
|
371
|
+
status = nil
|
|
372
|
+
error = nil
|
|
373
|
+
attempted = false
|
|
374
|
+
|
|
375
|
+
# Undo touches the live file only while it holds the restored bytes. A
|
|
376
|
+
# write that never took effect, or content a same-name writer has put
|
|
377
|
+
# there since, is not this restore's to undo.
|
|
378
|
+
live_md = skill ? skill.md_file_path : File.join(skill_dir, "#{name}.md")
|
|
379
|
+
holds_restored = lambda do
|
|
380
|
+
File.file?(live_md) && Digest::SHA256.hexdigest(File.binread(live_md)) == content_hash
|
|
381
|
+
rescue StandardError
|
|
382
|
+
false
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# The live write sits inside the guarded region, so no interrupt can fall
|
|
386
|
+
# between the write and the ensure that undoes it.
|
|
387
|
+
begin
|
|
388
|
+
restored_skill = if skill
|
|
389
|
+
AnthropicSkillParser.update(skill.base_path, body)
|
|
390
|
+
else
|
|
391
|
+
AnthropicSkillParser.create(@knowledge_dir, name, body)
|
|
392
|
+
end
|
|
393
|
+
attempted = true
|
|
394
|
+
status, error = append_entry(entry, mark)
|
|
395
|
+
ensure
|
|
396
|
+
# In ensure, so an interrupt or a thread kill cannot leave the live
|
|
397
|
+
# entry changed without its record. A record that is on the chain
|
|
398
|
+
# stands whatever add_block raised; any other answer, including an
|
|
399
|
+
# unknown one, puts the live entry back exactly as it was: an entry
|
|
400
|
+
# that already existed gets its previous body written back, one that
|
|
401
|
+
# was recreated here is removed again -- provided it still holds what
|
|
402
|
+
# this restore wrote.
|
|
403
|
+
begin
|
|
404
|
+
status ||= KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend) if attempted
|
|
405
|
+
ensure
|
|
406
|
+
if status != :landed && holds_restored.call
|
|
407
|
+
if skill
|
|
408
|
+
AnthropicSkillParser.update(skill.base_path, prev_content)
|
|
409
|
+
else
|
|
410
|
+
FileUtils.rm_rf(skill_dir)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
unless status == :landed
|
|
417
|
+
return { success: false, error: "restore not recorded (#{error&.message || status}); entry left as it was" }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# After the record, and outside it: a metadata failure is reported but
|
|
421
|
+
# never undoes a restore the chain already holds.
|
|
422
|
+
update_knowledge_meta(name, content_hash)
|
|
423
|
+
update_vector_index(name, body, restored_skill)
|
|
424
|
+
track_pending_change(layer: 'L1', action: tracked_action, skill_id: name, reason: reason)
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
success: true,
|
|
428
|
+
name: name,
|
|
429
|
+
action: 'restore',
|
|
430
|
+
content_hash: content_hash,
|
|
431
|
+
restored_from: content_hash
|
|
432
|
+
}
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# C3: every body this entry has pointed at, in chain order
|
|
436
|
+
def history(name)
|
|
437
|
+
# Settled first, so `retrievable` below reflects what the chain records.
|
|
438
|
+
settle_quarantine!
|
|
439
|
+
chain = KairosChain::Chain.new(storage_backend: storage_backend)
|
|
440
|
+
# L1: start inactive; only an l1_history_activation record flips this.
|
|
441
|
+
active = false
|
|
442
|
+
rows = []
|
|
443
|
+
|
|
444
|
+
chain.chain.each do |block|
|
|
445
|
+
Array(block.data).each do |raw|
|
|
446
|
+
record = begin
|
|
447
|
+
JSON.parse(raw, symbolize_names: true)
|
|
448
|
+
rescue JSON::ParserError, TypeError
|
|
449
|
+
nil
|
|
450
|
+
end
|
|
451
|
+
next unless record.is_a?(Hash)
|
|
452
|
+
|
|
453
|
+
case record[:type]
|
|
454
|
+
when 'l1_history_activation'
|
|
455
|
+
# Activation threads forward from the block that declared it
|
|
456
|
+
flag = record.key?(:active) ? record[:active] : record[:enabled]
|
|
457
|
+
active = (flag == true || flag == 'true')
|
|
458
|
+
when 'knowledge_update'
|
|
459
|
+
next unless record[:knowledge_id] == name && record[:next_hash]
|
|
460
|
+
|
|
461
|
+
rows << {
|
|
462
|
+
index: block.index,
|
|
463
|
+
timestamp: record[:timestamp],
|
|
464
|
+
action: record[:action],
|
|
465
|
+
next_hash: record[:next_hash],
|
|
466
|
+
retrievable: begin
|
|
467
|
+
history_store.retrievable?(record[:next_hash])
|
|
468
|
+
rescue ArgumentError
|
|
469
|
+
false
|
|
470
|
+
end,
|
|
471
|
+
recorded_while_active: active
|
|
472
|
+
}
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
rows
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# C4: drop one stored body; the chain keeps the fact of the removal
|
|
481
|
+
def erase_body(name, content_hash, reason:)
|
|
482
|
+
if reason.nil? || reason.to_s.strip.empty?
|
|
483
|
+
return { success: false, error: 'a reason is required' }
|
|
484
|
+
end
|
|
485
|
+
unless content_hash.to_s.match?(L1History::HASH_PATTERN)
|
|
486
|
+
return { success: false, error: "invalid content hash: #{content_hash}" }
|
|
487
|
+
end
|
|
488
|
+
# Nothing was ever stored; checked before the lock, which would create
|
|
489
|
+
# the store root.
|
|
490
|
+
unless File.directory?(history_store.root)
|
|
491
|
+
return { success: false, error: "body not stored: #{content_hash}" }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# The checks, the quarantine and the record run under one lock, so a
|
|
495
|
+
# second erasure cannot settle this one's quarantine as a leftover while
|
|
496
|
+
# its record is still being appended.
|
|
497
|
+
history_store.with_erase_lock do
|
|
498
|
+
settle_quarantine!(locked: true)
|
|
499
|
+
|
|
500
|
+
# E3: live content blocks the erasure. Judged per directory, so an entry
|
|
501
|
+
# whose directory name differs from its declared name is still seen.
|
|
502
|
+
live = live_directories_for_body(content_hash)
|
|
503
|
+
unless live.empty?
|
|
504
|
+
return { success: false,
|
|
505
|
+
error: "body is live content in: #{live.sort.join(', ')}" }
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# E2: the hash must be cited by a knowledge_update of this entry, and by
|
|
509
|
+
# no other entry. A hash cited elsewhere is a body shared between entries,
|
|
510
|
+
# and erasing it would remove the other entry's history too.
|
|
511
|
+
citing = knowledge_ids_citing_body(content_hash)
|
|
512
|
+
unless citing.include?(name)
|
|
513
|
+
return { success: false,
|
|
514
|
+
error: "#{name} never pointed at this body: #{content_hash}" }
|
|
515
|
+
end
|
|
516
|
+
shared_with = (citing - [name]).sort
|
|
517
|
+
unless shared_with.empty?
|
|
518
|
+
return { success: false,
|
|
519
|
+
error: "body is shared with: #{shared_with.join(', ')}" }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
unless history_store.retrievable?(content_hash)
|
|
523
|
+
return { success: false, error: "body not stored: #{content_hash}" }
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# E1: quarantine first, append second. Nothing is removed until the
|
|
527
|
+
# erasure record is on the chain. The record carries the quarantine's
|
|
528
|
+
# erase id, which is what lets a leftover quarantine be matched to
|
|
529
|
+
# exactly this record later.
|
|
530
|
+
# The mark is taken before the quarantine, so the only step between the
|
|
531
|
+
# quarantine and the guarded region below is building the record. A
|
|
532
|
+
# quarantine orphaned there anyway is settled by settle_quarantine!.
|
|
533
|
+
mark = KairosChain::LandedProbe.mark(storage_backend: storage_backend)
|
|
534
|
+
erase_id = history_store.begin_erase(content_hash)
|
|
535
|
+
return { success: false, error: "body not stored: #{content_hash}" } unless erase_id
|
|
536
|
+
|
|
537
|
+
entry = {
|
|
538
|
+
type: 'l1_body_erasure',
|
|
539
|
+
layer: 'L1',
|
|
540
|
+
knowledge_id: name,
|
|
541
|
+
content_hash: content_hash,
|
|
542
|
+
erase_id: erase_id,
|
|
543
|
+
reason: reason,
|
|
544
|
+
timestamp: Time.now.iso8601
|
|
545
|
+
}.to_json
|
|
546
|
+
status = nil
|
|
547
|
+
error = nil
|
|
548
|
+
settled = true
|
|
549
|
+
|
|
550
|
+
begin
|
|
551
|
+
status, error = append_entry(entry, mark)
|
|
552
|
+
ensure
|
|
553
|
+
# In ensure, so an interrupt cannot leave a quarantine behind without
|
|
554
|
+
# deciding it. Landed: drop the bytes. Known absent: put them back.
|
|
555
|
+
# Unknown: leave the quarantine; settle_quarantine! decides it from the
|
|
556
|
+
# chain once the chain can be read.
|
|
557
|
+
begin
|
|
558
|
+
status ||= KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend)
|
|
559
|
+
ensure
|
|
560
|
+
begin
|
|
561
|
+
case status
|
|
562
|
+
when :landed then history_store.commit_erase(content_hash, erase_id)
|
|
563
|
+
when :absent then history_store.abort_erase(content_hash, erase_id)
|
|
564
|
+
end
|
|
565
|
+
rescue StandardError => e
|
|
566
|
+
# The quarantine stays and is settled on the next history access.
|
|
567
|
+
warn "[l1_history] quarantine #{erase_id} left for settling: #{e.message}"
|
|
568
|
+
settled = false
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
case status
|
|
574
|
+
when :landed
|
|
575
|
+
{ success: true, name: name, content_hash: content_hash, erased: settled }
|
|
576
|
+
when :absent
|
|
577
|
+
{ success: false, error: "erasure not recorded, body kept: #{error&.message}" }
|
|
578
|
+
else
|
|
579
|
+
{ success: false,
|
|
580
|
+
error: "erasure outcome unknown (#{error&.message}); the body stays quarantined " \
|
|
581
|
+
'and is settled from the chain on the next history access' }
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Settle quarantines an interrupted erasure left behind (L1History
|
|
587
|
+
# #reconcile_quarantine!). Cheap when there are none: the chain is read only
|
|
588
|
+
# when a quarantine exists. Takes the erase lock unless the caller holds it.
|
|
589
|
+
private def settle_quarantine!(locked: false)
|
|
590
|
+
return if history_store.quarantined.empty?
|
|
591
|
+
return history_store.reconcile_quarantine!(recorded_erase_ids) if locked
|
|
592
|
+
|
|
593
|
+
history_store.with_erase_lock { history_store.reconcile_quarantine!(recorded_erase_ids) }
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Erase ids in l1_body_erasure records on the chain; nil when the chain
|
|
597
|
+
# cannot be read, which leaves every quarantine undecided.
|
|
598
|
+
private def recorded_erase_ids
|
|
599
|
+
chain = KairosChain::Chain.new(storage_backend: storage_backend)
|
|
600
|
+
return [] if chain.load_state == :absent
|
|
601
|
+
return nil unless chain.load_state == :readable
|
|
602
|
+
|
|
603
|
+
chain.chain.each_with_object([]) do |block, ids|
|
|
604
|
+
Array(block.data).each do |raw|
|
|
605
|
+
record = begin
|
|
606
|
+
JSON.parse(raw.to_s)
|
|
607
|
+
rescue JSON::ParserError
|
|
608
|
+
nil
|
|
609
|
+
end
|
|
610
|
+
next unless record.is_a?(Hash) && record['type'] == 'l1_body_erasure' && record['erase_id']
|
|
611
|
+
|
|
612
|
+
ids << record['erase_id']
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
rescue StandardError
|
|
616
|
+
nil
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# E3 helper. Every entry directory whose current body hashes to
|
|
620
|
+
# content_hash blocks the erasure: the main knowledge dir and every external
|
|
621
|
+
# knowledge dir a SkillSet registered, as `list` exposes them. Each
|
|
622
|
+
# directory is parsed on its own and never looked up by its declared name,
|
|
623
|
+
# which is what let a directory whose name differs slip past the old guard.
|
|
624
|
+
# A directory that cannot be parsed is skipped rather than treated as a
|
|
625
|
+
# match.
|
|
626
|
+
private def live_directories_for_body(content_hash)
|
|
627
|
+
candidates = skill_dirs.map { |dir| [dir, File.basename(dir)] }
|
|
628
|
+
@external_dirs.each do |ext|
|
|
629
|
+
external_skill_dirs(ext[:dir], only: ext[:only]).each do |dir|
|
|
630
|
+
candidates << [dir, "#{ext[:source]}:#{File.basename(dir)}"]
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
candidates.each_with_object([]) do |(dir, label), found|
|
|
635
|
+
begin
|
|
636
|
+
skill = AnthropicSkillParser.parse(dir)
|
|
637
|
+
path = skill && skill.md_file_path
|
|
638
|
+
next unless path && File.exist?(path)
|
|
639
|
+
|
|
640
|
+
found << label if Digest::SHA256.hexdigest(File.read(path)) == content_hash
|
|
641
|
+
rescue StandardError
|
|
642
|
+
next
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# E2 helper. The knowledge ids whose knowledge_update records cite this hash
|
|
648
|
+
# as the body before or after the update. Each block's data is an array of
|
|
649
|
+
# JSON strings; an unparsable entry is skipped rather than fatal.
|
|
650
|
+
private def knowledge_ids_citing_body(content_hash)
|
|
651
|
+
ids = []
|
|
652
|
+
KairosChain::Chain.new(storage_backend: storage_backend).chain.each do |block|
|
|
653
|
+
data = block.respond_to?(:data) ? block.data : block['data']
|
|
654
|
+
Array(data).each do |raw|
|
|
655
|
+
record = begin
|
|
656
|
+
JSON.parse(raw.to_s)
|
|
657
|
+
rescue StandardError
|
|
658
|
+
nil
|
|
659
|
+
end
|
|
660
|
+
next unless record.is_a?(Hash)
|
|
661
|
+
next unless record['type'] == 'knowledge_update'
|
|
662
|
+
next unless [record['prev_hash'], record['next_hash']].include?(content_hash)
|
|
663
|
+
|
|
664
|
+
ids << record['knowledge_id'] if record['knowledge_id']
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
ids.uniq
|
|
668
|
+
end
|
|
669
|
+
|
|
293
670
|
# List scripts in a knowledge skill
|
|
294
671
|
#
|
|
295
672
|
# @param name [String] Skill name
|
|
@@ -433,6 +810,10 @@ module KairosMcp
|
|
|
433
810
|
content = File.read(skill.md_file_path)
|
|
434
811
|
content_hash = Digest::SHA256.hexdigest(content)
|
|
435
812
|
|
|
813
|
+
# C2: `content` is the body being archived; keep it before the move
|
|
814
|
+
refusal = keep_bodies!(content)
|
|
815
|
+
return refusal if refusal
|
|
816
|
+
|
|
436
817
|
dest_path = File.join(archived_dir, name)
|
|
437
818
|
meta_path = File.join(dest_path, ARCHIVE_META_FILE)
|
|
438
819
|
|
|
@@ -532,6 +913,11 @@ module KairosMcp
|
|
|
532
913
|
meta_file = archived_meta
|
|
533
914
|
meta = File.exist?(meta_file) ? YAML.safe_load(File.read(meta_file)) : {}
|
|
534
915
|
|
|
916
|
+
# C2: read the archived body while it is still at the archived path
|
|
917
|
+
archived_body = File.read(AnthropicSkillParser.parse(archived_path).md_file_path)
|
|
918
|
+
refusal = keep_bodies!(archived_body)
|
|
919
|
+
return refusal if refusal
|
|
920
|
+
|
|
535
921
|
# Move back to active
|
|
536
922
|
FileUtils.mv(archived_path, active_path)
|
|
537
923
|
|
|
@@ -835,9 +1221,63 @@ module KairosMcp
|
|
|
835
1221
|
basename.match?(BACKUP_DIR_PATTERN)
|
|
836
1222
|
end
|
|
837
1223
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1224
|
+
# Memoized body store
|
|
1225
|
+
def history_store
|
|
1226
|
+
@history_store ||= L1History.new(@knowledge_dir)
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1229
|
+
# C2: store every body whose hash enters the chain, before any write.
|
|
1230
|
+
# nil = nothing to refuse (stored, or history inactive); hash = refusal.
|
|
1231
|
+
def keep_bodies!(*bodies)
|
|
1232
|
+
unless defined?(@history_active)
|
|
1233
|
+
if L1History.enabled?
|
|
1234
|
+
begin
|
|
1235
|
+
FileUtils.mkdir_p(history_store.root)
|
|
1236
|
+
rescue StandardError => e
|
|
1237
|
+
# L2: no store root means nothing may be appended; stay unmemoized
|
|
1238
|
+
# so a later write retries.
|
|
1239
|
+
return { success: false, error: "history store unavailable: #{e.message}" }
|
|
1240
|
+
end
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
@history_active = begin
|
|
1244
|
+
history_store.sync_activation!(KairosChain::Chain.new(storage_backend: storage_backend))
|
|
1245
|
+
rescue StandardError => e
|
|
1246
|
+
# Chain unwritable: warn and fall back; never block L1 writes on activation
|
|
1247
|
+
warn "[l1_history] activation sync failed: #{e.message}"
|
|
1248
|
+
L1History.enabled?
|
|
1249
|
+
end
|
|
1250
|
+
end
|
|
1251
|
+
return nil unless @history_active
|
|
1252
|
+
|
|
1253
|
+
bodies.compact.each do |body|
|
|
1254
|
+
begin
|
|
1255
|
+
history_store.store(body)
|
|
1256
|
+
rescue StandardError => e
|
|
1257
|
+
return { success: false, error: "history store failed: #{e.message}" }
|
|
1258
|
+
end
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
nil
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
# Record a knowledge_update. A failed append is warned about and the caller
|
|
1265
|
+
# continues (restore, which must undo its write, appends on its own path).
|
|
1266
|
+
def record_hash_reference(name:, action:, prev_hash:, next_hash:, reason:, extra: {})
|
|
1267
|
+
entry = hash_reference_entry(name: name, action: action, prev_hash: prev_hash,
|
|
1268
|
+
next_hash: next_hash, reason: reason, extra: extra)
|
|
1269
|
+
status, error = append_entry(entry, KairosChain::LandedProbe.mark(storage_backend: storage_backend))
|
|
1270
|
+
unless status == :landed
|
|
1271
|
+
# Log but don't fail if blockchain recording fails
|
|
1272
|
+
warn "Failed to record to blockchain: #{error&.message || status}"
|
|
1273
|
+
return
|
|
1274
|
+
end
|
|
1275
|
+
|
|
1276
|
+
update_knowledge_meta(name, next_hash)
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def hash_reference_entry(name:, action:, prev_hash:, next_hash:, reason:, extra: {})
|
|
1280
|
+
{
|
|
841
1281
|
type: 'knowledge_update',
|
|
842
1282
|
layer: 'L1',
|
|
843
1283
|
knowledge_id: name,
|
|
@@ -846,22 +1286,40 @@ module KairosMcp
|
|
|
846
1286
|
next_hash: next_hash,
|
|
847
1287
|
reason: reason,
|
|
848
1288
|
timestamp: Time.now.iso8601
|
|
849
|
-
}.to_json
|
|
1289
|
+
}.merge(extra).to_json
|
|
1290
|
+
end
|
|
850
1291
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1292
|
+
# Append one entry. => [status, error]. status is :landed when add_block
|
|
1293
|
+
# returned, or when it raised but the entry is on the chain after `mark`
|
|
1294
|
+
# (LandedProbe); otherwise :absent or :unknown, with the StandardError that
|
|
1295
|
+
# add_block raised. An exception that is not a StandardError propagates;
|
|
1296
|
+
# a caller that must undo something on failure probes again in its ensure.
|
|
1297
|
+
def append_entry(entry, mark)
|
|
1298
|
+
KairosChain::Chain.new(storage_backend: storage_backend).add_block([entry])
|
|
1299
|
+
[:landed, nil]
|
|
1300
|
+
rescue StandardError => e
|
|
1301
|
+
[KairosChain::LandedProbe.status(mark, entry, storage_backend: storage_backend), e]
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
# SQLite keeps knowledge metadata beside the chain. Runs only after the
|
|
1305
|
+
# record landed, and a failure here never reaches the record's caller: the
|
|
1306
|
+
# chain already holds the change.
|
|
1307
|
+
def update_knowledge_meta(name, next_hash)
|
|
1308
|
+
return unless storage_backend.backend_type == :sqlite
|
|
1309
|
+
|
|
1310
|
+
if next_hash
|
|
1311
|
+
skill = get(name)
|
|
1312
|
+
storage_backend.save_knowledge_meta(name, {
|
|
854
1313
|
content_hash: next_hash,
|
|
855
|
-
version:
|
|
856
|
-
description:
|
|
857
|
-
tags:
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
storage_backend.delete_knowledge_meta(name)
|
|
1314
|
+
version: skill&.version,
|
|
1315
|
+
description: skill&.description,
|
|
1316
|
+
tags: skill&.tags
|
|
1317
|
+
})
|
|
1318
|
+
else
|
|
1319
|
+
storage_backend.delete_knowledge_meta(name)
|
|
861
1320
|
end
|
|
862
1321
|
rescue StandardError => e
|
|
863
|
-
|
|
864
|
-
warn "Failed to record to blockchain: #{e.message}"
|
|
1322
|
+
warn "[KnowledgeProvider] Recorded; knowledge metadata update failed: #{e.message}"
|
|
865
1323
|
end
|
|
866
1324
|
|
|
867
1325
|
def storage_backend
|