samagotchi 0.148.0 → 0.150.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +3 -3
  5. data/docs/bundles.md +38 -1
  6. data/docs/cli.md +76 -24
  7. data/docs/configuration.md +8 -6
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +42 -14
  13. data/docs/internals/objectives.md +269 -0
  14. data/docs/internals/web-frontend.md +4 -1
  15. data/docs/internals/web-push.md +2 -2
  16. data/docs/internals/working-set.md +5 -2
  17. data/docs/memory.md +47 -9
  18. data/docs/plugins.md +10 -2
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +45 -2
  21. data/docs/system-one.md +17 -1
  22. data/docs/testing.md +5 -0
  23. data/lib/samagotchi/atomic_file.rb +3 -0
  24. data/lib/samagotchi/bootstrap_command.rb +5 -2
  25. data/lib/samagotchi/bridge/card_store.rb +52 -7
  26. data/lib/samagotchi/bridge.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  30. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  31. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  32. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  33. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  34. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  35. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  37. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  39. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  40. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  42. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  43. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  44. data/lib/samagotchi/chi_home.rb +4 -2
  45. data/lib/samagotchi/chi_shim.rb +2 -1
  46. data/lib/samagotchi/child_env.rb +10 -3
  47. data/lib/samagotchi/config.rb +7 -1
  48. data/lib/samagotchi/context_note.rb +17 -5
  49. data/lib/samagotchi/engine.rb +113 -6
  50. data/lib/samagotchi/engine_peers.rb +11 -0
  51. data/lib/samagotchi/git_env.rb +43 -0
  52. data/lib/samagotchi/guardrails/context.rb +2 -0
  53. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  54. data/lib/samagotchi/guardrails/targets.rb +4 -1
  55. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  57. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  58. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  59. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  60. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  61. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  62. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  63. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  64. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  65. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  66. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  67. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  68. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  69. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  70. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  71. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  72. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  73. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  74. data/lib/samagotchi/note_command.rb +1 -1
  75. data/lib/samagotchi/note_delivery.rb +6 -2
  76. data/lib/samagotchi/objectives.rb +395 -0
  77. data/lib/samagotchi/objectives_audit.rb +115 -0
  78. data/lib/samagotchi/objectives_check.rb +365 -0
  79. data/lib/samagotchi/objectives_command.rb +192 -0
  80. data/lib/samagotchi/plugin/api.rb +2 -1
  81. data/lib/samagotchi/plugin/context.rb +21 -1
  82. data/lib/samagotchi/project_env.rb +3 -2
  83. data/lib/samagotchi/self_report.rb +2 -0
  84. data/lib/samagotchi/session_commands.rb +15 -0
  85. data/lib/samagotchi/session_continue.rb +3 -0
  86. data/lib/samagotchi/session_inbox.rb +14 -6
  87. data/lib/samagotchi/session_manager.rb +1 -1
  88. data/lib/samagotchi/steer.rb +24 -4
  89. data/lib/samagotchi/testing/boundary.rb +2 -0
  90. data/lib/samagotchi/testing/host.rb +1 -0
  91. data/lib/samagotchi/testing/minitest.rb +3 -0
  92. data/lib/samagotchi/testing/rspec.rb +3 -0
  93. data/lib/samagotchi/testing/sandbox.rb +3 -1
  94. data/lib/samagotchi/tool_activity.rb +17 -0
  95. data/lib/samagotchi/tool_declarations.rb +34 -0
  96. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  97. data/lib/samagotchi/tools/builtins.rb +4 -1
  98. data/lib/samagotchi/tools/delegate.rb +2 -1
  99. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  100. data/lib/samagotchi/tools/peers.rb +3 -0
  101. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  102. data/lib/samagotchi/turn_flow.rb +3 -1
  103. data/lib/samagotchi/update_command.rb +76 -28
  104. data/lib/samagotchi/update_hint.rb +4 -1
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/web/auto_update.rb +8 -8
  107. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  108. data/lib/samagotchi/web/message_display.rb +9 -0
  109. data/lib/samagotchi/web/participant_view.rb +2 -2
  110. data/lib/samagotchi/web/public/app.js +15 -3
  111. data/lib/samagotchi/web/public/block_open.js +13 -0
  112. data/lib/samagotchi/web/public/bubbles.js +4 -2
  113. data/lib/samagotchi/web/public/cards_place.js +30 -9
  114. data/lib/samagotchi/web/public/commands.js +4 -1
  115. data/lib/samagotchi/web/public/details_motion.js +2 -1
  116. data/lib/samagotchi/web/public/index.html +20 -14
  117. data/lib/samagotchi/web/public/objectives.css +80 -0
  118. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  119. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  120. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  121. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  122. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  123. data/lib/samagotchi/web/public/stage_model.js +5 -6
  124. data/lib/samagotchi/web/public/stage_view.js +8 -7
  125. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  126. data/lib/samagotchi/web/public/turn_block.js +34 -19
  127. data/lib/samagotchi/web/public/turn_html.js +4 -1
  128. data/lib/samagotchi/web/public/turn_model.js +1 -1
  129. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  130. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  131. data/lib/samagotchi/web/push/notifier.rb +7 -9
  132. data/lib/samagotchi/web/session_hub.rb +2 -1
  133. data/lib/samagotchi/web/session_show.rb +12 -1
  134. data/lib/samagotchi/web/update_ops.rb +2 -1
  135. data/lib/samagotchi/worker.rb +3 -0
  136. data/lib/samagotchi/worker_inbound.rb +16 -8
  137. metadata +22 -1
