samagotchi 0.35.0 → 0.36.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 +33 -1
- data/docs/context.md +37 -0
- data/docs/guardrails.md +11 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +28 -2
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- metadata +1 -1
|
@@ -33,14 +33,16 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
|
|
|
33
33
|
- Parameters: `name: "entry_name"`, `content: "..."`, `scope: "project"|"system"`, optional `description: "one-liner"`, optional `current_model_only: true`.
|
|
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
|
-
- `description` is appended to the managed `index.md` line (`— description`). Replaces previous description if given;
|
|
36
|
+
- `description` is appended to the managed `index.md` line (`— description`): one line, at most 200 characters (longer is refused; newlines become spaces). Replaces previous description if given; a missing or blank one preserves the existing one.
|
|
37
|
+
- To change only the description, pass `name`, `scope` and `description` without `content`: the index line changes (its date becomes today), the file stays as it is. The memory must exist in that scope.
|
|
38
|
+
- To remove a memory, pass `name`, `scope` and `remove: true` (nothing else): its file and model overlays move to the bundle trash (`chi bundle trash` lists and empties it) and its index line goes, a dangling line too. Only when the user asked or a skill's steps say so; the guardrails ask the user first. A memory a bundle installed is refused: `chi bundle uninstall` owns it.
|
|
37
39
|
- 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
40
|
- 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.
|
|
39
41
|
|
|
40
42
|
**When to write:**
|
|
41
43
|
- 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.
|
|
42
44
|
- Keep entries small and focused (one topic per file). Use clear filenames: `commit_preferences`, `testing_guide`, `project_conventions`.
|
|
43
|
-
- Never store secrets, tokens, or transient state. Memories are shared via bundles.
|
|
45
|
+
- Never store secrets, tokens, or transient state. Memories are shared via bundles. One exception: a skill may keep a working handoff memory (its steps say so) whose status is its description, removed when the work is done.
|
|
44
46
|
|
|
45
47
|
**Placeholders:**
|
|
46
48
|
- Content may contain placeholder hints written as double-curly braces around a name (e.g., test_command, language). Detected by `Placeholder` (`Placeholder::PLACEHOLDER_RE`) — install warns but does not fail. Fill them when you write. The placeholder syntax is two opening braces, a name, two closing braces.
|
|
@@ -46,6 +46,9 @@ module Samagotchi
|
|
|
46
46
|
# one). samagotchi/context stops before a word character or a dot, so
|
|
47
47
|
# chi's own lib/samagotchi/context_*.rb isn't one.
|
|
48
48
|
CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|samagotchi/context(?![\w.])|memories/\.bundles}
|
|
49
|
+
# The guardrails bundle's ask before a memory removal: its paths are in
|
|
50
|
+
# the config dir, yet a parent may answer it.
|
|
51
|
+
MEMORY_REMOVE_RULE = "memory-remove"
|
|
49
52
|
# The guardrails bundle's shell-touches-chi, for a word it can't
|
|
50
53
|
# resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
|
|
51
54
|
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
|
|
@@ -94,6 +97,9 @@ module Samagotchi
|
|
|
94
97
|
return false unless facts.is_a?(Hash)
|
|
95
98
|
return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
|
|
96
99
|
return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
|
|
100
|
+
# A memory's file sits in the config dir, but removing one is the
|
|
101
|
+
# memory-remove rule's ask, which a parent may answer.
|
|
102
|
+
return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
|
|
97
103
|
|
|
98
104
|
dirs = chi_dirs
|
|
99
105
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
@@ -28,6 +28,7 @@ module Samagotchi
|
|
|
28
28
|
# modes: "strict", "auto" or a list of them: the rule only votes
|
|
29
29
|
# in those guardrails.mode values (missing = every mode;
|
|
30
30
|
# a rule from config.yml without it votes in every mode)
|
|
31
|
+
# memory: "remove": a memory_write call with remove: true
|
|
31
32
|
# skip_read_only: true: a shell command that only reads
|
|
32
33
|
# (ReadOnlyShell) doesn't match
|
|
33
34
|
# verdict ask|deny, reason, scopes (for an ask; default all).
|
|
@@ -38,14 +39,14 @@ module Samagotchi
|
|
|
38
39
|
class Rules
|
|
39
40
|
class ParseError < StandardError; end
|
|
40
41
|
|
|
41
|
-
KEYS = %w[id tool command path git touches rm models modes skip_read_only verdict reason scopes].freeze
|
|
42
|
-
MATCH_KEYS = %w[tool command path git touches rm].freeze
|
|
42
|
+
KEYS = %w[id tool command path git touches rm memory models modes skip_read_only verdict reason scopes].freeze
|
|
43
|
+
MATCH_KEYS = %w[tool command path git touches rm memory].freeze
|
|
43
44
|
VERDICTS = %w[ask deny].freeze
|
|
44
45
|
# guardrails.mode's values.
|
|
45
46
|
MODES = %w[auto strict].freeze
|
|
46
47
|
GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
|
|
47
48
|
|
|
48
|
-
Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :models, :modes, :skip_read_only, :verdict, :reason,
|
|
49
|
+
Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :memory, :models, :modes, :skip_read_only, :verdict, :reason,
|
|
49
50
|
:scopes, :source, keyword_init: true) do
|
|
50
51
|
def matches?(targets)
|
|
51
52
|
return false unless targets
|
|
@@ -56,6 +57,7 @@ module Samagotchi
|
|
|
56
57
|
return false if git && !targets.git_outside_repo?
|
|
57
58
|
return false if touches && !targets.touches_chi?
|
|
58
59
|
return false if rm && !targets.rm_outside_tmp?
|
|
60
|
+
return false if memory && !targets.memory_remove?
|
|
59
61
|
return false if skip_read_only && targets.read_only?
|
|
60
62
|
|
|
61
63
|
true
|
|
@@ -128,7 +130,8 @@ module Samagotchi
|
|
|
128
130
|
|
|
129
131
|
Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
|
|
130
132
|
path: path_of(raw["path"], label), git: git_of(raw["git"], label),
|
|
131
|
-
touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label),
|
|
133
|
+
touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), memory: memory_of(raw["memory"], label),
|
|
134
|
+
models: models_of(raw["models"], label),
|
|
132
135
|
modes: modes_of(raw["modes"], label),
|
|
133
136
|
skip_read_only: flag_of(raw, "skip_read_only", label), verdict: verdict.to_sym,
|
|
134
137
|
reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
|
|
@@ -185,6 +188,14 @@ module Samagotchi
|
|
|
185
188
|
value
|
|
186
189
|
end
|
|
187
190
|
|
|
191
|
+
# Only "remove" for now.
|
|
192
|
+
def self.memory_of(value, label)
|
|
193
|
+
return nil if value.nil?
|
|
194
|
+
raise ParseError, "#{label}: memory must be remove" unless value == "remove"
|
|
195
|
+
|
|
196
|
+
value
|
|
197
|
+
end
|
|
198
|
+
|
|
188
199
|
def self.modes_of(value, label)
|
|
189
200
|
return nil if value.nil?
|
|
190
201
|
|
|
@@ -42,6 +42,11 @@ module Samagotchi
|
|
|
42
42
|
# chi's own tools
|
|
43
43
|
attr_reader :args
|
|
44
44
|
|
|
45
|
+
# @return [Boolean] a memory_write call that removes a memory (remove:
|
|
46
|
+
# true), for a rule's memory: remove
|
|
47
|
+
attr_reader :memory_remove
|
|
48
|
+
alias memory_remove? memory_remove
|
|
49
|
+
|
|
45
50
|
# @param call [Hash] the parsed tool call
|
|
46
51
|
# @param context [Context]
|
|
47
52
|
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
@@ -76,8 +81,9 @@ module Samagotchi
|
|
|
76
81
|
entry = registry && registry[tool]
|
|
77
82
|
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
78
83
|
end
|
|
84
|
+
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
79
85
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
80
|
-
repo: context.repo(cwd), args: args,
|
|
86
|
+
repo: context.repo(cwd), args: args, memory_remove: memory_remove,
|
|
81
87
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
82
88
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
83
89
|
end
|
|
@@ -128,7 +134,8 @@ module Samagotchi
|
|
|
128
134
|
|
|
129
135
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
130
136
|
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, model_name: nil, model_key: nil,
|
|
131
|
-
session_root: nil, chi_dirs: nil)
|
|
137
|
+
session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
138
|
+
@memory_remove = memory_remove
|
|
132
139
|
@repo = repo
|
|
133
140
|
@chi_dirs = chi_dirs
|
|
134
141
|
@session_root = session_root || repo_root || cwd
|
|
@@ -25,7 +25,7 @@ module Samagotchi
|
|
|
25
25
|
pattern = managed_pattern(entry_name)
|
|
26
26
|
if content.match?(pattern)
|
|
27
27
|
existing = content[pattern].to_s.chomp
|
|
28
|
-
resolved_desc = description
|
|
28
|
+
resolved_desc = blank_description?(description) ? extract_description(existing) : description
|
|
29
29
|
resolved_source = source.nil? ? extract_source(existing) : (source || nil)
|
|
30
30
|
new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
|
|
31
31
|
next content.sub(pattern) { new_line + $1 }
|
|
@@ -72,11 +72,14 @@ module Samagotchi
|
|
|
72
72
|
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
73
73
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
74
74
|
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
75
|
-
desc = description
|
|
76
|
-
line += " — #{desc}" unless desc.
|
|
75
|
+
desc = description.to_s.gsub(/\s+/, " ").strip
|
|
76
|
+
line += " — #{desc}" unless desc.empty?
|
|
77
77
|
line
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# A blank description (nil, "", whitespace) keeps the line's own.
|
|
81
|
+
def self.blank_description?(description) = description.to_s.strip.empty?
|
|
82
|
+
|
|
80
83
|
def self.extract_description(line)
|
|
81
84
|
if line =~ /\s—\s(.*)\s*\z/
|
|
82
85
|
$1
|
|
@@ -277,6 +277,7 @@ module Samagotchi
|
|
|
277
277
|
("git #{rule.git}" if rule.git),
|
|
278
278
|
("touches #{rule.touches}" if rule.touches),
|
|
279
279
|
("rm #{rule.rm}" if rule.rm),
|
|
280
|
+
("memory #{rule.memory}" if rule.memory),
|
|
280
281
|
("models #{rule.models.join(",")}" if rule.models),
|
|
281
282
|
("modes #{rule.modes.join(",")}" if rule.modes),
|
|
282
283
|
("skip_read_only" if rule.skip_read_only)
|
|
@@ -39,7 +39,7 @@ module Samagotchi
|
|
|
39
39
|
# worker runs in its session's working directory)
|
|
40
40
|
def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
|
|
41
41
|
event = {
|
|
42
|
-
action: tool_activity_action(tool_name, registry: registry),
|
|
42
|
+
action: tool_activity_action(tool_name, registry: registry, call: call),
|
|
43
43
|
tool: tool_name,
|
|
44
44
|
params: tool_activity_params(tool_name, call, registry: registry),
|
|
45
45
|
status: tool_activity_status(result, tool_name)
|
|
@@ -151,14 +151,15 @@ module Samagotchi
|
|
|
151
151
|
parts.drop(base.length).join("/")
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
# @param call [Hash, nil] the call, for a label that depends on its form
|
|
155
|
+
def tool_activity_action(tool_name, registry: nil, call: nil)
|
|
155
156
|
case tool_name
|
|
156
157
|
when Tools::Execute::NAME then "running command"
|
|
157
158
|
when Tools::Read::NAME then "reading file"
|
|
158
159
|
when Tools::Write::NAME then "writing file"
|
|
159
160
|
when Tools::Edit::NAME then "editing file"
|
|
160
161
|
when Tools::MemoryRead::NAME then "reading memory"
|
|
161
|
-
when Tools::MemoryWrite::NAME then
|
|
162
|
+
when Tools::MemoryWrite::NAME then memory_write_action(call)
|
|
162
163
|
when Tools::TaskCreate::NAME then "starting background task"
|
|
163
164
|
when Tools::TaskGet::NAME then "checking task"
|
|
164
165
|
when Tools::TaskList::NAME then "listing tasks"
|
|
@@ -182,6 +183,15 @@ module Samagotchi
|
|
|
182
183
|
# whose task was stopped (the stop-task button, or the model's task_stop),
|
|
183
184
|
# and for a command the user's Stop killed or kept from starting: a
|
|
184
185
|
# cancel, not a failure (its text still starts "Error:" for the model).
|
|
186
|
+
# memory_write's label: a call with only a description changes the index line.
|
|
187
|
+
def memory_write_action(call)
|
|
188
|
+
return "saving memory" unless call.is_a?(Hash)
|
|
189
|
+
return "removing memory" if call[:remove].to_s.strip.downcase == "true"
|
|
190
|
+
return "updating memory description" if call[:content].to_s.empty? && !call[:description].to_s.strip.empty?
|
|
191
|
+
|
|
192
|
+
"saving memory"
|
|
193
|
+
end
|
|
194
|
+
|
|
185
195
|
def tool_activity_status(result, tool_name = nil)
|
|
186
196
|
text = result.to_s
|
|
187
197
|
return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
|
|
@@ -232,6 +242,7 @@ module Samagotchi
|
|
|
232
242
|
parts << "scope=#{preview_tool_param(scope)}" unless scope.empty?
|
|
233
243
|
desc = call[:description].to_s.strip
|
|
234
244
|
parts << "description=#{preview_tool_param(desc)}" unless desc.empty?
|
|
245
|
+
parts << "remove=true" if call[:remove].to_s.strip.downcase == "true"
|
|
235
246
|
parts.join(" ")
|
|
236
247
|
when Tools::TaskCreate::NAME
|
|
237
248
|
parts = ["command=#{preview_tool_param(call[:content])}"]
|
|
@@ -127,7 +127,7 @@ module Samagotchi
|
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
name: "memory_write",
|
|
130
|
-
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.",
|
|
130
|
+
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. To change only an entry's index line, pass name, scope and description without content; to remove an entry, name, scope and remove: true. If the user asks to save guidance for the current model only, pass current_model_only: true to save a model-specific overlay.",
|
|
131
131
|
parameters: {
|
|
132
132
|
type: "object",
|
|
133
133
|
properties: {
|
|
@@ -137,7 +137,7 @@ module Samagotchi
|
|
|
137
137
|
},
|
|
138
138
|
content: {
|
|
139
139
|
type: "string",
|
|
140
|
-
description: "Markdown content to write"
|
|
140
|
+
description: "Markdown content to write; leave it out to change only the description"
|
|
141
141
|
},
|
|
142
142
|
scope: {
|
|
143
143
|
type: "string",
|
|
@@ -145,14 +145,18 @@ module Samagotchi
|
|
|
145
145
|
},
|
|
146
146
|
description: {
|
|
147
147
|
type: "string",
|
|
148
|
-
description: "Optional
|
|
148
|
+
description: "Optional one-line description (at most 200 characters) appended to the managed index line; without content, it changes only that line"
|
|
149
149
|
},
|
|
150
150
|
current_model_only: {
|
|
151
151
|
type: "boolean",
|
|
152
152
|
description: "Set true to save this entry as a model-specific overlay for the current model only (<name>.<model>.md); it is auto-appended when the entry is read under that model and never listed in the index."
|
|
153
|
+
},
|
|
154
|
+
remove: {
|
|
155
|
+
type: "boolean",
|
|
156
|
+
description: "Set true, with only name and scope, to remove the entry: its file (and model overlays) move to the bundle trash and its index line is dropped. Only when the user asked or a skill's steps say so."
|
|
153
157
|
}
|
|
154
158
|
},
|
|
155
|
-
required: %w[name
|
|
159
|
+
required: %w[name scope]
|
|
156
160
|
}
|
|
157
161
|
},
|
|
158
162
|
{
|
|
@@ -63,7 +63,7 @@ module Samagotchi
|
|
|
63
63
|
MemoryWrite::NAME => lambda do |call, kctx|
|
|
64
64
|
MemoryWrite.call(call[:content], path: call[:path], scope: call[:scope], description: call[:description],
|
|
65
65
|
current_model_only: truthy?(call[:current_model_only]),
|
|
66
|
-
model_key: kctx.model_key)
|
|
66
|
+
model_key: kctx.model_key, remove: truthy?(call[:remove]))
|
|
67
67
|
end,
|
|
68
68
|
Write::NAME => ->(call, _kctx) { Write.call(call[:content], path: call[:path]) },
|
|
69
69
|
Read::NAME => lambda do |call, _kctx|
|
|
@@ -150,13 +150,37 @@ module Samagotchi
|
|
|
150
150
|
/^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
# The longest description an index line takes: the line lands in
|
|
154
|
+
# every session's prompt for the repo.
|
|
155
|
+
DESCRIPTION_LIMIT = 200
|
|
156
|
+
|
|
157
|
+
# @param remove [Boolean] move the entry (and its model overlays) into
|
|
158
|
+
# the bundle trash and drop its index line (#remove)
|
|
159
|
+
def self.call(content, path:, scope:, description: nil, current_model_only: false, model_key: nil, remove: false)
|
|
154
160
|
entry_name = path.to_s.strip
|
|
155
161
|
body = content.to_s
|
|
156
162
|
return "Error: entry name is required" if entry_name.empty?
|
|
157
163
|
return MemoryRead.invalid_name_error(entry_name) if MemoryRead.invalid_name?(entry_name)
|
|
158
164
|
return "Error: scope is required" if scope.to_s.strip.empty?
|
|
159
|
-
|
|
165
|
+
|
|
166
|
+
if remove
|
|
167
|
+
if !body.empty? || !description.to_s.strip.empty? || current_model_only
|
|
168
|
+
return "Error: remove: true takes only name and scope (no content, description or current_model_only)"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return remove(entry_name, scope)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
description = normalize_description(description)
|
|
175
|
+
if description && description.length > DESCRIPTION_LIMIT
|
|
176
|
+
return "Error: description is #{description.length} characters; the limit is #{DESCRIPTION_LIMIT} " \
|
|
177
|
+
"(one line: what the memory is for)"
|
|
178
|
+
end
|
|
179
|
+
if body.empty?
|
|
180
|
+
return "Error: content is required (or pass only description to change the index line)" unless description
|
|
181
|
+
|
|
182
|
+
return update_description(entry_name, scope, description, current_model_only)
|
|
183
|
+
end
|
|
160
184
|
|
|
161
185
|
resolved_scope = MemoryRead.normalize_scope(scope)
|
|
162
186
|
|
|
@@ -205,6 +229,83 @@ module Samagotchi
|
|
|
205
229
|
"Error: #{e.message}"
|
|
206
230
|
end
|
|
207
231
|
|
|
232
|
+
# A description as the index line takes it: whitespace (newlines too)
|
|
233
|
+
# collapsed to single spaces; nil when blank, which keeps the stored one.
|
|
234
|
+
def self.normalize_description(description)
|
|
235
|
+
text = description.to_s.gsub(/\s+/, " ").strip
|
|
236
|
+
text.empty? ? nil : text
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The description-only form: name, scope and description, no content.
|
|
240
|
+
# Rewrites that entry's index line (size and date too) and leaves its
|
|
241
|
+
# file alone, so a status kept in the description costs no rewrite of
|
|
242
|
+
# the body.
|
|
243
|
+
def self.update_description(entry_name, scope, description, current_model_only)
|
|
244
|
+
return "Error: the index has no index line of its own; pass content to write it" if entry_name == MEMORY_INDEX
|
|
245
|
+
if current_model_only
|
|
246
|
+
return "Error: a model overlay has no index line; pass content to write the overlay, or drop " \
|
|
247
|
+
"current_model_only to change the base entry's description"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
resolved_scope = MemoryRead.normalize_scope(scope)
|
|
251
|
+
file_path = File.join(MemoryRead.memories_dir(resolved_scope), "#{entry_name}.md")
|
|
252
|
+
return "Error: no memory '#{entry_name}' in #{resolved_scope} scope; pass content to create it" unless File.file?(file_path)
|
|
253
|
+
|
|
254
|
+
manage_index(resolved_scope, entry_name, File.size(file_path), description)
|
|
255
|
+
"Memory '#{entry_name}' description updated in #{resolved_scope} scope (file unchanged)."
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# remove: true. Moves <name>.md and its model overlays into one
|
|
259
|
+
# MemoryBundle::Trash dir (memory-<name>-<time>, which `chi bundle
|
|
260
|
+
# trash` lists and empties) and drops the index line; a line whose
|
|
261
|
+
# file is already gone is dropped alone. A memory a bundle owns (an
|
|
262
|
+
# installed bundle's record lists it, or its line says "· from
|
|
263
|
+
# <bundle>") is the uninstaller's, and the index isn't a memory.
|
|
264
|
+
def self.remove(entry_name, scope)
|
|
265
|
+
return "Error: the index can't be removed; it is kept up to date for you" if entry_name == MEMORY_INDEX
|
|
266
|
+
|
|
267
|
+
require_relative "../memory_bundle/index_updater"
|
|
268
|
+
require_relative "../memory_bundle/provenance"
|
|
269
|
+
require_relative "../memory_bundle/trash"
|
|
270
|
+
resolved_scope = MemoryRead.normalize_scope(scope)
|
|
271
|
+
dir = MemoryRead.memories_dir(resolved_scope)
|
|
272
|
+
index = File.join(dir, "#{MEMORY_INDEX}.md")
|
|
273
|
+
index_text = File.file?(index) ? File.read(index, encoding: "UTF-8") : ""
|
|
274
|
+
line = index_text[managed_pattern(entry_name)]
|
|
275
|
+
file = File.join(dir, "#{entry_name}.md")
|
|
276
|
+
|
|
277
|
+
owner = Samagotchi::MemoryBundle::Provenance.claimants("#{entry_name}.md", scope: resolved_scope).first ||
|
|
278
|
+
(line && Samagotchi::MemoryBundle::IndexUpdater.extract_source(line))
|
|
279
|
+
return "Error: memory '#{entry_name}' came from bundle #{owner}; `chi bundle uninstall #{owner}` removes it" if owner
|
|
280
|
+
|
|
281
|
+
unless File.file?(file)
|
|
282
|
+
return "Error: no memory '#{entry_name}' in #{resolved_scope} scope" unless line
|
|
283
|
+
|
|
284
|
+
Samagotchi::MemoryBundle::IndexUpdater.remove_index(resolved_scope, entry_name)
|
|
285
|
+
return "Memory '#{entry_name}' had no file in #{resolved_scope} scope; its dangling index line was dropped."
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
trash = Samagotchi::MemoryBundle::Trash.new("memory-#{entry_name}")
|
|
289
|
+
([file] + overlay_files(dir, entry_name, index_text)).each { |path| trash.move(path) }
|
|
290
|
+
Samagotchi::MemoryBundle::IndexUpdater.remove_index(resolved_scope, entry_name)
|
|
291
|
+
"Memory '#{entry_name}' removed from #{resolved_scope} scope: moved to #{trash.dir} " \
|
|
292
|
+
"(`chi bundle trash` lists and empties it); index line dropped."
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The entry's model overlays: <name>.<key>.md with a key ModelOverlay
|
|
296
|
+
# makes, unless that stem is a memory of its own (names may have dots:
|
|
297
|
+
# handoff_x.v2 has an index line).
|
|
298
|
+
def self.overlay_files(dir, entry_name, index_text)
|
|
299
|
+
pattern = /\A#{Regexp.escape(entry_name)}\.([a-z0-9-]+)\.md\z/
|
|
300
|
+
Dir.children(dir).sort.filter_map do |child|
|
|
301
|
+
key = child[pattern, 1] or next
|
|
302
|
+
next if index_text.match?(managed_pattern("#{entry_name}.#{key}"))
|
|
303
|
+
|
|
304
|
+
path = File.join(dir, child)
|
|
305
|
+
path if File.file?(path)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
208
309
|
# Adds or refreshes a single managed line for `entry_name` in the given
|
|
209
310
|
# scope's index.md, preserving every other byte byte-for-byte.
|
|
210
311
|
# Delegates to IndexUpdater to maintain a single source of truth.
|
data/lib/samagotchi/version.rb
CHANGED