samagotchi 0.34.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 +77 -1
- data/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- 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 +22 -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/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- 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/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -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/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
|
@@ -8,7 +8,9 @@ require "open3"
|
|
|
8
8
|
# web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
|
|
9
9
|
# through event[:present]: display only, the model's text stays as it was,
|
|
10
10
|
# and it survives a reload). Every UI also gets one line right after the
|
|
11
|
-
# turn, the terminals' only view of the links:
|
|
11
|
+
# turn, the terminals' only view of the links (marked fallback_for:
|
|
12
|
+
# :display when the answer links every URL it names, so the web with
|
|
13
|
+
# markdown on leaves it out; needs chi 0.35.0):
|
|
12
14
|
#
|
|
13
15
|
# sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
|
|
14
16
|
#
|
|
@@ -48,8 +50,8 @@ require "open3"
|
|
|
48
50
|
# of these stays text (warned at load).
|
|
49
51
|
#
|
|
50
52
|
# The note is not stored in the conversation: it is an event, replayed by a
|
|
51
|
-
# UI
|
|
52
|
-
#
|
|
53
|
+
# UI with the session's cards (a reload keeps it, after the worker stopped
|
|
54
|
+
# too: cards.json). The links are stored as the answer's display. A ref in
|
|
53
55
|
# code (a `span` or a fenced block) or in a markdown link is not linked in
|
|
54
56
|
# the answer. The hook is inert until sources are configured.
|
|
55
57
|
class SourceLinks
|
|
@@ -114,17 +116,34 @@ class SourceLinks
|
|
|
114
116
|
return if text.nil? || text.empty?
|
|
115
117
|
|
|
116
118
|
hits = occurrences(text[0, MAX_SCAN])
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
linked = present_links(event[:present], text, hits)
|
|
120
|
+
return unless @note
|
|
121
|
+
|
|
122
|
+
found = collect(hits)
|
|
123
|
+
return if found.empty?
|
|
124
|
+
|
|
125
|
+
# The display links every URL the note names: the note only stands in
|
|
126
|
+
# for it, and a UI that renders the display's links leaves it out.
|
|
127
|
+
covered = linked && found.all? { |_name, _ref, url| linked.key?(url.downcase) }
|
|
128
|
+
event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
|
|
124
129
|
end
|
|
125
130
|
|
|
126
131
|
private
|
|
127
132
|
|
|
133
|
+
# Links the refs in the answer as shown: the model's text unless an
|
|
134
|
+
# earlier hook changed it (then its offsets differ, so it is scanned
|
|
135
|
+
# again). The URLs it linked (downcased, as #collect dedups), or nil when
|
|
136
|
+
# the display didn't take them (no event[:present], nothing to present, or
|
|
137
|
+
# the presenter rejected the text).
|
|
138
|
+
def present_links(present, text, hits)
|
|
139
|
+
return nil unless present
|
|
140
|
+
|
|
141
|
+
linked = {}
|
|
142
|
+
ours = nil
|
|
143
|
+
shown = present.call { |display| ours = link(display, display == text ? hits : nil, linked) }
|
|
144
|
+
ours.nil? || shown != ours ? nil : linked
|
|
145
|
+
end
|
|
146
|
+
|
|
128
147
|
# The content of the last `role: "model"` message, or nil. Handles both
|
|
129
148
|
# string- and symbol-keyed messages. A turn that ended without a visible
|
|
130
149
|
# answer stores a `kind: turn_note` system message instead, so the role
|
|
@@ -198,8 +217,8 @@ class SourceLinks
|
|
|
198
217
|
|
|
199
218
|
# +text+ with each ref as a markdown link, every occurrence; the part
|
|
200
219
|
# beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
|
|
201
|
-
# the caller has it.
|
|
202
|
-
def link(text, hits = nil)
|
|
220
|
+
# the caller has it; +linked+ gets each linked URL, downcased.
|
|
221
|
+
def link(text, hits = nil, linked = {})
|
|
203
222
|
head = text[0, MAX_SCAN]
|
|
204
223
|
hits ||= occurrences(head)
|
|
205
224
|
out = +""
|
|
@@ -208,6 +227,7 @@ class SourceLinks
|
|
|
208
227
|
next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
|
|
209
228
|
|
|
210
229
|
out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
|
|
230
|
+
linked[hit[:url].downcase] = true
|
|
211
231
|
pos = hit[:finish]
|
|
212
232
|
end
|
|
213
233
|
return text if pos.zero?
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.4.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
7
|
+
requires_chi: ">= 0.35.0"
|
|
7
8
|
files:
|
|
8
|
-
source_links.md: sha256:
|
|
9
|
+
source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
|
|
9
10
|
hooks:
|
|
10
11
|
source_links.rb:
|
|
11
|
-
sha256: sha256:
|
|
12
|
+
sha256: sha256:8ce4d11659f4bc151167f99e47299c0f6ef246797c9167c609c1699e3edea2e5
|
|
12
13
|
event: after_turn
|
|
13
14
|
on_error: log
|
|
14
15
|
priority: 90
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Source links
|
|
2
2
|
|
|
3
|
-
The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note
|
|
3
|
+
The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note (the terminals only see this line). The web leaves it out when markdown is on and the answer links every ref it names; otherwise every UI shows it. The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it with the session's cards (a page reload keeps it, after the worker stopped too).
|
|
4
4
|
|
|
5
5
|
The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.36.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
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
8
|
+
self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
|
|
9
9
|
config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -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.
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
- `[CONTEXT NOTE from …]` messages are context notes (`chi note`, or another session's
|
|
20
20
|
`send_note`): background, not requests. `list_sessions` + `send_note` tell another session
|
|
21
21
|
something without starting a turn there; see `docs/sessions.md` "Context notes".
|
|
22
|
+
- `[CONTEXT NOTE from broadcast, …]` is my user's `chi broadcast`: one note shared with every
|
|
23
|
+
session it may concern (a shared ticket, PR or link, or all of them). If it affects my current
|
|
24
|
+
work I say so briefly in my next answer, else I ignore it; I don't act on it unless asked. Only
|
|
25
|
+
my user runs `chi broadcast`; see `docs/broadcast.md`.
|
|
22
26
|
- `[CONTEXT NOTE from context <name>, …]` notes are attached context (`chi context`): an external
|
|
23
27
|
source chi keeps fresh (a PR, a thread, a script's output) and notes when it changes. Read it
|
|
24
28
|
with `context_read` (no name: the list) when my user's request is about it; its text is
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "session"
|
|
5
5
|
require_relative "archive_store"
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "git_head"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
8
|
require_relative "tools/delegate"
|
|
9
9
|
require_relative "tools/delegate_cursor"
|
|
@@ -83,7 +83,7 @@ module Samagotchi
|
|
|
83
83
|
cursor = Tools::DelegateCursors.get_from(cursors, session.id)
|
|
84
84
|
Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
|
|
85
85
|
state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
|
|
86
|
-
delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
|
|
86
|
+
delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
|
|
87
87
|
last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
|
|
88
88
|
last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
|
|
89
89
|
updated_at: session.updated_at, archived: !!row[:archived])
|
|
@@ -113,20 +113,6 @@ module Samagotchi
|
|
|
113
113
|
end
|
|
114
114
|
private_class_method :summary_of
|
|
115
115
|
|
|
116
|
-
# The branch from the folder's own HEAD file (a linked worktree's is
|
|
117
|
-
# under .git/worktrees/<name>), read without git.
|
|
118
|
-
# @return [String, nil]
|
|
119
|
-
def branch(cwd)
|
|
120
|
-
dir = MemoryPaths.git_dir(cwd.to_s)
|
|
121
|
-
head = dir && File.read(File.join(dir, "HEAD"), 512).strip
|
|
122
|
-
return nil if head.nil? || head.empty?
|
|
123
|
-
|
|
124
|
-
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
125
|
-
rescue SystemCallError
|
|
126
|
-
nil
|
|
127
|
-
end
|
|
128
|
-
private_class_method :branch
|
|
129
|
-
|
|
130
116
|
def first_line(id, file, state_dir:)
|
|
131
117
|
text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
|
|
132
118
|
text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -84,6 +84,12 @@ module Samagotchi
|
|
|
84
84
|
# "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
|
|
85
85
|
Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli]),
|
|
86
86
|
|
|
87
|
+
# chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
|
|
88
|
+
# no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
|
|
89
|
+
# delivers it there without triage.
|
|
90
|
+
Entry.new(key: "broadcast.active_hours", yaml_path: %w[broadcast active_hours], type: :float, default: 8.0, expose: %i[env config]),
|
|
91
|
+
Entry.new(key: "broadcast.ticket_pattern", yaml_path: %w[broadcast ticket_pattern], type: :string, default: '\b[A-Z][A-Z0-9]+-\d+\b', expose: %i[env config]),
|
|
92
|
+
|
|
87
93
|
Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
|
|
88
94
|
Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
|
|
89
95
|
Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -203,7 +203,7 @@ module Samagotchi
|
|
|
203
203
|
emit: ->(sink, event) { emit_event(sink, event) },
|
|
204
204
|
show_card: ->(**card) { show_card(**card) },
|
|
205
205
|
tools: -> { @tools },
|
|
206
|
-
notify: ->(text, level, source) { hook_notify(text, level, source) },
|
|
206
|
+
notify: ->(text, level, source, fallback_for: nil) { hook_notify(text, level, source, fallback_for: fallback_for) },
|
|
207
207
|
tools_changed: -> { tools_changed! }
|
|
208
208
|
)
|
|
209
209
|
@lifecycle_mutex = Mutex.new
|
|
@@ -1223,7 +1223,7 @@ module Samagotchi
|
|
|
1223
1223
|
# hook's label (event[:hook]) from the registry.
|
|
1224
1224
|
def hook_runtime
|
|
1225
1225
|
Hooks::Runtime.new(
|
|
1226
|
-
notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
|
|
1226
|
+
notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
|
|
1227
1227
|
ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
|
|
1228
1228
|
stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
|
|
1229
1229
|
steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
|
|
@@ -1239,14 +1239,24 @@ module Samagotchi
|
|
|
1239
1239
|
end
|
|
1240
1240
|
private :hook_source
|
|
1241
1241
|
|
|
1242
|
+
# What a notice may stand in for (hook_notify's fallback_for:).
|
|
1243
|
+
NOTICE_FALLBACKS = %i[display].freeze
|
|
1244
|
+
|
|
1242
1245
|
# One line to the user (:hook_notice). During a turn it is a turn
|
|
1243
1246
|
# event: the turn's sink (the REPL) and the observers (bridge, log).
|
|
1244
1247
|
# Outside one (a plugin's command at the prompt) it is announced with
|
|
1245
1248
|
# between_turns: true, which every UI shows as cards are shown.
|
|
1246
|
-
|
|
1249
|
+
#
|
|
1250
|
+
# +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
|
|
1251
|
+
# :display, the answer's display (AnswerDisplay), for a UI that doesn't
|
|
1252
|
+
# render its links. A UI that does leaves the line out; the others, the
|
|
1253
|
+
# log among them, show it. An unknown value is dropped, so the line
|
|
1254
|
+
# shows everywhere.
|
|
1255
|
+
def hook_notify(text, level, hook, fallback_for: nil)
|
|
1247
1256
|
level = (level || :info).to_sym
|
|
1248
1257
|
level = :info unless %i[info warn].include?(level)
|
|
1249
|
-
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level
|
|
1258
|
+
notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
|
|
1259
|
+
fallback_for: notice_fallback(fallback_for, hook) }.compact
|
|
1250
1260
|
return hold_load_event(notice) && nil if @loading_plugins
|
|
1251
1261
|
|
|
1252
1262
|
in_turn, sink = @turn_state.in_turn_sink
|
|
@@ -1263,6 +1273,20 @@ module Samagotchi
|
|
|
1263
1273
|
end
|
|
1264
1274
|
private :hook_notify
|
|
1265
1275
|
|
|
1276
|
+
# A known fallback_for (a String is symbolized), else nil; an unknown
|
|
1277
|
+
# one is logged once at debug.
|
|
1278
|
+
def notice_fallback(value, hook)
|
|
1279
|
+
return if value.nil?
|
|
1280
|
+
|
|
1281
|
+
fallback = value.to_s.to_sym
|
|
1282
|
+
return fallback if NOTICE_FALLBACKS.include?(fallback)
|
|
1283
|
+
|
|
1284
|
+
@unknown_fallbacks ||= Set.new
|
|
1285
|
+
Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
|
|
1286
|
+
nil
|
|
1287
|
+
end
|
|
1288
|
+
private :notice_fallback
|
|
1289
|
+
|
|
1266
1290
|
# A guardrails line to the user after load (:guardrail_warning, as the
|
|
1267
1291
|
# load failures show): a running turn's event, else announced.
|
|
1268
1292
|
def guardrail_notify(message)
|
|
@@ -2285,7 +2309,7 @@ module Samagotchi
|
|
|
2285
2309
|
cwd: -> { @session&.working_directory },
|
|
2286
2310
|
messages: -> { plugin_messages },
|
|
2287
2311
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
2288
|
-
notify: ->(text, level, label) { hook_notify(text, level, label) },
|
|
2312
|
+
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
2289
2313
|
ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
|
|
2290
2314
|
hook_ask_user(question, options, header, allow_freeform, hook)
|
|
2291
2315
|
},
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "memory_paths"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# What a folder has checked out, read from its HEAD file without running
|
|
7
|
+
# git (cheap enough for a list of sessions).
|
|
8
|
+
module GitHead
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# The branch checked out in +dir+'s repository (a linked worktree's own
|
|
12
|
+
# HEAD is under .git/worktrees/<name>), a short sha when detached.
|
|
13
|
+
# @return [String, nil] nil outside git or for an unreadable HEAD
|
|
14
|
+
def branch(dir)
|
|
15
|
+
git_dir = MemoryPaths.git_dir(dir.to_s)
|
|
16
|
+
head = git_dir && File.read(File.join(git_dir, "HEAD"), 512).to_s.strip
|
|
17
|
+
return nil if head.nil? || head.empty?
|
|
18
|
+
|
|
19
|
+
head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
|
|
20
|
+
rescue SystemCallError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -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
|
|
@@ -13,9 +13,10 @@ module Samagotchi
|
|
|
13
13
|
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
14
14
|
# Rules that ask about chi's own config and hooks: the core
|
|
15
15
|
# ProtectedPaths asks and the guardrails bundle's shell rule; and a
|
|
16
|
-
# command source for attached context (it runs later, ungated)
|
|
17
|
-
# the user may allow them
|
|
18
|
-
|
|
16
|
+
# command source for attached context (it runs later, ungated); chi
|
|
17
|
+
# broadcast, which is the user's. Only the user may allow them
|
|
18
|
+
# (ParentApprovals).
|
|
19
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast].freeze
|
|
19
20
|
SCOPES = %w[once session repo rule].freeze
|
|
20
21
|
DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
|
|
21
22
|
|
|
@@ -83,8 +83,9 @@ module Samagotchi
|
|
|
83
83
|
|
|
84
84
|
# What a hook can do beyond reading its event: the Engine's
|
|
85
85
|
# callables, each given the hook's label. +notify+ takes
|
|
86
|
-
# (text:, level:, hook
|
|
87
|
-
#
|
|
86
|
+
# (text:, level:, hook:, and fallback_for: when the hook gave one: what
|
|
87
|
+
# the line stands in for, Engine#hook_notify) and shows one line to the
|
|
88
|
+
# user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
|
|
88
89
|
# returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
|
|
89
90
|
# cancels the running turn (true when it did); +steer+ takes (text:,
|
|
90
91
|
# hook:) and puts the text into the running turn (Engine#steer, true
|
|
@@ -239,7 +240,10 @@ module Samagotchi
|
|
|
239
240
|
#
|
|
240
241
|
# All procs get the same hash, so event[:hook] is set before each one;
|
|
241
242
|
# the helpers are set once per fire and read event[:hook] when called:
|
|
242
|
-
# event[:notify].call(text, level: :info) one line
|
|
243
|
+
# event[:notify].call(text, level: :info, fallback_for: nil) one line
|
|
244
|
+
# to the user; fallback_for: :display marks a line that stands in
|
|
245
|
+
# for the answer's display (event[:present]), which a UI that
|
|
246
|
+
# renders the display's links leaves out (Engine#hook_notify)
|
|
243
247
|
# event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
|
|
244
248
|
# -> {selected:, freeform:, selected_indices:} or nil (no one to
|
|
245
249
|
# ask, cancelled, bad options)
|
|
@@ -330,8 +334,10 @@ module Samagotchi
|
|
|
330
334
|
def with_runtime(event)
|
|
331
335
|
return unless event.is_a?(Hash)
|
|
332
336
|
|
|
333
|
-
event[:notify] ||= lambda { |text, level: :info|
|
|
334
|
-
|
|
337
|
+
event[:notify] ||= lambda { |text, level: :info, fallback_for: nil|
|
|
338
|
+
notice = { text: text.to_s, level: level, hook: event[:hook] }
|
|
339
|
+
notice[:fallback_for] = fallback_for unless fallback_for.nil?
|
|
340
|
+
@runtime&.notify&.call(**notice)
|
|
335
341
|
nil
|
|
336
342
|
}
|
|
337
343
|
event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
|
|
@@ -147,7 +147,7 @@ module Samagotchi
|
|
|
147
147
|
# hook's, not the user's or the model's).
|
|
148
148
|
def on_hook_notice(event)
|
|
149
149
|
level = event[:level].to_s == "warn" ? :warn : :info
|
|
150
|
-
log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
|
|
150
|
+
log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
# after_turn hooks presented the answer (AnswerDisplay): its size only
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "session"
|
|
4
4
|
require_relative "session_inbox"
|
|
5
|
+
require_relative "note_delivery"
|
|
5
6
|
require_relative "session_manager"
|
|
6
7
|
require_relative "cli/command"
|
|
7
8
|
require_relative "cli/flags"
|
|
@@ -22,6 +23,7 @@ module Samagotchi
|
|
|
22
23
|
-m TEXT the note; without it, stdin is read
|
|
23
24
|
--all every session a worker runs now, in every project
|
|
24
25
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
26
|
+
Every session it may concern, without picking them: chi broadcast
|
|
25
27
|
TEXT
|
|
26
28
|
|
|
27
29
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
@@ -83,6 +85,8 @@ module Samagotchi
|
|
|
83
85
|
options = parsed.options.merge(ids: parsed.args)
|
|
84
86
|
return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
|
|
85
87
|
return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
|
|
88
|
+
# The system prompt reads a note from "broadcast" as chi broadcast's.
|
|
89
|
+
return usage_error("--source broadcast is chi broadcast's own") if options[:source].to_s.strip == "broadcast"
|
|
86
90
|
|
|
87
91
|
options
|
|
88
92
|
end
|
|
@@ -125,21 +129,17 @@ module Samagotchi
|
|
|
125
129
|
|
|
126
130
|
# @return [Boolean] whether the note was queued
|
|
127
131
|
def deliver(id, text, source)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return false
|
|
133
|
-
end
|
|
132
|
+
result = NoteDelivery.deliver(id, text: text, source: source, state_dir: @state_dir)
|
|
133
|
+
@stdout.puts("#{id[0, 8]} #{outcome(result)}")
|
|
134
|
+
result.delivered?
|
|
135
|
+
end
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
@stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
|
|
137
|
+
def outcome(result)
|
|
138
|
+
case result.status
|
|
139
|
+
when :refused then "refused: it is open in a chi REPL; notes need attached mode"
|
|
140
|
+
when :queued then "queued: its worker adds it within a few seconds"
|
|
141
|
+
else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
|
|
141
142
|
end
|
|
142
|
-
true
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
# The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "session_inbox"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
8
|
+
# requires KernelLoop and so the tools (a require cycle otherwise).
|
|
9
|
+
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
10
|
+
|
|
11
|
+
# The one rule for putting a context note into a session, shared by
|
|
12
|
+
# `chi note`, the agent's send_note and `chi broadcast`: a session a chi
|
|
13
|
+
# REPL owns refuses it (notes need attached mode); any other gets the
|
|
14
|
+
# note in its notes/ folder, which a live worker adds within seconds and
|
|
15
|
+
# a session with no worker at its next start. Callers word the outcome.
|
|
16
|
+
module NoteDelivery
|
|
17
|
+
# The outcome for one session.
|
|
18
|
+
# @!attribute status [Symbol] :queued (a worker runs it), :waits (no
|
|
19
|
+
# worker: it waits for the next start) or :refused (a chi REPL owns it)
|
|
20
|
+
# @!attribute queued [Integer] notes waiting in its notes/ folder
|
|
21
|
+
# (:waits only; 0 otherwise)
|
|
22
|
+
Result = Data.define(:id, :status, :queued) do
|
|
23
|
+
def delivered? = status != :refused
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param id [String] a full session id (resolved by the caller)
|
|
29
|
+
# @return [Result]
|
|
30
|
+
# @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
|
|
31
|
+
def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
|
|
32
|
+
owner = SessionManager.session_owner(id, state_dir: state_dir)
|
|
33
|
+
return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
|
|
34
|
+
|
|
35
|
+
path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
|
|
36
|
+
state_dir: state_dir)
|
|
37
|
+
return Result.new(id: id, status: :queued, queued: 0) if owner
|
|
38
|
+
|
|
39
|
+
Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|