@@ -9,6 +9,7 @@ require_relative "desktop"
9
9
  require_relative "gem_update"
10
10
  require_relative "installed_gem"
11
11
  require_relative "live_versions"
12
+ require_relative "memory_bundle/file_standing"
12
13
  require_relative "memory_bundle/shipped_update"
13
14
  require_relative "memory_bundle/system_bundle"
14
15
  require_relative "session"
@@ -20,8 +21,8 @@ require "json"
20
21
  module Samagotchi
21
22
  # `chi update`: brings an installed chi up to date in one go: the system
22
23
  # bundle, the shipped bundles the user installed, and the macOS helper.
23
- # Unedited things update; edited ones are kept (.md) or replaced and
24
- # reported (hooks, plugin, rules, which didn't load once edited). Live
24
+ # Unedited things update; edited ones merge or stay behind (.md) or are replaced
25
+ # and reported (hooks, plugin, rules, which didn't load once edited). Live
25
26
  # workers and a running chi web are reported, never restarted. The bundles
26
27
  # chi didn't ship are only checked, under the chi that will run them
27
28
  # (BundleCommand::InstalledCheck): what it finds is rows that fail nothing.
@@ -34,10 +35,15 @@ module Samagotchi
34
35
  Updates this chi's install: the gem (from rubygems.org), then the
35
36
  system bundle, the bundles chi ships that you installed (chi bundle
36
37
  list), and the desktop helper (macOS; rebuilt only when its sources
37
- changed). Edited memory files are kept and reported; nothing is
38
- installed that isn't already, and old gem versions stay. The bundles
39
- chi doesn't ship are never changed: the new chi checks them and adds
40
- a row per warning or error (these don't fail the update).
38
+ changed). A memory file you edited takes the update in a text merge
39
+ (the old file goes to the bundle trash), or stays behind as it is when
40
+ no merge is possible, and the table says which and why; each run
41
+ retries a file left behind, though one an older chi marked, with a base
42
+ no installed chi shipped, merges only if the bundle takes out or rewords
43
+ no line it still holds. Nothing is
44
+ installed that isn't already, and old gem versions stay. The bundles chi doesn't ship are
45
+ never changed: the new chi checks them and adds a row per warning or
46
+ error (these don't fail the update).
41
47
  --dry-run show what would change; change nothing (with a newer
42
48
  gem to install, bundles chi doesn't ship aren't checked:
43
49
  that chi would)
@@ -76,8 +82,11 @@ module Samagotchi
76
82
 
77
83
  # A table row. info rows (live workers, chi web) say what runs; they
78
84
  # never fail. level ("warn", "error"; else nil) marks a finding on a bundle
79
- # chi didn't ship: it reports, changes nothing and fails nothing.
80
- Row = Struct.new(:component, :from, :to, :status, :failed, :kept, :level, keyword_init: true)
85
+ # chi didn't ship: it reports, changes nothing and fails nothing. kept: the
86
+ # memory files that stay behind; merged: the ones that took the update;
87
+ # trash_copy: where their old copies went, in full (the table's short: --json
88
+ # has the path); earlier: the files of the earlier merges the row names.
89
+ Row = Struct.new(:component, :from, :to, :status, :failed, :kept, :merged, :earlier, :trash_copy, :level, keyword_init: true)
81
90
 
82
91
  # What a row says after a change, and in a dry run.
83
92
  VERBS = { update: ["updated", "would update"], install: ["installed", "would install"],
@@ -95,6 +104,7 @@ module Samagotchi
95
104
  gem_update: nil, exec: nil, bundler: nil, home: ChiHome, env: ENV)
96
105
  @home = home
97
106
  @in_home = env.delete(IN_HOME_ENV) == "1"
107
+ @unattended = env[ChildEnv::UNATTENDED_UPDATE] == "1"
98
108
  # Built after the home is known: its install env goes into the home.
99
109
  @gem_update_given = gem_update
100
110
  @exec = exec || ->(argv) { Kernel.exec(*argv) }
@@ -147,7 +157,7 @@ module Samagotchi
147
157
  gem = gem_row(options)
148
158
  rows = [gem, system_bundle_row, *bundle_rows(options), *external_rows(options), desktop_row(options), *live_rows].compact
149
159
  if options[:json]
150
- @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || []) }))
160
+ @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || [], merged: row.merged || [], earlier: row.earlier || []) }))
151
161
  else
