samagotchi 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80a8fec85f3a253ce7f762f3c8881ee0c49d6e73117f205a53cb64482e7a9cc3
4
- data.tar.gz: 6e21caec0f46b5a2831382a2c77842e5d3935e363b6b6ea96f5d136a24ffa1eb
3
+ metadata.gz: 9290117f26040b725e12c5cd18bf2a8858a573f34e2323e5a56517c030f95d7f
4
+ data.tar.gz: 7e5d1e2e8a285424b907172038f6bab1045b1388129241cc1a32cb26132437ae
5
5
  SHA512:
6
- metadata.gz: ad7fd8882239bb39478cddc39d48e85c2dedfb782d73da193b7dfd401b40599aaf2385462c911d633ebdf78d2f2841d3c400367c1e6feefc300778e617afa425
7
- data.tar.gz: 2104f11e65bfdf5f9b82a92a2e1281cda2c6a1c6c28a58bd7eedc89e34492f47f34f8aec229aba66d6c875b465ae56f181a0d0a539b039c5ed79a4830c1e4cd0
6
+ metadata.gz: d70bd257f5b82a2057077e2142adf5601f4bc652a0e820c695b5ecff305156c0270815374054e41d6403ecb0ee4579fd75fda84f670094e99a21f7cf73e34b5c
7
+ data.tar.gz: 961269d190e236ecbe3a3ce0dae6d18d86adf93f5c16aa8246dd91e10a43b73152c8595a4e6377491aaf86e74d8558b225893364dc6dd226634d6b94a7484504
data/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.9.0] - 2026-10-01
12
+
13
+ ### Changed
14
+
15
+ - A small change to a memory or skill can go through `edit` on its file instead of rewriting it all with
16
+ `memory_write`; the prompt, the memory guide and the nudge below say so. `write`/`edit` on a memory's file
17
+ refresh its line in `index.md` (date, size; the description is kept), and the default guardrails no longer ask
18
+ before them as "writes outside the repository".
19
+ - The system prompt no longer tells the model to keep `index.md` updated (`memory_write` does it); its result
20
+ says "Index line refreshed automatically." instead of naming the index file.
21
+ - `skills` bundle 0.1.1 (`chi update`; needs chi 0.9.0): a skill changed some other way (an `execute` running `sed`) counts as
22
+ updated: you get the usual "skill X updated" line, `/skill diff` has the old text, and the nudge and the turn-end
23
+ "wasn't updated" line no longer fire for it. The nudge asks to `edit` the changed step rather than rewrite the
24
+ whole skill.
25
+
26
+ ### Fixed
27
+
28
+ - `chi bundle install` of a bundle that's already installed no longer says "Skipped X (already exists; use --force
29
+ to overwrite)" for a file that's the same as the bundle's; it skips it quietly and says once that
30
+ `chi bundle upgrade NAME` updates the bundle and keeps local edits. A file skipped for a local edit no longer
31
+ also reports a checksum mismatch.
32
+ - `chi bundle install` and `upgrade` no longer list a bundle's unchanged guardrail rule files as "Installed"; they
33
+ are skipped as already up to date like its memory files.
34
+ - A memory file you edited, then `chi bundle install` of its bundle again (skipped for the edit), no longer gets
35
+ overwritten by the next `chi bundle upgrade`: the re-install kept your edit as the bundle's base, so the upgrade
36
+ took it as unedited.
37
+ - `chi sessions list --sort/--order` is no longer ignored with `--live`, `--cwd` or `--format`.
38
+ - A tool's time in the web's turn timing and the debug log's `tool_call_completed ms:` no longer counts the wait for
39
+ a guardrail approval; the log line shows the wait as `waited_ms:`.
40
+
41
+ Update with `chi update` (bundles: `skills` 0.1.1).
42
+
11
43
  ## [0.8.1] - 2026-10-01
12
44
 
13
45
  ### Added
@@ -481,7 +513,8 @@ and long-lived sessions.
481
513
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
482
514
  hotkey panel that send selected text or the clipboard to your sessions.
483
515
 
484
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.8.1...HEAD
516
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.9.0...HEAD
517
+ [0.9.0]: https://github.com/dm1try/samagotchi/compare/v0.8.1...v0.9.0
485
518
  [0.8.1]: https://github.com/dm1try/samagotchi/compare/v0.8.0...v0.8.1
486
519
  [0.8.0]: https://github.com/dm1try/samagotchi/compare/v0.7.0...v0.8.0
