pikuri-tasks 0.0.6 → 0.1.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/README.md +16 -14
- data/lib/pikuri/tasks/completed.rb +20 -20
- data/lib/pikuri/tasks/create.rb +32 -32
- data/lib/pikuri/tasks/delete.rb +22 -25
- data/lib/pikuri/tasks/extension.rb +53 -48
- data/lib/pikuri/tasks/in_progress.rb +26 -22
- data/lib/pikuri/tasks/list.rb +85 -66
- data/lib/pikuri/tasks/list_changed.rb +18 -0
- data/lib/pikuri-tasks.rb +2 -10
- metadata +6 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bef39f17faad60151665971aac68c2090ca08d02e343ce209ca00cdc81e4e7c5
|
|
4
|
+
data.tar.gz: bc0e54db37e868a58015c8ff18f0a9aba2a7e1232310faa608fcd4e525e2aea8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6583cca82b6b2b8ac13169ce045420cb960a06157a2cc2f4da7f6ebbbbb232066ae58d9b484c4736bc509b4c78625265a5b84838cd44e9cbe2dbd94c5575fbb0
|
|
7
|
+
data.tar.gz: b778189760eb74d9ee7d882da3b308ed4bd8a020d21c2f480e6f2cc76b1868b64ea907e24f5ce132b688b14c09fcd38e8237e8825bf0a02398c31d99e077ad0e
|
data/README.md
CHANGED
|
@@ -5,19 +5,19 @@ In-memory task list + four LLM-facing tools for the
|
|
|
5
5
|
|
|
6
6
|
Provides:
|
|
7
7
|
- `Pikuri::Tasks::List` — per-Agent in-memory list of
|
|
8
|
-
`(content, status)` items. Status is one of `pending`,
|
|
8
|
+
`(id, content, status)` items. Status is one of `pending`,
|
|
9
9
|
`in_progress`, `completed`. Nothing is written to disk.
|
|
10
10
|
- Four tool classes, all sharing one `List` instance:
|
|
11
11
|
- `Pikuri::Tasks::Create` (`task_create`) — mass-create pending
|
|
12
|
-
items from a
|
|
13
|
-
|
|
14
|
-
nothing is added.
|
|
12
|
+
items from a JSON array of strings. Atomic: if any element is
|
|
13
|
+
blank or a duplicate (within the batch or already on the
|
|
14
|
+
list), nothing is added.
|
|
15
15
|
- `Pikuri::Tasks::InProgress` (`task_in_progress`) — mark an item
|
|
16
|
-
as `in_progress` by
|
|
16
|
+
as `in_progress` by numeric id.
|
|
17
17
|
- `Pikuri::Tasks::Completed` (`task_completed`) — mark an item as
|
|
18
|
-
`completed` by
|
|
18
|
+
`completed` by numeric id.
|
|
19
19
|
- `Pikuri::Tasks::Delete` (`task_delete`) — remove an item by
|
|
20
|
-
|
|
20
|
+
numeric id.
|
|
21
21
|
- `Pikuri::Tasks::Extension` — wires the four tools + a brief
|
|
22
22
|
`<tasks_usage>` workflow snippet into a `Pikuri::Agent` via the
|
|
23
23
|
`c.add_extension(...)` block API.
|
|
@@ -25,10 +25,12 @@ Provides:
|
|
|
25
25
|
Two shape choices worth flagging:
|
|
26
26
|
- **Status is baked into the tool name** (no `status:` parameter
|
|
27
27
|
with an enum). Removes the `"in-progress"` vs `"in_progress"`
|
|
28
|
-
vs `"inprogress"` typo failure mode
|
|
29
|
-
- **
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
vs `"inprogress"` typo failure mode.
|
|
29
|
+
- **Items are addressed by numeric id** — assigned on create,
|
|
30
|
+
shown in every rendered list, never reused after a delete. A
|
|
31
|
+
near-miss when re-typing the task's content cannot lock the
|
|
32
|
+
model out of its own list; duplicates are still rejected on
|
|
33
|
+
`task_create` because they are almost always a mistake.
|
|
32
34
|
|
|
33
35
|
## Install
|
|
34
36
|
|
|
@@ -59,9 +61,9 @@ so the LLM always sees fresh state without a separate read tool:
|
|
|
59
61
|
|
|
60
62
|
```
|
|
61
63
|
<tasks>
|
|
62
|
-
- [pending] Add dark mode toggle
|
|
63
|
-
- [in_progress] Write unit tests
|
|
64
|
-
- [completed] Update README
|
|
64
|
+
- #1 [pending] Add dark mode toggle
|
|
65
|
+
- #2 [in_progress] Write unit tests
|
|
66
|
+
- #3 [completed] Update README
|
|
65
67
|
</tasks>
|
|
66
68
|
```
|
|
67
69
|
|
|
@@ -2,25 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Tasks
|
|
5
|
-
# The +task_completed+ tool: mark the item
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# +"completed"+ / +"complete"+ / +"done"+.
|
|
5
|
+
# The +task_completed+ tool: mark the item with the given +id+ as
|
|
6
|
+
# +completed+. Same shape as {InProgress} (status in the tool name,
|
|
7
|
+
# item addressed by numeric +#id+). Returns {List#render}; a bad id
|
|
8
|
+
# returns +"Error: no such task id"+ plus the current list.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# not match.
|
|
10
|
+
# Sharing: +P_one_agent+ — closes over one agent's {List}; see its
|
|
11
|
+
# +== Sharing+.
|
|
14
12
|
class Completed < Pikuri::Tool
|
|
15
13
|
# @return [String]
|
|
16
14
|
DESCRIPTION = <<~DESC
|
|
17
15
|
Mark a task as `completed` once the work — including any required verification — is actually done.
|
|
18
16
|
|
|
19
17
|
Usage:
|
|
20
|
-
- Pass the
|
|
18
|
+
- Pass the task's numeric `id` as shown in the rendered list (`- #3 [in_progress] ...` → id 3).
|
|
21
19
|
- Do NOT mark `completed` based on intent; mark it only after the underlying work is verified.
|
|
22
20
|
- If the work is partially done or blocked, leave the task `in_progress` and add a follow-up via `task_create`.
|
|
23
|
-
- On `Error: no such task: ...` the call did nothing —
|
|
21
|
+
- On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
|
|
24
22
|
- On success the full current list is returned for you to read back.
|
|
25
23
|
DESC
|
|
26
24
|
|
|
@@ -31,24 +29,26 @@ module Pikuri
|
|
|
31
29
|
name: 'task_completed',
|
|
32
30
|
description: DESCRIPTION,
|
|
33
31
|
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
34
|
-
p.
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
p.required_integer :id,
|
|
33
|
+
'Numeric id of the existing task to mark as ' \
|
|
34
|
+
'completed, as shown in the rendered list, e.g. 3.'
|
|
37
35
|
},
|
|
38
|
-
execute: lambda { |
|
|
39
|
-
Completed.execute(list: list,
|
|
40
|
-
}
|
|
36
|
+
execute: lambda { |id:|
|
|
37
|
+
Completed.execute(list: list, id: id)
|
|
38
|
+
},
|
|
39
|
+
# No legs; see {Create}.
|
|
40
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
41
41
|
)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# @param list [List]
|
|
45
|
-
# @param
|
|
45
|
+
# @param id [Integer]
|
|
46
46
|
# @return [String]
|
|
47
|
-
def self.execute(list:,
|
|
48
|
-
list.set_status(
|
|
47
|
+
def self.execute(list:, id:)
|
|
48
|
+
list.set_status(id: id, status: 'completed')
|
|
49
49
|
list.render
|
|
50
50
|
rescue ItemNotFound
|
|
51
|
-
"Error: no such task:
|
|
51
|
+
"Error: no such task id: #{id}. Current list:\n#{list.render}"
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
end
|
data/lib/pikuri/tasks/create.rb
CHANGED
|
@@ -2,23 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Tasks
|
|
5
|
-
# The +task_create+ tool: mass-create pending items
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
5
|
+
# The +task_create+ tool: mass-create pending items from a JSON array
|
|
6
|
+
# of strings — a native +items+ array parameter, the shape the model's
|
|
7
|
+
# training prior produces unprompted. (Regression guard: don't revert
|
|
8
|
+
# to a newline-separated string. An earlier such design meant to spare
|
|
9
|
+
# small models the bracket-balancing, but models sent JSON arrays
|
|
10
|
+
# anyway and the splitter turned `[`, `"foo",`, `]` into garbage tasks
|
|
11
|
+
# — match the prior, don't fight it.)
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# the LLM doesn't have to reconcile.
|
|
13
|
+
# Atomic: each element is stripped, and a blank or duplicate element
|
|
14
|
+
# (within the batch or already in the list) aborts the *whole* call
|
|
15
|
+
# with an +"Error: ..."+ string and adds nothing — the LLM resends a
|
|
16
|
+
# corrected batch, and never has to reconcile a half-applied list. On
|
|
17
|
+
# success returns {List#render}.
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
19
|
+
# Sharing: +P_one_agent+ — closes over one agent's {List}; see its
|
|
20
|
+
# +== Sharing+.
|
|
22
21
|
class Create < Pikuri::Tool
|
|
23
22
|
# @return [String] static description shown to the LLM,
|
|
24
23
|
# opencode-shape (summary + +Usage:+ bullets).
|
|
@@ -27,42 +26,43 @@ module Pikuri
|
|
|
27
26
|
|
|
28
27
|
Usage:
|
|
29
28
|
- Use at the start of a multi-step task to capture the plan.
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
- Empty input is rejected the same way.
|
|
33
|
-
- On success the full current list is returned for you to read back.
|
|
29
|
+
- A blank element, a duplicate (within the batch or already on the list), or an empty array aborts the whole call with `Error: ...` and adds nothing — resend a corrected batch.
|
|
30
|
+
- On success the full current list is returned, with each task's `#id` — use that id with `task_in_progress` / `task_completed` / `task_delete`.
|
|
34
31
|
DESC
|
|
35
32
|
|
|
36
|
-
# @param list [List] the shared per-Agent list, captured by
|
|
37
|
-
#
|
|
38
|
-
# same instance.
|
|
33
|
+
# @param list [List] the shared per-Agent list, captured by closure
|
|
34
|
+
# so every task tool mutates the same instance.
|
|
39
35
|
# @return [Create]
|
|
40
36
|
def initialize(list:)
|
|
41
37
|
super(
|
|
42
38
|
name: 'task_create',
|
|
43
39
|
description: DESCRIPTION,
|
|
44
40
|
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
45
|
-
p.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
'Blank lines are ignored.'
|
|
41
|
+
p.required_string_array :items,
|
|
42
|
+
'Task contents, one task per array element, e.g. ' \
|
|
43
|
+
'["Add dark mode toggle", "Write unit tests", "Update README"].'
|
|
49
44
|
},
|
|
50
45
|
execute: lambda { |items:|
|
|
51
46
|
Create.execute(list: list, items: items)
|
|
52
|
-
}
|
|
47
|
+
},
|
|
48
|
+
# No legs: an in-memory, per-agent list. Nothing read from disk, nothing sent.
|
|
49
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
53
50
|
)
|
|
54
51
|
end
|
|
55
52
|
|
|
56
|
-
# Validate and apply the batch.
|
|
57
|
-
# without constructing a tool instance.
|
|
53
|
+
# Validate and apply the batch.
|
|
58
54
|
#
|
|
59
55
|
# @param list [List]
|
|
60
|
-
# @param items [String] raw +items+ argument from the LLM
|
|
56
|
+
# @param items [Array<String>] raw +items+ argument from the LLM
|
|
57
|
+
# (already type-validated by {Pikuri::Tool::Parameters}).
|
|
61
58
|
# @return [String] either {List#render} on success or an
|
|
62
59
|
# +"Error: ..."+ string the LLM can react to.
|
|
63
60
|
def self.execute(list:, items:)
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
return 'Error: task_create requires at least one item' if items.empty?
|
|
62
|
+
|
|
63
|
+
cleaned = items.map(&:strip)
|
|
64
|
+
blank = cleaned.index('')
|
|
65
|
+
return "Error: blank item at index #{blank} — every element must be non-blank task text" if blank
|
|
66
66
|
|
|
67
67
|
seen_in_batch = {}
|
|
68
68
|
cleaned.each do |c|
|
data/lib/pikuri/tasks/delete.rb
CHANGED
|
@@ -2,29 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Tasks
|
|
5
|
-
# The +task_delete+ tool: remove the item
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# The +task_delete+ tool: remove the item with the given +id+.
|
|
6
|
+
# Distinct from {Completed} in intent — +completed+ means the work was
|
|
7
|
+
# done, +delete+ means the task shouldn't have been there — a
|
|
8
|
+
# distinction the tool names carry, not the list. A deleted id is never
|
|
9
|
+
# reused ({List#add}). Returns {List#render}; a bad id returns
|
|
10
|
+
# +"Error: no such task id"+ plus the current list.
|
|
9
11
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# there. The list itself draws no such distinction once an item
|
|
13
|
-
# is gone, but the LLM picks the right verb because the tool
|
|
14
|
-
# names make the intent clear.
|
|
15
|
-
#
|
|
16
|
-
# Returns the rendered current list via {List#render} on success,
|
|
17
|
-
# or +"Error: no such task: '<content>'"+ when the content does
|
|
18
|
-
# not match.
|
|
12
|
+
# Sharing: +P_one_agent+ — closes over one agent's {List}; see its
|
|
13
|
+
# +== Sharing+.
|
|
19
14
|
class Delete < Pikuri::Tool
|
|
20
15
|
# @return [String]
|
|
21
16
|
DESCRIPTION = <<~DESC
|
|
22
17
|
Remove a task from the list. Use this for items that turn out not to be needed, were created in error, or have been superseded.
|
|
23
18
|
|
|
24
19
|
Usage:
|
|
25
|
-
- Pass the
|
|
20
|
+
- Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` → id 3).
|
|
26
21
|
- Use `task_completed` (not this) when the work was actually done.
|
|
27
|
-
- On `Error: no such task: ...` the call did nothing —
|
|
22
|
+
- On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
|
|
28
23
|
- On success the full current list is returned for you to read back.
|
|
29
24
|
DESC
|
|
30
25
|
|
|
@@ -35,24 +30,26 @@ module Pikuri
|
|
|
35
30
|
name: 'task_delete',
|
|
36
31
|
description: DESCRIPTION,
|
|
37
32
|
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
38
|
-
p.
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
p.required_integer :id,
|
|
34
|
+
'Numeric id of the existing task to remove, ' \
|
|
35
|
+
'as shown in the rendered list, e.g. 3.'
|
|
36
|
+
},
|
|
37
|
+
execute: lambda { |id:|
|
|
38
|
+
Delete.execute(list: list, id: id)
|
|
41
39
|
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
40
|
+
# No legs; see {Create}.
|
|
41
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
45
42
|
)
|
|
46
43
|
end
|
|
47
44
|
|
|
48
45
|
# @param list [List]
|
|
49
|
-
# @param
|
|
46
|
+
# @param id [Integer]
|
|
50
47
|
# @return [String]
|
|
51
|
-
def self.execute(list:,
|
|
52
|
-
list.delete(
|
|
48
|
+
def self.execute(list:, id:)
|
|
49
|
+
list.delete(id)
|
|
53
50
|
list.render
|
|
54
51
|
rescue ItemNotFound
|
|
55
|
-
"Error: no such task:
|
|
52
|
+
"Error: no such task id: #{id}. Current list:\n#{list.render}"
|
|
56
53
|
end
|
|
57
54
|
end
|
|
58
55
|
end
|
|
@@ -3,47 +3,28 @@
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
# Namespace for the in-memory task-list feature. Holds the
|
|
5
5
|
# {List} value type, the four task tool classes ({Create},
|
|
6
|
-
# {InProgress}, {Completed}, {Delete}),
|
|
7
|
-
# wires them into an
|
|
6
|
+
# {InProgress}, {Completed}, {Delete}), the {ListChanged} domain
|
|
7
|
+
# event, and the {Extension} that wires them into an
|
|
8
|
+
# {Pikuri::Agent}.
|
|
8
9
|
module Tasks
|
|
9
|
-
# An {Pikuri::Agent::Extension} that auto-wires an in-memory
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# == Usage
|
|
10
|
+
# An {Pikuri::Agent::Extension} that auto-wires an in-memory task list
|
|
11
|
+
# onto an agent: constructs a fresh {List}, registers the four task
|
|
12
|
+
# tools against it, contributes a workflow snippet, and (in {#bind})
|
|
13
|
+
# arms {ListChanged} emission.
|
|
15
14
|
#
|
|
16
15
|
# Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
17
16
|
# c.add_extension Pikuri::Tasks::Extension.new
|
|
18
17
|
# end
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# Sub-agents do not inherit extensions (see CLAUDE.md's "Seams").
|
|
26
|
-
# Concretely: a sub-agent spawned by the +agent+ tool gets a
|
|
27
|
-
# fresh persona, fresh toolset, no task list. That keeps the
|
|
28
|
-
# parent's plan private to the parent and avoids the
|
|
29
|
-
# who-owns-which-task confusion a shared list would produce.
|
|
30
|
-
# If a host wants the sub-agent to also have a task list, it
|
|
31
|
-
# adds the extension to the sub-agent's own configurator —
|
|
32
|
-
# cleanly opt-in, no implicit sharing.
|
|
33
|
-
#
|
|
34
|
-
# == Empty default
|
|
35
|
-
#
|
|
36
|
-
# No +catalog+-style empty state: registering the extension
|
|
37
|
-
# always installs the four tools and the snippet. A host that
|
|
38
|
-
# doesn't want tasks simply omits the extension.
|
|
19
|
+
# Sub-agents don't inherit extensions, so a spawned sub-agent gets no
|
|
20
|
+
# task list — keeping the parent's plan private and avoiding
|
|
21
|
+
# who-owns-which-task confusion. A host wanting one adds the extension
|
|
22
|
+
# to the sub-agent's own configurator.
|
|
39
23
|
class Extension
|
|
40
24
|
include Pikuri::Agent::Extension
|
|
41
25
|
|
|
42
|
-
# System-prompt snippet appended once per agent.
|
|
43
|
-
#
|
|
44
|
-
# descriptions cover their own usage). Mirrors the shape of
|
|
45
|
-
# opencode's +todowrite.txt+ but condensed to fit pikuri's
|
|
46
|
-
# "short prose over abstract framing" docs convention.
|
|
26
|
+
# System-prompt snippet appended once per agent. Rules-of-thumb
|
|
27
|
+
# only, no inventory (the tool descriptions cover their own usage).
|
|
47
28
|
#
|
|
48
29
|
# @return [String]
|
|
49
30
|
PROMPT_SNIPPET = <<~PROMPT
|
|
@@ -51,19 +32,18 @@ module Pikuri
|
|
|
51
32
|
You have an in-memory task list. Use it to plan and track multi-step work.
|
|
52
33
|
|
|
53
34
|
Workflow:
|
|
54
|
-
- When a task has 3+ steps, call `task_create` once with the full plan (
|
|
55
|
-
- Before starting an item, call `task_in_progress` with its
|
|
56
|
-
- When an item is fully done (including any required verification), call `task_completed` with its
|
|
35
|
+
- When a task has 3+ steps, call `task_create` once with the full plan (a JSON array of strings, all start as `pending`).
|
|
36
|
+
- Before starting an item, call `task_in_progress` with its numeric id. Keep exactly one item `in_progress` at a time.
|
|
37
|
+
- When an item is fully done (including any required verification), call `task_completed` with its numeric id.
|
|
57
38
|
- Use `task_delete` to remove items that turn out not to be needed.
|
|
58
39
|
|
|
59
40
|
Skip task tracking entirely for single-step or purely informational requests — it adds noise, not value.
|
|
60
41
|
|
|
61
|
-
Every mutation returns the full current list, so you do not need a separate read tool.
|
|
42
|
+
Every mutation returns the full current list, with each task's id (`- #3 [pending] ...` → id 3), so you do not need a separate read tool. Ids never change and are never reused.
|
|
62
43
|
</tasks_usage>
|
|
63
44
|
PROMPT
|
|
64
45
|
|
|
65
|
-
# Tool classes the extension auto-registers.
|
|
66
|
-
# construction and for the duplicate-registration guard below.
|
|
46
|
+
# Tool classes the extension auto-registers.
|
|
67
47
|
TOOL_CLASSES = [Create, InProgress, Completed, Delete].freeze
|
|
68
48
|
|
|
69
49
|
# @return [Extension]
|
|
@@ -71,17 +51,16 @@ module Pikuri
|
|
|
71
51
|
@list = List.new
|
|
72
52
|
end
|
|
73
53
|
|
|
74
|
-
# @return [List] the per-agent list, exposed for tests
|
|
75
|
-
#
|
|
76
|
-
#
|
|
54
|
+
# @return [List] the per-agent list, exposed for tests. Do NOT read
|
|
55
|
+
# it from another thread — it's agent-thread-confined; consume the
|
|
56
|
+
# {ListChanged} events wired by {#bind} instead.
|
|
77
57
|
attr_reader :list
|
|
78
58
|
|
|
79
|
-
# Construct the four tools (each sharing +@list+)
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# a different list.
|
|
59
|
+
# Construct the four tools (each sharing +@list+), register them,
|
|
60
|
+
# and append {PROMPT_SNIPPET}. Raises if any of the four were
|
|
61
|
+
# pre-registered via +c.add_tool+ — the extension is the single
|
|
62
|
+
# owner of the shared list, and a pre-registered tool would bind to
|
|
63
|
+
# a different one.
|
|
85
64
|
#
|
|
86
65
|
# @param c [Pikuri::Agent::Configurator]
|
|
87
66
|
# @return [void]
|
|
@@ -95,7 +74,33 @@ module Pikuri
|
|
|
95
74
|
end
|
|
96
75
|
|
|
97
76
|
TOOL_CLASSES.each { |cls| c.add_tool(cls.new(list: @list)) }
|
|
98
|
-
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Array<String>] the static task-workflow snippet.
|
|
81
|
+
def system_prompt_snippets = [PROMPT_SNIPPET]
|
|
82
|
+
|
|
83
|
+
# Arm {List#on_change} to emit a {ListChanged} (carrying a fresh
|
|
84
|
+
# {List#items} snapshot) after every mutation — how a UI listener
|
|
85
|
+
# observes the list without touching the agent-thread-confined {List}.
|
|
86
|
+
#
|
|
87
|
+
# @param ctx [Pikuri::Agent::ExtensionContext]
|
|
88
|
+
# @return [void]
|
|
89
|
+
def bind(ctx)
|
|
90
|
+
@list.on_change = -> { ctx.emit_event(ListChanged.new(items: @list.items)) }
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clear the task list on conversation reset — a plan is meaningless
|
|
95
|
+
# without the conversation that produced it. {List#clear} fires the
|
|
96
|
+
# armed +on_change+, so an empty {ListChanged} reaches the listener
|
|
97
|
+
# stream like any other mutation.
|
|
98
|
+
#
|
|
99
|
+
# @param ctx [Pikuri::Agent::ExtensionContext] unused; part of the
|
|
100
|
+
# protocol signature.
|
|
101
|
+
# @return [void]
|
|
102
|
+
def on_conversation_reset(_ctx)
|
|
103
|
+
@list.clear
|
|
99
104
|
nil
|
|
100
105
|
end
|
|
101
106
|
end
|
|
@@ -2,26 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Tasks
|
|
5
|
-
# The +task_in_progress+ tool: mark the item
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# +"in_progress"+ vs +"inprogress"+ typos) at the cost of one
|
|
10
|
-
# extra tool class.
|
|
5
|
+
# The +task_in_progress+ tool: mark the item with the given +id+ as
|
|
6
|
+
# +in_progress+. The status is baked into the tool name, not a
|
|
7
|
+
# parameter — one fewer degree of freedom for the LLM (no
|
|
8
|
+
# +"in-progress"+/+"inprogress"+ typos) at the cost of one tool class.
|
|
11
9
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# Items are addressed by numeric +#id+, not content string (regression
|
|
11
|
+
# guard: an earlier content-as-id design locked the LLM out of its own
|
|
12
|
+
# list on one near-miss byte — a stray quote or trailing comma; an id
|
|
13
|
+
# is two digits it just read back). Returns {List#render}; a bad id
|
|
14
|
+
# returns +"Error: no such task id"+ plus the current list.
|
|
15
|
+
#
|
|
16
|
+
# Sharing: +P_one_agent+ — closes over one agent's {List}; see its
|
|
17
|
+
# +== Sharing+.
|
|
16
18
|
class InProgress < Pikuri::Tool
|
|
17
19
|
# @return [String]
|
|
18
20
|
DESCRIPTION = <<~DESC
|
|
19
21
|
Mark a task as `in_progress` immediately before you start working on it.
|
|
20
22
|
|
|
21
23
|
Usage:
|
|
22
|
-
- Pass the
|
|
24
|
+
- Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` → id 3).
|
|
23
25
|
- Keep exactly one task `in_progress` at a time. Finish (or revert) the current one before starting another.
|
|
24
|
-
- On `Error: no such task: ...` the call did nothing —
|
|
26
|
+
- On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
|
|
25
27
|
- On success the full current list is returned for you to read back.
|
|
26
28
|
DESC
|
|
27
29
|
|
|
@@ -32,24 +34,26 @@ module Pikuri
|
|
|
32
34
|
name: 'task_in_progress',
|
|
33
35
|
description: DESCRIPTION,
|
|
34
36
|
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
35
|
-
p.
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
p.required_integer :id,
|
|
38
|
+
'Numeric id of the existing task to mark as ' \
|
|
39
|
+
'in_progress, as shown in the rendered list, e.g. 3.'
|
|
40
|
+
},
|
|
41
|
+
execute: lambda { |id:|
|
|
42
|
+
InProgress.execute(list: list, id: id)
|
|
38
43
|
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
44
|
+
# No legs; see {Create}.
|
|
45
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
42
46
|
)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# @param list [List]
|
|
46
|
-
# @param
|
|
50
|
+
# @param id [Integer]
|
|
47
51
|
# @return [String]
|
|
48
|
-
def self.execute(list:,
|
|
49
|
-
list.set_status(
|
|
52
|
+
def self.execute(list:, id:)
|
|
53
|
+
list.set_status(id: id, status: 'in_progress')
|
|
50
54
|
list.render
|
|
51
55
|
rescue ItemNotFound
|
|
52
|
-
"Error: no such task:
|
|
56
|
+
"Error: no such task id: #{id}. Current list:\n#{list.render}"
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
end
|
data/lib/pikuri/tasks/list.rb
CHANGED
|
@@ -2,54 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Tasks
|
|
5
|
-
# Raised by {List#add} when a new item's +content+ already appears
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# content string so the tool layer can surface it verbatim in the
|
|
10
|
-
# +"Error: ..."+ observation.
|
|
5
|
+
# Raised by {List#add} when a new item's +content+ already appears in
|
|
6
|
+
# the list — rejected because a duplicate is almost always an LLM
|
|
7
|
+
# mistake (re-sending an already-captured plan). Carries the offending
|
|
8
|
+
# content for the +"Error: ..."+ observation.
|
|
11
9
|
class DuplicateItem < StandardError; end
|
|
12
10
|
|
|
13
|
-
# Raised by {List#set_status} and {List#delete} when no item with
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# the name on the next turn.
|
|
11
|
+
# Raised by {List#set_status} and {List#delete} when no item with the
|
|
12
|
+
# given +id+ exists; the mutation tools rescue it into an +"Error: no
|
|
13
|
+
# such task id"+ observation.
|
|
17
14
|
class ItemNotFound < StandardError; end
|
|
18
15
|
|
|
19
|
-
# Allowed values for {Item#status}.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# +
|
|
23
|
-
# fourth status.
|
|
16
|
+
# Allowed values for {Item#status}. Deliberately three — the workflow
|
|
17
|
+
# the prompt advertises (pending → in_progress → completed) — with
|
|
18
|
+
# +task_delete+ removing items outright rather than adding a fourth
|
|
19
|
+
# +cancelled+/+deleted+ status.
|
|
24
20
|
STATUSES = %w[pending in_progress completed].freeze
|
|
25
21
|
|
|
26
|
-
# One row in a {List}. +
|
|
27
|
-
#
|
|
28
|
-
# one of {STATUSES}.
|
|
29
|
-
Item = Data.define(:content, :status)
|
|
22
|
+
# One row in a {List}. +id+ is the numeric identifier the mutation
|
|
23
|
+
# tools address the item by (assigned by {List#add}, never reused);
|
|
24
|
+
# +content+ is the LLM-visible task text; +status+ is one of {STATUSES}.
|
|
25
|
+
Item = Data.define(:id, :content, :status)
|
|
30
26
|
|
|
31
27
|
# An in-memory ordered list of {Item}s, scoped to a single
|
|
32
|
-
# {Pikuri::Agent}
|
|
33
|
-
#
|
|
34
|
-
# so every mutation hits the same instance.
|
|
28
|
+
# {Pikuri::Agent}, shared by closure into all four task tools so every
|
|
29
|
+
# mutation hits the same instance.
|
|
35
30
|
#
|
|
36
|
-
#
|
|
31
|
+
# Not persisted: dropped when the +Agent+ is collected, matching the
|
|
32
|
+
# gem's "in-memory only" scope.
|
|
37
33
|
#
|
|
38
|
-
#
|
|
39
|
-
# (ruby_llm dispatches them sequentially), so no locking. A future
|
|
40
|
-
# parallel-tool-execution feature would need a +Mutex+ here.
|
|
34
|
+
# == Sharing
|
|
41
35
|
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
36
|
+
# +P_one_agent+ — this is one conversation's plan, so two agents on one
|
|
37
|
+
# list would read and complete each other's tasks. Unguarded, and it needs
|
|
38
|
+
# no lock because its only caller is the agent whose tool calls are already
|
|
39
|
+
# serialized. Another thread (a UI rendering the list) must not touch a
|
|
40
|
+
# +List+ directly: it consumes the {ListChanged} events {Extension#bind}
|
|
41
|
+
# wires onto the listener stream, whose +items+ payload is an immutable
|
|
42
|
+
# snapshot safe to hand across threads.
|
|
47
43
|
class List
|
|
48
44
|
# @return [List]
|
|
49
45
|
def initialize
|
|
50
46
|
@items = []
|
|
47
|
+
@next_id = 1
|
|
48
|
+
@on_change = nil
|
|
51
49
|
end
|
|
52
50
|
|
|
51
|
+
# Optional zero-argument hook invoked synchronously after every
|
|
52
|
+
# *successful* mutation (a raise means nothing changed, so no
|
|
53
|
+
# notify). Set by {Extension#bind} to emit a {ListChanged}; +nil+
|
|
54
|
+
# (default) disables notification.
|
|
55
|
+
#
|
|
56
|
+
# @return [Proc, nil]
|
|
57
|
+
attr_accessor :on_change
|
|
58
|
+
|
|
53
59
|
# @return [Array<Item>] a frozen snapshot of the current items,
|
|
54
60
|
# in insertion order. Callers cannot mutate the internal
|
|
55
61
|
# storage through this accessor.
|
|
@@ -67,82 +73,95 @@ module Pikuri
|
|
|
67
73
|
@items.empty?
|
|
68
74
|
end
|
|
69
75
|
|
|
70
|
-
# Append a new item with status +pending
|
|
71
|
-
#
|
|
72
|
-
# the
|
|
76
|
+
# Append a new item with status +pending+ and the next id from a
|
|
77
|
+
# monotonic per-list counter. Ids are never reused: after a
|
|
78
|
+
# delete, the freed id stays dead, so a stale id held by the LLM
|
|
79
|
+
# errors loudly instead of silently resolving to a newer task.
|
|
73
80
|
#
|
|
74
81
|
# @param content [String] non-empty content; whitespace is the
|
|
75
82
|
# caller's responsibility.
|
|
76
83
|
# @return [Item] the newly added item
|
|
77
|
-
# @raise [DuplicateItem] if an item with the same +content+
|
|
78
|
-
# already exists.
|
|
84
|
+
# @raise [DuplicateItem] if an item with the same +content+ exists.
|
|
79
85
|
def add(content)
|
|
80
|
-
raise DuplicateItem, content if
|
|
86
|
+
raise DuplicateItem, content if @items.any? { |i| i.content == content }
|
|
81
87
|
|
|
82
|
-
item = Item.new(content: content, status: 'pending')
|
|
88
|
+
item = Item.new(id: @next_id, content: content, status: 'pending')
|
|
89
|
+
@next_id += 1
|
|
83
90
|
@items << item
|
|
91
|
+
@on_change&.call
|
|
84
92
|
item
|
|
85
93
|
end
|
|
86
94
|
|
|
87
|
-
# Update the status of the item whose +
|
|
95
|
+
# Update the status of the item whose +id+ matches.
|
|
88
96
|
#
|
|
89
|
-
# @param
|
|
97
|
+
# @param id [Integer]
|
|
90
98
|
# @param status [String] one of {STATUSES}.
|
|
91
99
|
# @return [Item] the updated item (a fresh frozen +Data+
|
|
92
100
|
# instance — the old one is replaced in place).
|
|
93
101
|
# @raise [ItemNotFound] if no matching item exists.
|
|
94
102
|
# @raise [ArgumentError] if +status+ is not in {STATUSES}.
|
|
95
|
-
def set_status(
|
|
103
|
+
def set_status(id:, status:)
|
|
96
104
|
unless STATUSES.include?(status)
|
|
97
105
|
raise ArgumentError, "invalid status: #{status.inspect} (allowed: #{STATUSES.join(', ')})"
|
|
98
106
|
end
|
|
99
107
|
|
|
100
|
-
idx = @items.index { |i| i.
|
|
101
|
-
raise ItemNotFound,
|
|
108
|
+
idx = @items.index { |i| i.id == id }
|
|
109
|
+
raise ItemNotFound, id.to_s if idx.nil?
|
|
102
110
|
|
|
103
|
-
@items[idx] =
|
|
111
|
+
@items[idx] = @items[idx].with(status: status)
|
|
112
|
+
@on_change&.call
|
|
104
113
|
@items[idx]
|
|
105
114
|
end
|
|
106
115
|
|
|
107
|
-
# Remove the item whose +
|
|
116
|
+
# Remove the item whose +id+ matches. The id is not reused for
|
|
117
|
+
# later items (see {#add}).
|
|
108
118
|
#
|
|
109
|
-
# @param
|
|
119
|
+
# @param id [Integer]
|
|
110
120
|
# @return [Item] the removed item.
|
|
111
121
|
# @raise [ItemNotFound] if no matching item exists.
|
|
112
|
-
def delete(
|
|
113
|
-
idx = @items.index { |i| i.
|
|
114
|
-
raise ItemNotFound,
|
|
122
|
+
def delete(id)
|
|
123
|
+
idx = @items.index { |i| i.id == id }
|
|
124
|
+
raise ItemNotFound, id.to_s if idx.nil?
|
|
125
|
+
|
|
126
|
+
removed = @items.delete_at(idx)
|
|
127
|
+
@on_change&.call
|
|
128
|
+
removed
|
|
129
|
+
end
|
|
115
130
|
|
|
116
|
-
|
|
131
|
+
# Drop every item and rewind the id counter to 1, then fire
|
|
132
|
+
# {#on_change} (always, even when already empty, so a UI listener
|
|
133
|
+
# sees the cleared state). Rewinding +@next_id+ is safe here — unlike
|
|
134
|
+
# after {#delete}, a clear wipes the model's context too, so no stale
|
|
135
|
+
# id survives to collide with a reused one.
|
|
136
|
+
#
|
|
137
|
+
# @return [void]
|
|
138
|
+
def clear
|
|
139
|
+
@items = []
|
|
140
|
+
@next_id = 1
|
|
141
|
+
@on_change&.call
|
|
142
|
+
nil
|
|
117
143
|
end
|
|
118
144
|
|
|
119
|
-
# The canonical rendering
|
|
120
|
-
#
|
|
121
|
-
# without
|
|
145
|
+
# The canonical rendering every task tool returns as its
|
|
146
|
+
# observation, so the LLM sees the full state (ids included) each
|
|
147
|
+
# call without a separate read tool:
|
|
122
148
|
#
|
|
123
149
|
# <tasks>
|
|
124
|
-
# - [pending] Add dark mode toggle
|
|
125
|
-
# - [in_progress] Write unit tests
|
|
126
|
-
# - [completed] Update README
|
|
150
|
+
# - #1 [pending] Add dark mode toggle
|
|
151
|
+
# - #2 [in_progress] Write unit tests
|
|
152
|
+
# - #3 [completed] Update README
|
|
127
153
|
# </tasks>
|
|
128
154
|
#
|
|
129
|
-
# Empty
|
|
130
|
-
#
|
|
131
|
-
# signal rather than an ambiguous blank block.
|
|
155
|
+
# Empty renders as +<tasks>(empty)</tasks>+ — an unambiguous "the
|
|
156
|
+
# call worked, list is now empty" rather than a blank block.
|
|
132
157
|
#
|
|
133
158
|
# @return [String]
|
|
134
159
|
def render
|
|
135
160
|
return '<tasks>(empty)</tasks>' if @items.empty?
|
|
136
161
|
|
|
137
|
-
lines = @items.map { |i| "- [#{i.status}] #{i.content}" }
|
|
162
|
+
lines = @items.map { |i| "- ##{i.id} [#{i.status}] #{i.content}" }
|
|
138
163
|
"<tasks>\n#{lines.join("\n")}\n</tasks>"
|
|
139
164
|
end
|
|
140
|
-
|
|
141
|
-
private
|
|
142
|
-
|
|
143
|
-
def find(content)
|
|
144
|
-
@items.find { |i| i.content == content }
|
|
145
|
-
end
|
|
146
165
|
end
|
|
147
166
|
end
|
|
148
167
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# Domain event emitted after every {List} mutation (add / status
|
|
6
|
+
# change / delete), wired by {Extension#bind} via
|
|
7
|
+
# {Pikuri::Agent::ExtensionContext#emit_event}. Carries a frozen
|
|
8
|
+
# whole-list snapshot ({List#items}), so a consumer needs no +List+
|
|
9
|
+
# reference — render or serialize the payload as-is.
|
|
10
|
+
#
|
|
11
|
+
# A batch +task_create+ emits one event per added item, so a renderer
|
|
12
|
+
# should treat the latest snapshot as authoritative (last-wins), not
|
|
13
|
+
# diff event-by-event. Fired on the agent's thread — a listener
|
|
14
|
+
# feeding another thread serializes inside +on_event+ and hands off
|
|
15
|
+
# only the immutable result (see {List}'s thread-confinement note).
|
|
16
|
+
ListChanged = Data.define(:items)
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/pikuri-tasks.rb
CHANGED
|
@@ -2,16 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'pikuri-core'
|
|
4
4
|
|
|
5
|
-
# Entry file for the pikuri-tasks gem
|
|
6
|
-
# loader
|
|
7
|
-
# +Pikuri::+ namespace alongside pikuri-core. After +require
|
|
8
|
-
# 'pikuri-tasks'+, +Pikuri::Tasks::List+, the four task tool classes
|
|
9
|
-
# (+Create+, +InProgress+, +Completed+, +Delete+), and
|
|
10
|
-
# +Pikuri::Tasks::Extension+ are all defined.
|
|
11
|
-
#
|
|
12
|
-
# The loader is per-gem (not shared with pikuri-core's loader) so each
|
|
13
|
-
# gem owns its own +lib/+ tree and the cooperation between gems is via
|
|
14
|
-
# the Pikuri namespace alone.
|
|
5
|
+
# Entry file for the pikuri-tasks gem: sets up the per-gem Zeitwerk
|
|
6
|
+
# loader. See {Pikuri::Tasks} for the namespace overview.
|
|
15
7
|
module Pikuri
|
|
16
8
|
module Tasks
|
|
17
9
|
LOADER = Zeitwerk::Loader.new
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pikuri-tasks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: pikuri-core
|
|
@@ -16,14 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - '='
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.0
|
|
18
|
+
version: 0.1.0
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - '='
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.0
|
|
25
|
+
version: 0.1.0
|
|
27
26
|
description: |
|
|
28
27
|
pikuri-tasks gives a pikuri-core agent an in-memory task list it
|
|
29
28
|
can use to plan and track multi-step work. A +Pikuri::Tasks::List+
|
|
@@ -51,6 +50,7 @@ files:
|
|
|
51
50
|
- lib/pikuri/tasks/extension.rb
|
|
52
51
|
- lib/pikuri/tasks/in_progress.rb
|
|
53
52
|
- lib/pikuri/tasks/list.rb
|
|
53
|
+
- lib/pikuri/tasks/list_changed.rb
|
|
54
54
|
homepage: https://codeberg.org/mvysny/pikuri
|
|
55
55
|
licenses:
|
|
56
56
|
- MIT
|
|
@@ -59,7 +59,6 @@ metadata:
|
|
|
59
59
|
changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
|
|
60
60
|
bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
|
|
61
61
|
rubygems_mfa_required: 'true'
|
|
62
|
-
post_install_message:
|
|
63
62
|
rdoc_options: []
|
|
64
63
|
require_paths:
|
|
65
64
|
- lib
|
|
@@ -74,8 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
73
|
- !ruby/object:Gem::Version
|
|
75
74
|
version: '0'
|
|
76
75
|
requirements: []
|
|
77
|
-
rubygems_version: 3.
|
|
78
|
-
signing_key:
|
|
76
|
+
rubygems_version: 3.6.7
|
|
79
77
|
specification_version: 4
|
|
80
78
|
summary: Per-session in-memory task list + tools for pikuri.
|
|
81
79
|
test_files: []
|