152
162
  print_table(rows)
153
163
  footers(options, rows).each { |line| @stdout.puts(line) }
@@ -279,17 +289,24 @@ module Samagotchi
279
289
  end
280
290
 
281
291
  def system_bundle_row
282
- result = MemoryBundle::SystemBundle.sync(dry_run: @dry_run)
292
+ system = MemoryBundle::SystemBundle
293
+ earlier = system.merge_notes
294
+ result = system.sync(dry_run: @dry_run)
295
+ # These rows have no place for the notes, which wait for one that has.
296
+ earlier = [] if %i[installed skipped failed].include?(result.status)
283
297
  row = Row.new(component: "system bundle", from: result.from, to: result.to)
298
+ notes = file_notes(row, system::BUNDLE_NAME, result.to, result.merged, result.behind, result.trash_copy, earlier: earlier)
284
299
  case result.status
285
300
  when :installed then done(row, :install)
286
- when :updated then done(row, :update, kept(row, result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
287
- when :restored then done(row, :restore).tap { |r| r.to = nil }
288
- when :up_to_date then up_to_date(row)
289
- when :newer_installed then skipped(row, "newer than this chi's #{result.to}: left").tap { |r| r.to = nil }
301
+ when :updated then done(row, :update, notes.empty? ? nil : notes.join("; "))
302
+ when :restored then done(row, :restore, notes.empty? ? nil : notes.join("; ")).tap { |r| r.to = nil }
303
+ when :merged, :up_to_date then same_version(row, notes + result.warnings, changed: result.merged.any?)
304
+ when :newer_installed then skipped(row, ["newer than this chi's #{result.to}: left", *notes].join("; ")).tap { |r| r.to = nil }
290
305
  when :skipped then skipped(row, result.error)
291
306
  else failed(row, result.error)
292
307
  end
308
+ system.clear_merge_notes(earlier, files: result.merged) if read_by_someone?
309
+ row
293
310
  end
294
311
 
295
312
  def bundle_rows(options)
@@ -320,14 +337,50 @@ module Samagotchi
320
337
 
321
338
  def bundle_row(update)
322
339
  row = Row.new(component: update.name, from: update.from, to: update.to)
340
+ earlier = update.status == :failed ? [] : Array(update.earlier)
341
+ notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
323
342
  case update.status
324
343
  when :would_update, :updated
325
- notes = [adds(update.adds), kept(row, update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
344
+ notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own].compact
326
345
  done(row, :update, notes.empty? ? nil : notes.join("; "))
327
- when :up_to_date then up_to_date(row)
328
- when :skipped then skipped(row, update.note)
329
- else failed(row, update.note)
346
+ when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note].compact, changed: Array(update.merged).any?)
347
+ when :skipped then skipped(row, [update.note, *notes].join("; "))
348
+ else failed(row, [update.note, *notes].compact.join("; "))
330
349
  end
350
+ # A failed row keeps them: the run didn't finish, and the next says what stands.
351
+ MemoryBundle::Provenance.new(name: update.name).clear_merge_notes(earlier, files: row.merged) if read_by_someone? && update.status != :failed
352
+ row
353
+ end
354
+
355
+ # What one bundle's memory files did, in FileStanding's words; the row keeps
356
+ # the file names and the trash copy's full line for --json. merged is what
357
+ # this run merged, earlier the files of the notes it shows.
358
+ # @return [Array<String>] the row's clauses
359
+ def file_notes(row, bundle, version, merged, behind, trash_copy, earlier: [])
360
+ merged = Array(merged)
361
+ behind = Array(behind)
362
+ row.merged = merged
363
+ row.earlier = earlier.map(&:file)
364
+ row.kept = behind.map(&:file)
365
+ @unsure_behind ||= behind.any?(&:unsure)
366
+ row.trash_copy = trash_copy
367
+ notes = MemoryBundle::FileStanding.clauses(bundle, version, merged: merged, behind: behind, earlier: earlier, dry_run: @dry_run)
368
+ trash_copy || earlier.any?(&:trash) ? [*notes, MemoryBundle::FileStanding::IN_THE_TRASH] : notes
369
+ end
370
+
371
+ # Whether a row printed now ends the notes for what it printed, this run's
372
+ # merges too. A dry run changes nothing, and an automatic update's rows may be
373
+ # read by no one (ChildEnv::UNATTENDED_UPDATE).
374
+ def read_by_someone? = !@dry_run && !@unattended
375
+
376
+ # A row at the same version: up to date only when nothing merged and nothing
377
+ # stays behind. No to, and no verb of its own: the clauses say it all.
378
+ def same_version(row, notes, changed:)
379
+ return up_to_date(row, notes.join("; ")) unless changed || row.kept.any?
380
+
381
+ row.to = nil
382
+ row.status = notes.join("; ")
383
+ row
331
384
  end
332
385
 
333
386
  def desktop_row(options)
@@ -420,6 +473,9 @@ module Samagotchi
420
473
  return "(dry run: nothing was changed)" if @dry_run
421
474
  return "some parts failed (see above)" if rows.any?(&:failed)
422
475
 
476
+ behind = rows.sum { |row| Array(row.kept).size }
477
+ return "#{MemoryBundle::FileStanding.stay_behind(behind, unsure: @unsure_behind)} (see above)" if behind.positive?
478
+
423
479
  # A finding on a bundle chi didn't ship repeats until its bundle changes; it isn't a change.
424
480
  changes = rows.reject(&:level)
425
481
  changes.all? { |r| r.status.start_with?("up to date", "skipped", "not installed", "couldn't check") } ? "everything is up to date" : "done"
@@ -434,9 +490,9 @@ module Samagotchi
434
490
  row
435
491
  end
436
492
 
437
- def up_to_date(row)
493
+ def up_to_date(row, note = nil)
438
494
  row.to = nil
439
- row.status = "up to date"
495
+ row.status = note.to_s.empty? ? "up to date" : "up to date (#{note})"
440
496
  row
441
497
  end
442
498
 
@@ -456,14 +512,6 @@ module Samagotchi
456
512
  members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
457
513
  end
458
514
 
459
- def kept(row, files, bundle)
460
- row.kept = files.dup
461
- return nil if row.kept.empty?
462
-
463
- verb = @dry_run ? "would keep" : "kept"
464
- "#{verb} your edits in #{row.kept.join(", ")}: chi bundle diff #{bundle} #{row.kept.first}"
465
- end
466
-
467
515
  def replaced(files)
468
516
  return nil if files.empty?
469
517
 
@@ -43,7 +43,10 @@ module Samagotchi
43
43
  end
44
44
 
45
45
  def message(version, shipped_dir:, helper:)
46
- bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count { |r| r.status == :would_update }
46
+ # A retry that would merge is an update too; one that only records a reason isn't.
47
+ bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count do |r|
48
+ r.status == :would_update || (r.status == :would_merge && r.merged.any?)
49
+ end
47
50
  parts = []
48
51
  parts << (bundles == 1 ? "1 bundle" : "#{bundles} bundles") if bundles.positive?
49
52
  parts << "the desktop helper" if helper&.stale?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.148.0"
4
+ VERSION = "0.150.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -197,7 +197,7 @@ module Samagotchi
197
197
  end
198
198
 
199
199
  # A newer chi this didn't install, settled: a run of its own from here.
200
- # The terminal's table already told what `chi update` kept.
200
+ # The terminal's table already told what `chi update` merged or left behind.
201
201
  def adopt_installed
202
202
  return unless @restart == "idle" && @seen
203
203
 
@@ -215,10 +215,10 @@ module Samagotchi
215
215
  return Log.info(:web, "auto_update_deferred", version: version, why: result) if result.is_a?(Symbol)
216
216
 
217
217
  rows = result.rows
218
- record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: kept_files(rows),
219
- phase: "installed")
218
+ record = AutoUpdateRecord.new(version: version, from: VERSION, at: @clock.call.iso8601, kept: files_in(rows, "kept"),
219
+ merged: files_in(rows, "merged"), phase: "installed")
220
220
  if result.done?