487
520
  [0.7.0]: https://github.com/dm1try/samagotchi/compare/v0.6.0...v0.7.0
data/docs/guardrails.md CHANGED
@@ -126,6 +126,12 @@ All the fields a rule gives must match. Absolute and `**/` globs match the
126
126
  resolved path; other globs match the path relative to the repo root. Paths
127
127
  resolve the way the tools resolve them (against the cwd; `~` expanded).
128
128
 
129
+ `outside_repo` leaves out a memory's file: a `*.md` right in the project or
130
+ system memories folder (not `index.md`, not a hidden file). `write`/`edit`
131
+ there is what `memory_write` does unasked, and the model is told to `edit` a
132
+ memory for a small change. The memories folder's `index.md`, `.bundles/`, and
133
+ the rest of chi's config folder still count as outside.
134
+
129
135
  To switch off single rules (a bundle's, say) without editing its files, list
130
136
  them under `disable:`. A plain id switches off every rule with that id; `bundle:id`
131
137
  only that bundle's:
data/docs/memory.md CHANGED
@@ -12,7 +12,8 @@ Tool behavior:
12
12
  - `memory_read`: `scope` is optional.
13
13
  - If `scope` is provided (`project` or `system`), only that scope is read.
14
14
  - If `scope` is omitted, read falls back from project to system.
15
- - `memory_write`: `scope` is required (`project` or `system`). The entry name is passed via the `name` parameter (not `path` — the file tools use `path`). On success, the return value includes the full file path, so you can use the `edit` tool directly for targeted updates.
15
+ - `memory_write`: `scope` is required (`project` or `system`). The entry name is passed via the `name` parameter (not `path` — the file tools use `path`). On success, the return value includes the full file path. For a small change to an existing memory (a step, a line) the model is told to `edit` that file rather than rewrite it all with `memory_write`.
16
+ - `write`/`edit` that change a `*.md` right in a scope dir refresh its managed `index.md` line (date, bytes; the description is kept), as `memory_write` does. A memory created with `write` gets a line with no description; any other `.md` written there gets a line too. Model overlays (`<name>.<key>.md` next to `<name>.md`), files in another project's folder and files changed by `execute` are left out.
16
17
 
17
18
  ## Model-Specific Memory Overlays
18
19
 
@@ -51,8 +52,9 @@ chi is told about skills by the system bundle (`identity.md`, every turn, and
51
52
  looks unexpected on the way (a failing check, a warning the skill doesn't
52
53
  mention), chi stops, says what it saw and asks how to go on.
53
54
  - **Updating.** When a step turned out different (a renamed script, an extra
54
- step), chi fixes the skill in the same turn: those steps changed, the rest
55
- kept, a dated Changelog line added. No confirmation.
55
+ step), chi fixes the skill in the same turn: those steps changed (with `edit`
56
+ on its file, so a small fix doesn't resend the whole skill), the rest kept, a
57
+ dated Changelog line added. No confirmation.
56
58
 
57
59
  A skill is plain Markdown, no frontmatter:
58
60
 
data/docs/plugins.md CHANGED
@@ -940,16 +940,24 @@ skills> skill release updated (+2 −1): 1. Run `scripts/verify.sh`; stop if it
940
940
  The line is the file on disk changing, whatever the tool answered; a denied
941
941
  write shows nothing.
942
942
 
943
+ A skill read in the turn is also watched for changes made another way (an
944
+ `execute` running `sed`, a script): after each other tool call, and at the
945
+ turn's end, its file is compared with the content last seen (at the read, or
946
+ after the last write). A change shows the same line, keeps the content last
947
+ seen as a version (so `/skill diff` has it) and counts as the skill updated.
948
+ A plain read keeps no version.
949
+
943
950
  **The nudge** (`nudge: true`). Some models, finding a skill's step broken,
944
951
  skip it and go on without fixing the skill. In a turn that read a skill
945
952
  (`memory_read` of a `skill_*` name, or `read` of its file), the first failing
946
953
  tool call after it (an `execute` that exited non-zero, a tool error) steers
947
954
  the model once: *"A step of skill release failed. Find out why before skipping
948
- it; if the skill is out of date, fix it now: memory_write the whole skill,
949
- its title and every section as they were, that step fixed, a Changelog line
950
- added."* If the turn ends with a failed step and the
951
- skill not rewritten, one line says so: `skill release was followed, a step
952
- failed, the skill wasn't updated`. A failure unrelated to the skill (a test
955
+ it; if the skill is out of date, fix it now: edit the step that changed in
956
+ its file (or memory_write the whole skill) and add a Changelog line."* No
957
+ steer once a skill read this turn has changed, by any tool. If the turn ends
958
+ with a failed step and no skill read changed, one line says so: `skill
959
+ release was followed, a step failed, the skill wasn't updated`. A failure
960
+ unrelated to the skill (a test
953
961
  meant to fail) can set it off too: once per turn, and only after a skill was
954
962
  read.
955
963
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.0
3
+ version: 0.1.1
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:76758d41e75db107e0a6f7ce35b9eac4944052746af16aeba30f38210cc436dc
10
- requires_chi: ">= 0.4.0"
9
+ sha256: sha256:ecf76de5d0f4d07a0e87b96cdc13b0bbc22369f8eea843bfd22c6fade469dfca
10
+ requires_chi: ">= 0.9.0"
@@ -13,13 +13,20 @@
13
13
  # shows a line: "skill release updated (+2 −1): …" or "skill release saved
14
14
  # (project, 14 lines)".
15
15
  #
16
+ # A skill read in this turn is also watched for changes made some other way
17
+ # (an execute running sed, a script): after each other tool call, and at the
18
+ # turn's end, its file is compared with the content last seen (at the read,
19
+ # or after the last write above); a change keeps that content as a version,
20
+ # shows the same line and counts as the skill updated.
21
+ #
16
22
  # The nudge (nudge: true), for models that skip a failing step instead of
17
23
  # fixing the skill: in a turn that read a skill (memory_read of a skill_*
18
24
  # name, or a read of its file), the first failing tool call after it (an
19
25
  # execute with "exit: N", N ≠ 0, or with no exit line an Error: line near
20
26
  # the top; any tool's "[tool] Error: …") steers the model once to find out
21
- # why and fix the skill. At the turn's end, a failed step with no rewrite of
22
- # the skill gets a notice line.
27
+ # why and fix the skill, unless a skill read was changed already (by any
28
+ # tool, see above). At the turn's end, a failed step with no change to a
29
+ # skill read gets a notice line.
23
30
  #
24
31
  # Settings (config.yml, bundles: skills:):
25
32
  # history_keep: 20 older versions kept per skill
@@ -31,8 +38,7 @@ class Plugin
31
38
  WRITE_TOOLS = %w[memory_write write edit].freeze
32
39
  NOTICE_WIDTH = 60 # the changed line in an update's notice
33
40
  NUDGE = "A step of skill %s failed. Find out why before skipping it; if the skill is out of date, fix it now: " \
34
- "memory_write the whole skill, its title and every section as they were, that step fixed, a Changelog " \
35
- "line added."
41
+ "edit the step that changed in its file (or memory_write the whole skill) and add a Changelog line."
36
42
  NOT_FAILURES = %w[memory_read memory_write].freeze
37
43
  USAGE = "usage: /skill save [name] [--system] | list | show <name> | diff <name> [N]"
38
44
 
@@ -79,16 +85,38 @@ class Plugin
79
85
  def after_tool_call(event, ctx)
80
86
  stash = @stash
81
87
  @stash = nil
88
+ stash = nil unless stash && stash[:tool] == event[:tool].to_s
89
+ changed_elsewhere(ctx) unless stash
82
90
  step_failed(event) if @nudge && !@read.empty?
83
- return unless stash && stash[:tool] == event[:tool].to_s
91
+ return unless stash
84
92
 
85
93
  now = File.file?(stash[:path]) ? File.read(stash[:path]) : nil
86
94
  return if now.nil? || now == stash[:old]
87
95
 
88
- @written << stash[:name]
96
+ @written |= [stash[:name]]
97
+ @seen[stash[:name]] = { path: stash[:path], scope: stash[:scope], content: now }
89
98
  ctx.notify(change_notice(stash, now))
90
99
  end
91
100
 
101
+ # A skill read this turn whose file differs from the content last seen was
102
+ # changed by something other than the write tools (an execute's sed, say):
103
+ # keep what was there as a version, show the change, count the skill as
104
+ # updated (any change counts, the user's own edit too). A file gone since
105
+ # is left alone.
106
+ def changed_elsewhere(ctx)
107
+ @seen.each do |name, seen|
108
+ now = File.file?(seen[:path]) ? File.read(seen[:path]) : nil
109
+ next if now.nil? || now == seen[:content]
110
+
111
+ keep_version(ctx, seen[:scope], name, seen[:content]) if seen[:content]
112
+ ctx.notify(change_notice({ name: name, scope: seen[:scope], old: seen[:content] }, now))
113
+ seen[:content] = now
114
+ @written |= [name]
115
+ end
116
+ rescue SystemCallError => e
117
+ ctx.log.warn(:watch_failed, error: e.class.name, msg: e.message)
118
+ end
119
+
92
120
  def change_notice(stash, now)
93
121
  name = stash[:name]
94
122
  return "skill #{name} saved (#{stash[:scope]}, #{now.lines.size} lines)" unless stash[:old]
@@ -105,21 +133,42 @@ class Plugin
105
133
 
106
134
  def reset_turn
107
135
  @read = [] # skills read this turn, in order
136
+ @seen = {} # of those, name => {path:, scope:, content:} last seen
108
137
  @written = [] # skills changed this turn
109
138
  @failed = false # a step failed after a skill was read
110
139
  @nudged = false
111
140
  end
112
141
 
113
142
  def note_read(tool, event)
114
- names = case tool
143
+ found = case tool
115
144
  when "memory_read"
145
+ scope = event.dig(:call, :scope).to_s.strip
146
+ scope = nil unless memory_dirs.key?(scope)
147
+ # A skill not written yet is watched where memory_write would
148
+ # put it by default: the read scope, else project.
116
149
  event.dig(:call, :content).to_s.split(",").map(&:strip).select { |entry| entry.start_with?("skill_") }
117
- .filter_map { |entry| skill_name(entry) }
150
+ .filter_map do |entry|
151
+ name = skill_name(entry) or next
152
+ unwritten = File.join(memory_dirs[scope || "project"], "skill_#{name}.md")
153
+ [name, skill_path(name, scope: scope) || unwritten]
154
+ end
118
155
  when "read"
119
- Array(event.dig(:targets, :paths)).filter_map { |path| skill_at(path)&.last }
156
+ Array(event.dig(:targets, :paths)).filter_map { |path| (at = skill_at(path)) && [at.last, path] }
120
157
  else []
121
158
  end
122
- @read |= names
159
+ found.each { |name, path| watch(name, path) }
160
+ @read |= found.map(&:first)
161
+ end
162
+
163
+ # Remember a read skill's content (nil: not written yet) the first time in
164
+ # a turn: a later read mustn't hide a change made in between.
165
+ def watch(name, path)
166
+ return if @seen.key?(name) || path.nil?
167
+
168
+ content = File.file?(path) ? File.read(path) : nil
169
+ @seen[name] = { path: File.expand_path(path), scope: skill_at(path)&.first, content: content }
170
+ rescue SystemCallError
171
+ nil
123
172
  end
124
173
 
125
174
  def step_failed(event)
@@ -127,7 +176,7 @@ class Plugin
127
176
  return if NOT_FAILURES.include?(tool) || !failure?(tool, event[:output].to_s)
128
177
 
129
178
  @failed = true
130
- return if @nudged
179
+ return if @nudged || @read.any? { |name| @written.include?(name) }
131
180
 
132
181
  @nudged = !!event[:steer]&.call(format(NUDGE, @read.join(", ")))
133
182
  end
@@ -146,6 +195,7 @@ class Plugin
146
195
  end
147
196
 
148
197
  def after_turn(ctx)
198
+ changed_elsewhere(ctx)
149
199
  return unless @nudge && @failed
150
200
 
151
201
  missed = @read - @written
@@ -8,6 +8,6 @@
8
8
  - **Skills**: a skill is a memory named `skill_<name>` holding the steps of a repeatable task we did together (sections Steps, Gotchas, Changelog; `memory_write description:` says when to use it).
9
9
  When the user asks to keep how we did something ("let's memorize this", "save this as a skill", `/skill save`), write it right away with `memory_write` (project scope; system only when the user asks or it isn't about this project), then show it briefly.
10
10
  Before a task that a `skill_*` in the memory index matches, read it and follow it.
11
- When a step turned out different (a renamed command, an extra step, a gotcha), update the skill in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line.
11
+ When a step turned out different (a renamed command, an extra step, a gotcha), update the skill in the same turn: `edit` those steps in its file (memory_write only to rewrite it all), keep the rest as it was, add a dated Changelog line.
12
12
  When a skill's step fails or its file/command is missing, find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask.
13
13
  While following a skill, when anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask how to go on. Read each command's whole output, including warnings, before the next step.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.8.1
3
+ version: 0.9.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
- identity.md: sha256:d6f40d5e44096537d337c4ffda86823446f8b59eb8b8cc47014e97ec16f019c9
7
+ identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
9
  config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
10
- memory_guide.md: sha256:50c076fb6bda7ed010877ff664bb2e9e904418fe08b058ebaa911d4e82e9a683
10
+ memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -6,7 +6,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
6
6
 
7
7
  - Plain Markdown files (`*.md`) stored outside the repo so they persist.
8
8
  - Loaded at startup into your system prompt as **Project memories** and **System memories** indexes (blank-name `memory_read`).
9
- - You manage them via tools, not shell file ops. On write, `index.md` is auto-maintained — do not edit it manually.
9
+ - You manage them via tools (`memory_write`, or `edit` on a memory's file for a small change), not shell file ops. `index.md` is auto-maintained on every write — do not edit it manually.
10
10
 
11
11
  ## Scopes — where files live
12
12
 
@@ -16,7 +16,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
16
16
  | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
18
  - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
19
- - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write`.
19
+ - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
20
20
 
21
21
  ## Tools you have
22
22
 
@@ -34,7 +34,8 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
34
34
  - `name` is the entry name **without** `.md` (the tool adds it). Use `name`, **not** `path` — `path` belongs to the file tools and is ignored here. `name: "index"` is a verbatim write to `index.md` (no auto-index update) — rarely needed.
35
35
  - `scope` is **required** — never omit. Prefer `project` for repo conventions, `system` for user preferences.
36
36
  - `description` is appended to the managed `index.md` line (`— description`). Replaces previous description if given; otherwise preserves existing one.
37
- - On success returns `Memory 'name' saved to <scope> scope (N bytes). Index updated: …` — confirm `bytes` and `scope`.
37
+ - For a small change to an existing memory (a step, a line), `edit` its file instead of rewriting it all: the path is in `memory_write`'s result and the scope dirs are in the prompt. Its index line is refreshed either way.
38
+ - On success returns `Memory 'name' saved to <scope> scope (N bytes). File written: <path> Index line refreshed automatically.` — confirm `bytes` and `scope`; the index needs nothing from you.
38
39
 
39
40
  **When to write:**
40
41
  - After learning a durable preference (e.g. commit style, test command, coding guideline) that the user confirmed or you observed repeatedly — ask before overwriting existing entries where appropriate.
@@ -67,7 +68,7 @@ Shape (plain Markdown, no frontmatter):
67
68
  - **Scope**: `project` by default; `system` when the user asks, or when the skill is clearly not about this project.
68
69
  - **Saving**: on a request to keep how something was done ("let's memorize this", "save this as a skill", `/skill save`), write it at once, then show it briefly. On an ambiguous one ("I like how we did that") you may ask whether to save it.
69
70
  - **Following**: before a task a `skill_*` index line matches, `memory_read` it and follow its steps. A step that fails or names a missing file or command: find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask. When anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask. Read each command's whole output, including warnings, before the next step.
70
- - **Updating**: when a step turned out different, rewrite the skill with `memory_write` in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line. No confirmation needed.
71
+ - **Updating**: when a step turned out different, update the skill in the same turn: `edit` those steps in its file (`<scope dir>/skill_<name>.md`), or rewrite it with `memory_write`; keep the rest as it was, add a dated Changelog line. No confirmation needed.
71
72
  - **The `skills` bundle** (`chi bundle install skills`, optional) adds `/skill save [name] [--system]`, `/skill list`, `/skill show <name>`, `/skill diff <name> [N]`; it keeps older versions and shows a short diff line after each update.
72
73
 
73
74
  ## Memory Bundles — shareable packs
@@ -4,6 +4,7 @@ require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../log"
6
6
  require_relative "model_size"
7
+ require_relative "../memory_bundle/index_sync"
7
8
 
8
9
  module Samagotchi
9
10
  module Guardrails
@@ -123,9 +124,14 @@ module Samagotchi
123
124
  end
124
125
 
125
126
  # Whether any path is outside the repo root (the cwd outside a repo).
127
+ # A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
128
+ # write/edit there is what memory_write does, unasked; index.md, the
129
+ # bundles dir and the rest of chi's config dir still count.
126
130
  def outside_repo?
127
131
  root = @repo_root || @cwd
128
- @paths.any? { |p| p != root && !p.start_with?(File.join(root, "")) }
132
+ @paths.any? do |p|
133
+ p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
134
+ end
129
135
  end
130
136
 
131
137
  # The hook event's targets: hash.
@@ -123,8 +123,12 @@ module Samagotchi
123
123
  def on_tool_call_completed(event)
124
124
  # A tool's output can be any bytes (invalid UTF-8 would fail the match).
125
125
  output = event[:output].to_s.scrub
126
+ # ms leaves out a guardrail approval wait (waited_ms, logged apart).
127
+ waited = event[:waited_ms].to_i
128
+ ms = since(@tool_started_at.delete([event[:iteration], event[:call_index]]))
129
+ ms = [ms - waited, 0].max if ms
126
130
  log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
127
- ms: since(@tool_started_at.delete([event[:iteration], event[:call_index]])),
131
+ ms: ms, waited_ms: (waited if waited.positive?),
128
132
  output_chars: output.length, truncated: event[:output_truncated] || nil,
129
133
  error: tool_error?(output) || nil)
130
134
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index_updater"
4
+ require_relative "../tools/memory"
5
+ require_relative "../model_overlay"
6
+ require_relative "../log"
7
+
8
+ module Samagotchi
9
+ module MemoryBundle
10
+ # Keeps a memory's managed index.md line current when the file tools
11
+ # (write, edit) change it, as memory_write does: the ToolRunner calls
12
+ # .refresh after a write/edit that changed its file (one it could diff:
13
+ # a memory over 1 MB isn't refreshed). The line's description is kept.
14
+ #
15
+ # Only a *.md right in the project or system memories dir (the dirs
16
+ # memory_write writes to) counts: not index.md, not a hidden file, not a
17
+ # model overlay (<name>.<key>.md next to its <name>.md; a memory whose
18
+ # name has a dot and a sibling of the same stem reads as an overlay too,
19
+ # a leftover ambiguity). Writes into another project's folder and
20
+ # execute's writes aren't seen.
21
+ module IndexSync
22
+ SCOPES = %w[project system].freeze
23
+
24
+ # @param path [String] the file write/edit changed
25
+ # @return [Boolean] whether an index line was written
26
+ def self.refresh(path)
27
+ path = File.expand_path(path.to_s)
28
+ scope = memory_scope(path)
29
+ return false unless scope && File.file?(path)
30
+
31
+ name = File.basename(path, ".md")
32
+ return false if overlay?(path, name)
33
+
34
+ IndexUpdater.update_index(scope, name, File.size(path), nil)
35
+ rescue StandardError => e
36
+ Log.warn(:memory, "index_sync_failed", path: path, error: "#{e.class}: #{e.message}")
37
+ false
38
+ end
39
+
40
+ # "project" or "system" when +path+ is a memory's file (or an
41
+ # overlay's): a *.md right in that scope's memories dir, not index.md,
42
+ # not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
43
+ # uses it too: write/edit there is what memory_write does. A symlink
44
+ # isn't: write/edit follow it to wherever it points, memory_write
45
+ # replaces the link.
46
+ def self.memory_scope(path)
47
+ path = File.expand_path(path.to_s)
48
+ name = File.basename(path, ".md")
49
+ return nil unless File.extname(path) == ".md"
50
+ return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
51
+
52
+ dir = real(File.dirname(path))
53
+ SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def self.overlay?(path, name)
59
+ match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
60
+ return false unless match
61
+
62
+ base = name.delete_suffix(".#{match[1]}")
63
+ File.file?(File.join(File.dirname(path), "#{base}.md"))
64
+ end
65
+
66
+ def self.real(dir)
67
+ File.realpath(dir)
68
+ rescue SystemCallError
69
+ File.expand_path(dir)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -86,6 +86,8 @@ module Samagotchi
86
86
  all_files_in_bundle = []
87
87
  provenance = Provenance.new(name: @name)
88
88
  existing_provenance = provenance.read
89
+ # A plain install over an installed bundle: summary points at upgrade.
90
+ @reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
89
91
 
90
92
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
91
93
  file_key = File.basename(file_path)
@@ -125,12 +127,17 @@ module Samagotchi
125
127
  classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
126
128
  @results[file_key] = { status: classification.to_s }
127
129
  @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
130
+ elsif FileUtils.identical?(file_path, target_path)
131
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
128
132
  else
129
133
  @results[file_key] = { status: "would_skip" }
130
134
  end
131
135
  else
132
136
  @results[file_key] = { status: "would_install" }
133
137
  end
138
+ elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
139
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
140
+ update_target_index(target_scope, target_path, file_key)
134
141
  elsif File.exist?(target_path) && !@force
135
142
  @results[file_key] = { status: "skipped", reason: "already exists" }
136
143
  @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
@@ -252,13 +259,19 @@ module Samagotchi
252
259
  if @dry_run
253
260
  incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
254
261
  else
262
+ # Same bytes as installed: reported as up to date, not "Installed".
263
+ unchanged = incoming_rules.select do |src|
264
+ dest = File.join(guardrails_target, File.basename(src))
265
+ File.file?(dest) && FileUtils.identical?(src, dest)
266
+ end
255
267
  FileUtils.rm_rf(guardrails_target)
256
268
  unless incoming_rules.empty?
257
269
  FileUtils.mkdir_p(guardrails_target)
258
270
  incoming_rules.each do |src|
259
271
  dest = File.join(guardrails_target, File.basename(src))
260
272
  FileUtils.cp(src, dest)
261
- @results["guardrails/#{File.basename(src)}"] = { status: "installed" }
273
+ @results["guardrails/#{File.basename(src)}"] =
274
+ unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
262
275
  guardrail_files_for_provenance[File.basename(src)] = dest
263
276
  end
264
277
  end
@@ -278,7 +291,8 @@ module Samagotchi
278
291
  all_files_in_bundle.each do |file_key|
279
292
  next if @conflicts.key?(file_key)
280
293
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
281
- next if %w[kept kept_pruned fast_forward updated].include?(status)
294
+ # skipped: not copied, so a local edit isn't a bad download.
295
+ next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
282
296
  # For dry-run, fast_forward would appear as "fast_forward", updated not yet
283
297
  target_path = File.join(target_dir, file_key)
284
298
  next unless File.exist?(target_path)
@@ -340,7 +354,13 @@ module Samagotchi
340
354
  else
341
355
  all_files_in_bundle.each do |file_key|
342
356
  target_path = File.join(target_dir, file_key)
343
- provenance_files[file_key] = target_path if File.exist?(target_path)
357
+ next unless File.exist?(target_path)
358
+
359
+ # Skipped for a local edit: keep the installed base, so the
360
+ # next upgrade still sees the edit (not a fast-forward over it).
361
+ base_path = provenance.base_path(file_key)
362
+ kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
363
+ provenance_files[file_key] = kept ? base_path : target_path
344
364
  end
345
365
  end
346
366
  # Build hooks metadata for provenance
@@ -409,6 +429,7 @@ module Samagotchi
409
429
  lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
410
430
  lines.concat(@warnings) unless @warnings.empty?
411
431
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
432
+ lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
412
433
 
413
434
  lines.any? ? lines.join("\n") : "No files to install."
414
435
  end
@@ -224,11 +224,11 @@ module Samagotchi
224
224
  # a turn running, recap = the saved recap's first sentence, project =
225
225
  # Session#project_root
226
226
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
227
- project_root: nil, include_archived: false)
227
+ project_root: nil, include_archived: false, sort: nil, order: nil)
228
228
  sd = state_dir || Session.default_state_dir
229
229
  root = cwd && folder_path(cwd)
230
230
  roots = {}
231
- summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root,
231
+ summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
232
232
  include_archived: include_archived).lazy
233
233
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
234
234
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
@@ -427,7 +427,8 @@ module Samagotchi
427
427
  ).merge(
428
428
  status: status.empty? ? "ok" : status,
429
429
  completed_at: finished_at.iso8601(3),
430
- duration_ms: elapsed_ms(active[:started_monotonic])
430
+ # Less a guardrail approval wait (ToolRunner's waited_ms).
431
+ duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
431
432
  )
432
433
  end
433
434
  end
@@ -120,11 +120,12 @@ module Samagotchi
120
120
  end
121
121
 
122
122
  def list_summaries(project, scope_note)
123
- live, cwd, limit, include_archived, format = @opts.values_at(:live, :cwd, :limit, :include_archived, :format)
123
+ live, cwd, limit, include_archived, format, sort, order =
124
+ @opts.values_at(:live, :cwd, :limit, :include_archived, :format, :sort, :order)
124
125
  summaries = Samagotchi::SessionManager.session_summaries(
125
126
  live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
126
127
  project_root: project,
127
- include_archived: include_archived
128
+ include_archived: include_archived, sort: sort, order: order
128
129
  )
129
130
  case format
130
131
  when "json"
@@ -175,11 +175,12 @@ module Samagotchi
175
175
  System scope: ~/.config/samagotchi/memories/ (cross-project)
176
176
  memory_read accepts optional scope (project|system).
177
177
  memory_write requires explicit scope and entry name.
178
+ For a small change to an existing memory, `edit` its file (<scope dir>/<name>.md)
179
+ instead of rewriting it all with memory_write; its index line is refreshed either way.
178
180
  User prompts may contain memory shorthand like #entry_name.
179
181
  Treat #entry_name as a memory reference, not as a file path.
180
182
  If shorthand includes a scope prefix, such as #project/entry_name or #system/entry_name,
181
183
  preserve that scope when reading the memory.
182
- Keep each scope's index.md updated when adding/updating entries.
183
184
  Each scope's `index.md` is auto-maintained by `memory_write` (one
184
185
  managed line per entry with name/scope/date/size); free-form sections
185
186
  are preserved. The verbatim `index` write (`name: "index"`) is kept.
@@ -123,7 +123,7 @@ module Samagotchi
123
123
  },
124
124
  {
125
125
  name: "memory_write",
126
- description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
126
+ description: "Write or update a memory entry in scoped memories. Scope is required: project or system. Use the `name` parameter for the entry name (use `name`, NOT `path` — the file tools use `path`); each scope's index.md is auto-maintained (one managed line per entry); use name \"index\" to write the index file verbatim. For a small change to an existing memory (a step, a line), `edit` its file (<scope dir>/<name>.md) instead of rewriting it all; its index line is refreshed either way. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
127
127
  parameters: {
128
128
  type: "object",
129
129
  properties: {
@@ -6,6 +6,7 @@ require_relative "vision_context"
6
6
  require_relative "log"
7
7
  require_relative "edit_preview"
8
8
  require_relative "tools/tool_path"
9
+ require_relative "memory_bundle/index_sync"
9
10
 
10
11
  module Samagotchi
11
12
  # The single per-call path both loops use: the tool_call_started and
@@ -67,6 +68,7 @@ module Samagotchi
67
68
  before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
68
69
  result = verdict.deny? ? denied(call, verdict) : dispatch(call)
69
70
  diff = file_change(call, before)
71
+ refresh_memory_index(call) if diff
70
72
  result = approved(result, verdict) if verdict.allow? && verdict.decided_by
71
73
  result, images = attach_images(call, result) if result[:images]
72
74
 
@@ -117,6 +119,12 @@ module Samagotchi
117
119
  nil
118
120
  end
119
121
 
122
+ # A write/edit that changed a memory's file refreshes its index line,
123
+ # as memory_write does (MemoryBundle::IndexSync; a no-op elsewhere).
124
+ def refresh_memory_index(call)
125
+ MemoryBundle::IndexSync.refresh(Tools::ToolPath.normalize(call[:path]))
126
+ end
127
+
120
128
  # A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
121
129
  # binary file). They become "?" here, before the output reaches the
122
130
  # conversation, the events and the saved session: JSON.generate raises
@@ -204,9 +204,8 @@ module Samagotchi
204
204
  # The verbatim "write to index.md" behavior (path: "index") must not
205
205
  # trigger upsert logic.
206
206
  unless entry_name == MEMORY_INDEX
207
- index_path = self.index_path_for(resolved_scope)
208
207
  if manage_index(resolved_scope, entry_name, bytes, description)
209
- message += " Index updated: #{index_path}"
208
+ message += " Index line refreshed automatically."
210
209
  end
211
210
  end
212
211
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -256,6 +256,7 @@ files:
256
256
  - lib/samagotchi/log_subscriber.rb
257
257
  - lib/samagotchi/memory_bundle.rb
258
258
  - lib/samagotchi/memory_bundle/builder.rb
259
+ - lib/samagotchi/memory_bundle/index_sync.rb
259
260
  - lib/samagotchi/memory_bundle/index_updater.rb
260
261
  - lib/samagotchi/memory_bundle/installer.rb
261
262
  - lib/samagotchi/memory_bundle/listing.rb