Class: Pikuri::Tasks::List

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/tasks/list.rb

Overview

An in-memory ordered list of Items, scoped to a single Agent. Held inside Extension and captured by closure into the execute block of each of the four task tool classes, so every mutation hits the same instance.

Concurrency

The list is confined to the agent’s thread: the agent loop is single-threaded with respect to tool calls (ruby_llm dispatches them sequentially), so no locking. Other threads (e.g. a web UI rendering the list) must not touch a List directly — they consume the ListChanged events Extension#bind wires onto the listener stream, whose items payload is an immutable snapshot safe to hand across threads. A future parallel-tool-execution feature would need a Mutex here.

Persistence

None. The list is dropped when the Agent is garbage-collected. That matches the gem’s stated scope: “in-memory only, no session-state-on-disk.”

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeList



56
57
58
59
60
# File 'lib/pikuri/tasks/list.rb', line 56

def initialize
  @items = []
  @next_id = 1
  @on_change = nil
end

Instance Attribute Details

#on_changeProc?

Optional zero-argument hook invoked after every successful mutation (#add / #set_status / #delete) — not on failed ones (a raise means nothing changed). Set by Extension#bind to emit a Pikuri::Tasks::ListChanged onto the agent’s listener stream; nil (the default) disables notification. Runs on the mutating (agent) thread, synchronously inside the mutation call.

Returns:

  • (Proc, nil)


71
72
73
# File 'lib/pikuri/tasks/list.rb', line 71

def on_change
  @on_change
end

Instance Method Details

#add(content) ⇒ Item

Append a new item with status pending and the next id from a monotonic per-list counter. Ids are never reused: after a delete, the freed id stays dead, so a stale id held by the LLM errors loudly instead of silently resolving to a newer task.

Parameters:

  • content (String)

    non-empty content; whitespace is the caller’s responsibility.

Returns:

  • (Item)

    the newly added item

Raises:

  • (DuplicateItem)

    if an item with the same content already exists (a duplicate is almost always an LLM mistake).



100
101
102
103
104
105
106
107
108
# File 'lib/pikuri/tasks/list.rb', line 100

def add(content)
  raise DuplicateItem, content if @items.any? { |i| i.content == content }

  item = Item.new(id: @next_id, content: content, status: 'pending')
  @next_id += 1
  @items << item
  @on_change&.call
  item
end

#delete(id) ⇒ Item

Remove the item whose id matches. The id is not reused for later items (see #add).

Parameters:

  • id (Integer)

Returns:

  • (Item)

    the removed item.

Raises:



137
138
139
140
141
142
143
144
# File 'lib/pikuri/tasks/list.rb', line 137

def delete(id)
  idx = @items.index { |i| i.id == id }
  raise ItemNotFound, id.to_s if idx.nil?

  removed = @items.delete_at(idx)
  @on_change&.call
  removed
end

#empty?Boolean

Returns:

  • (Boolean)


86
87
88
# File 'lib/pikuri/tasks/list.rb', line 86

def empty?
  @items.empty?
end

#itemsArray<Item>

Returns a frozen snapshot of the current items, in insertion order. Callers cannot mutate the internal storage through this accessor.

Returns:

  • (Array<Item>)

    a frozen snapshot of the current items, in insertion order. Callers cannot mutate the internal storage through this accessor.



76
77
78
# File 'lib/pikuri/tasks/list.rb', line 76

def items
  @items.dup.freeze
end

#renderString

The canonical rendering returned as the observation by every task tool, so the LLM sees the latest full state — including each item’s id — on each call without needing a separate read tool. Format:

<tasks>
- #1 [pending] Add dark mode toggle
- #2 [in_progress] Write unit tests
- #3 [completed] Update README
</tasks>

Empty list renders as <tasks>(empty)</tasks> so the LLM gets an unambiguous “yes, the call worked and the list is now empty” signal rather than an ambiguous blank block.

Returns:

  • (String)


162
163
164
165
166
167
# File 'lib/pikuri/tasks/list.rb', line 162

def render
  return '<tasks>(empty)</tasks>' if @items.empty?

  lines = @items.map { |i| "- ##{i.id} [#{i.status}] #{i.content}" }
  "<tasks>\n#{lines.join("\n")}\n</tasks>"
end

#set_status(id:, status:) ⇒ Item

Update the status of the item whose id matches.

Parameters:

  • id (Integer)
  • status (String)

    one of STATUSES.

Returns:

  • (Item)

    the updated item (a fresh frozen Data instance — the old one is replaced in place).

Raises:



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/pikuri/tasks/list.rb', line 118

def set_status(id:, status:)
  unless STATUSES.include?(status)
    raise ArgumentError, "invalid status: #{status.inspect} (allowed: #{STATUSES.join(', ')})"
  end

  idx = @items.index { |i| i.id == id }
  raise ItemNotFound, id.to_s if idx.nil?

  @items[idx] = @items[idx].with(status: status)
  @on_change&.call
  @items[idx]
end

#sizeInteger

Returns:

  • (Integer)


81
82
83
# File 'lib/pikuri/tasks/list.rb', line 81

def size
  @items.size
end