221
- Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size)
221
+ Log.info(:web, "auto_update_installed", version: version, kept: record.kept.size, merged: record.merged.size)
222
222
  installed(record)
223
223
  else
224
224
  failed(record, rows)
@@ -232,7 +232,7 @@ module Samagotchi
232
232
  return carry_on(save(record)) unless @restart == "never"
233
233
 
234
234
  save(record.with(phase: "done", pushed: true))
235
- @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size)
235
+ @hub.publish_notice(kind: "installed", version: record.version, kept: record.kept.size, merged: record.merged.size)
236
236
  end
237
237
 
238
238
  # The next step of a run on record.
@@ -328,7 +328,7 @@ module Samagotchi
328
328
  record = restart_workers(record) || record
329
329
  Log.info(:web, "auto_update_done", version: record.version, restarted: record.restarted, left: record.left)
330
330
  save(record.with(phase: record.left_to_try?(@clock.call, RETRY_FOR) ? RETRYING : "done", pushed: true))
331
- @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size,
331
+ @hub.publish_notice(kind: "updated", version: record.version, kept: record.kept.size, merged: record.merged.size,
332
332
  restarted: record.restarted, left: record.left)
333
333
  end
334
334
 
@@ -366,8 +366,8 @@ module Samagotchi
366
366
  installed: gem_installed)
367
367
  end
368
368
 
369
- def kept_files(rows)
370
- rows.flat_map { |row| Array(row["kept"]).map(&:to_s) }.uniq
369
+ def files_in(rows, field)
370
+ rows.flat_map { |row| Array(row[field]).map(&:to_s) }.uniq
371
371
  end
372
372
  end
373
373
  end
@@ -13,8 +13,10 @@ module Samagotchi
13
13
  # +phase+: installed, workers, web (AutoUpdate::OPEN_PHASES), retrying,
14
14
  # done or failed. +restarted+ and +left+ count the stale workers (`workers` in
15
15
  # the file); +at+ is when the install ended, an ISO 8601 string.
16
- AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :phase, :restarted, :left, :detail, :pushed) do
17
- def initialize(version:, phase:, from: nil, at: nil, kept: [], restarted: 0, left: 0, detail: nil, pushed: false)
16
+ # +kept+ and +merged+ name the edited memory files that stayed behind and
17
+ # that took the update (chi update --json's).
18
+ AutoUpdateRecord = Data.define(:version, :from, :at, :kept, :merged, :phase, :restarted, :left, :detail, :pushed) do
19
+ def initialize(version:, phase:, from: nil, at: nil, kept: [], merged: [], restarted: 0, left: 0, detail: nil, pushed: false)
18
20
  super
19
21
  end
20
22
 
@@ -30,14 +32,14 @@ module Samagotchi
30
32
 
31
33
  workers = hash["workers"].is_a?(Hash) ? hash["workers"] : {}
32
34
  new(version: version, phase: phase, from: hash["from"], at: hash["at"],
33
- kept: Array(hash["kept"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
35
+ kept: Array(hash["kept"]).grep(String), merged: Array(hash["merged"]).grep(String), restarted: workers["restarted"].to_i, left: workers["left"].to_i,
34
36
  detail: hash["detail"], pushed: hash["pushed"] == true)
35
37
  end
36
38
 
37
39
  # What goes in the file, for .from_h to read back.
38
40
  # @return [Hash{String => Object}]
39
41
  def to_store
40
- { "version" => version, "from" => from, "at" => at, "kept" => kept, "phase" => phase,
42
+ { "version" => version, "from" => from, "at" => at, "kept" => kept, "merged" => merged, "phase" => phase,
41
43
  "workers" => { "restarted" => restarted, "left" => left }, "detail" => detail,
42
44
  "pushed" => pushed ? true : nil }.compact
43
45
  end
@@ -7,6 +7,7 @@ require_relative "../context_note"
7
7
  require_relative "../image_store"
8
8
  require_relative "../message_parts"
9
9
  require_relative "../message_time"
10
+ require_relative "../objectives"
10
11
  require_relative "../output_formatter"
11
12
  require_relative "../recap_store"
12
13
  require_relative "../ref"
@@ -95,11 +96,19 @@ module Samagotchi
95
96
  list = Array(msgs)
96
97
  # The outputs' ✂ marks (LLM context edits), by id, for the parts.
97
98
  marks = parts ? MessageParts.edit_marks(list) : {}
99
+ # The list before each objectives note, for what its entry says changed.
100
+ objectives = nil
98
101
  list.each_with_index do |m, index|
99
102
  role = (m[:role] || m["role"]).to_s
100
103
  content = (m[:content] || m["content"]).to_s
101
104
  if Samagotchi::ContextNote.note?(m)
102
105
  note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
106
+ payload = Samagotchi::ContextNote.fetch(m, :objectives)
107
+ entry = payload && Samagotchi::Objectives.entry(payload, before: objectives)
108
+ if entry
109
+ note[:objectives] = entry
110
+ objectives = Samagotchi::Objectives.from_payload(payload)
111
+ end
103
112
  # A context wake turn's note starts that turn (Steer.turn_prompt?):
104
113
  # the page opens the turn with it and pairs it with its record.
105
114
  if Samagotchi::Steer.turn_prompt?(m)
@@ -82,10 +82,10 @@ module Samagotchi
82
82
  after_generation after_tool_call after_turn answer_display before_generation before_tool_call before_turn
83
83
  card command_output command_queued command_ran context_added context_status empty_answer_retry
84
84
  generation_cancelled generation_completed generation_progress generation_started guardrail_warning hook_notice
85
- llm_context_edited merged_input participants_changed pending_input_merged plugin_init_finished
85
+ llm_context_edited merged_input objectives_updated participants_changed pending_input_merged plugin_init_finished
86
86
  plugin_init_started plugin_init_wait prompt_restored question question_relay reasoning_history_refused
87
87
  recap_ready reminder_injected sampling_refused session_end session_start side_usage steer_cut thinking_dropped
88
- thinking_refused tool_dispatch_completed tool_dispatch_started used_memories_updated working_set_updated
88
+ thinking_refused tool_dispatch_completed tool_dispatch_started turn_rolled_back used_memories_updated working_set_updated
89
89
  ].freeze
90
90
 
91
91
  # A merged input line's saved sources a participant sees: a person's
@@ -54,6 +54,7 @@ import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
54
54
  import { createCardsPlace } from "./cards_place.js";
55
55
  import { createStageView } from "./stage_view.js";
56
56
  import { createWorkingSet, groupItems } from "./working_set_view.js";
57
+ import { createObjectives } from "./objectives_wiring.js";
57
58
  import { turnGroups } from "./timing.js";
58
59
  import { createGapClock, lastActivity } from "./time_gaps.js";
59
60
  import { createMarkdownPlace } from "./markdown_place.js";
@@ -421,6 +422,10 @@ const deliveryControl = createDeliveryControl({
421
422
  // This tab's id in the session's events: tells its own prompts from the
422
423
  // ones other clients (an attached TUI, another tab, reminders) sent.
423
424
  const clientId = newClientId();
425
+ const objectives = createObjectives({
426
+ el: $("#objectivesStrip"), historyEl, session: () => store.selected, running: () => liveTurn.running, holding: () => turnDom.holds(), commands: () => store.current.commands,
427
+ clientId, sendCommand, sendTurn, follow: () => followWokenWorker(), toast: (...args) => showToast(...args),
428
+ });
424
429
  // The question and continue cards in the history (question_cards.js).
425
430
  const questionCards = createQuestionCards({
426
431
  sessionId: () => store.selected,
@@ -1017,6 +1022,7 @@ function forgetSession({ keepVerify = false } = {}) {
1017
1022
  });
1018
1023
  commandList.close();
1019
1024
  resetTurnView();
1025
+ objectives.reset();
1020
1026
  seedInitTasks(initTasks, []);
1021
1027
  renderInitRow();
1022
1028
  setMarkdownWarning(null);
@@ -1178,11 +1184,13 @@ function renderSessionView(data, { kept = [] } = {}) {
1178
1184
  if (data.messages && data.messages.length) renderHistory(data.messages);
1179
1185
  else renderHistory(data.history || []);
1180
1186
  seedWorkingSet(data);
1187
+ objectives.seed(data.objectives);
1181
1188
  restoreCommandBubbles(historyEl, kept);
1182
1189
  // A failed turn's prompt went back to the user: shown after the
1183
1190
  // conversation, marked failed, as it was live (prompt_restored).
1184
- if (data.failed_turn) {
1185
- makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
1191
+ const failedPrompt = data.failed_turn ? makeUserBubble(data.failed_turn.prompt, { state: "failed" }) : null;
1192
+ if (failedPrompt) {
1193
+ failedPrompt.dataset.failedTurn = "1";
1186
1194
  addEndBubble(failedTurnText(data.failed_turn));
1187
1195
  }
1188
1196
  seedSeenFromDom();
@@ -1196,7 +1204,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1196
1204
  // A live worker's last cards and between-turns notices, where they
1197
1205
  // arrived; the running turn's go into its step once it is drawn.
1198
1206
  const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
1199
- placeSnapshotCards(snapshotCards.placed);
1207
+ // Above a failed turn's prompt: it came after them, and it is no turn.
1208
+ placeSnapshotCards(snapshotCards.placed, { before: failedPrompt });
1200
1209
  // The recap saved with the session (also a stopped one's), else a live
1201
1210
  // worker's from before saved_recap existed. After the cards: the last
1202
1211
  // turn's check-in notice goes to the end, and the recap closes the page.
@@ -1761,6 +1770,8 @@ const streamHandlers = createStreamHandlers({
1761
1770
  // cards_place.js
1762
1771
  upsertCard, refreshCardBodies,
1763
1772
  syncModelPick: startPage.commandRan,
1773
+ objectivesUpdated: (data) => objectives.update(data),
1774
+ cardActionFailed: cards.actionFailed,
1764
1775
  });
1765
1776
 
1766
1777
  async function refreshTimingFromSession() {
@@ -2204,6 +2215,7 @@ store.on("settingsPanel", () => settingsControl.refresh());
2204
2215
  store.on("workingSetStrategy", () => workingSet?.strategyChanged());
2205
2216
  store.on("featureGates", () => featureGates.check(store.current));
2206
2217
  store.on("composer", updateComposerMode);
2218
+ store.on("composer", () => objectives.redraw());
2207
2219
  // The answers' Revise buttons follow liveTurn.running too (none on the
2208
2220
  // running turn's answer), which touches the composer.
2209
2221
  store.on("composer", () => reviseStacks.decorate());
@@ -0,0 +1,13 @@
1
+ // Whether a finished turn's steps block stays open: a completed turn's closes
2
+ // to its summary line, whatever its steps said (opened, it shows the outline).
3
+ // A canceled or failed turn with an outline stays open: its last narrated step
4
+ // is the only text the reader gets. A "gone" end (the worker left) counts as
5
+ // completed: its partial answer bubble carries the text, and a reload, which
6
+ // can't tell it from one, draws the same. The live end, the stage's hand-off
7
+ // and a reload all ask here.
8
+ //
9
+ // A module of its own: static JS is cached at unversioned URLs, and a new
10
+ // export on an old module would blank the page.
11
+ export function openAfterEnd(kind, outlined) {
12
+ return !!outlined && (kind === "canceled" || kind === "failed");
13
+ }
@@ -26,8 +26,10 @@ export function removeQueuedBubble(bubble) {
26
26
  if (slot && slot.childElementCount === 0) slot.remove();
27
27
  }
28
28
 
29
- // A prompt that started a turn: not a line steered into a running one.
30
- export const TURN_PROMPT = ".bubble.user:not(.steered)";
29
+ // A prompt that started a turn: not a line steered into a running one, nor a
30
+ // failed turn's prompt, drawn again after the history (it went back to its
31
+ // sender: the session and the turn count have no such turn).
32
+ export const TURN_PROMPT = ".bubble.user:not(.steered):not(.failed)";
31
33
 
32
34
  // The history's turn prompts, oldest first (what turns_since counts).
33
35
  export function turnPromptBubbles(historyEl) {
@@ -46,8 +46,10 @@ export function turnAnswerEl(turnEls) {
46
46
  // (bubbles.js createBubbles)
47
47
  // @param followWokenWorker () follows the worker a card's command woke
48
48
  // @param api {getSession, sendCommand} (data.js)
49
- // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries),
49
+ // @return {cardElement(id), upsertCard(card, {before}), placeSnapshotCards(entries, {before}),
50
50
  // refreshCardBodies(), onCardAction(event)}
51
+ const HELD_UNTIL_ANSWERED = /^\/objectives --card /;
52
+
51
53
  export function createCardsPlace({
52
54
  historyEl, turnDom, findInTurnRoots, selected, clientId, questionCards, bubbles,
53
55
  followWokenWorker, api = { getSession, sendCommand },
@@ -61,9 +63,12 @@ export function createCardsPlace({
61
63
  // running turn's card as a row of its step; else before +before+ or at the
62
64
  // end of the history). A running turn's card with actions asks the user
63
65
  // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
64
- // block, not into a step that collapses when the next one starts.
66
+ // block, not into a step that collapses when the next one starts. A turn
67
+ // still sliding away arrives first: its list of pieces holds this card's
68
+ // element, and an update may swap that (drawCard, a notice to a card).
65
69
  function upsertCard(card, { before = null } = {}) {
66
70
  if (!card || !card.id) return;
71
+ turnDom.settleFold?.();
67
72
  const existing = cardElement(card.id);
68
73
  if (existing) {
69
74
  const drawn = drawCard(existing, card);
@@ -79,11 +84,13 @@ export function createCardsPlace({
79
84
  }
80
85
 
81
86
  // A snapshot's cards and notices, each above the first turn after it; a
82
- // turn's own notice where it showed live (placeTurnNotice).
83
- function placeSnapshotCards(entries) {
87
+ // turn's own notice where it showed live (placeTurnNotice). +end+: what
88
+ // closes the history below them (a failed turn's prompt), for those with
89
+ // no turn after them.
90
+ function placeSnapshotCards(entries, { before: end = null } = {}) {
84
91
  const users = turnPromptBubbles(historyEl);
85
92
  for (const entry of entries) {
86
- const before = cardPlace(users, entry);
93
+ const before = cardPlace(users, entry) || end;
87
94
  if (isCard(entry)) upsertCard(entry, { before });
88
95
  else if (entry.type === "question") placeResolvedQuestion(entry, before);
89
96
  else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
@@ -162,7 +169,18 @@ export function createCardsPlace({
162
169
  }, 150);
163
170
  }
164
171
 
165
- // A card's action button (a click anywhere in the turn roots).
172
+ // Its command line's card is back in play: the worker refused the action.
173
+ function actionFailed(line) {
174
+ for (const button of findInTurnRoots(".plugin-card .card-action")) {
175
+ if (button.dataset.command === line) {
176
+ button.closest(".plugin-card").querySelectorAll(".card-action").forEach((b) => { b.disabled = false; });
177
+ }
178
+ }
179
+ }
180
+
181
+ // A card's action button (a click anywhere in the turn roots). A proposal
182
+ // card's buttons stay off until its folded card replaces them or the
183
+ // action fails (actionFailed): a second tap has nothing to do.
166
184
  function onCardAction(e) {
167
185
  const button = e.target.closest(".plugin-card .card-action");
168
186
  const id = selected();
@@ -170,7 +188,9 @@ export function createCardsPlace({
170
188
  const card = button.closest(".plugin-card");
171
189
  const err = card.querySelector(".card-error");
172
190
  err.classList.add("hidden");
173
- button.disabled = true;
191
+ const held = HELD_UNTIL_ANSWERED.test(button.dataset.command);
192
+ const off = held ? [...card.querySelectorAll(".card-action")] : [button];
193
+ off.forEach((b) => { b.disabled = true; });
174
194
  api.sendCommand(id, button.dataset.command, { clientId, card: true })
175
195
  .then(() => {
176
196
  // The command woke the worker: follow its stream if none is open
@@ -180,9 +200,10 @@ export function createCardsPlace({
180
200
  .catch((error) => {
181
201
  err.textContent = error.message;
182
202
  err.classList.remove("hidden");
203
+ off.forEach((b) => { b.disabled = false; });
183
204
  })
184
- .finally(() => { button.disabled = false; });
205
+ .finally(() => { if (!held) button.disabled = false; });
185
206
  }
186
207
 
187
- return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction };
208
+ return { cardElement, upsertCard, placeSnapshotCards, refreshCardBodies, onCardAction, actionFailed };
188
209
  }
@@ -163,6 +163,8 @@ export function continueLine(choice, reason = "") {
163
163
  const QUEUED_TEXT = "queued: runs after this turn";
164
164
  const APPLY_TEXT = "Apply (restarts the cache)";
165
165
 
166
+ const OBJECTIVES_LINE = /^\/objectives\b/;
167
+
166
168
  export function commandView(event, myId) {
167
169
  const own = isOwn(event.client_id, myId);
168
170
  const waiting = event.waits === "turn_end";
@@ -181,7 +183,8 @@ export function commandView(event, myId) {
181
183
  commandId: event.command_id || null,
182
184
  confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
183
185
  // A card's action (Nudge…): no bubble unless it says something back.
184
- hidden: event.card === true && !event.output && event.status !== "error",
186
+ // A tap's /objectives edit: the strip and the entry are its answer.
187
+ hidden: event.card === true && event.status !== "error" && (!event.output || OBJECTIVES_LINE.test(event.line || "")),
185
188
  };
186
189
  }
187
190
 
@@ -56,7 +56,8 @@ export function installDetailsMotion({ root, selector, motion = canAnimate }) {
56
56
  const summary = e.target.closest?.("summary");
57
57
  const details = summary?.parentElement;
58
58
  if (!details || !details.matches?.(selector) || summary.parentElement !== details) return;
59
- if (e.target.closest("a, button, input, label") !== null && e.target !== summary) return;
59
+ // A live part of the summary (a steps block's footprint opens its own view).
60
+ if (e.target.closest("a, button, input, label, .ws-foot") !== null && e.target !== summary) return;
60
61
  if (!motion(details)) return;
61
62
  e.preventDefault();
62
63
  if (busy.has(details)) return;