ask-agent 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad75fe0e23adeabc49b068df1916a102d7122710dfe5a45471ba68b47864d75c
4
- data.tar.gz: 4f7381343e7d63a4d2b4ffe9e70cf93c92ab941ccdf11dad52d6ff65bbe227cb
3
+ metadata.gz: 90d637b9a007fef96f7b03e78ac61366ec3597bd789dfb3aaa36059687fb1632
4
+ data.tar.gz: 48e9b46cd59bdbefa5e93dc2925c42c13f8b9ab85b389a2e4bf2b15b2c7add1e
5
5
  SHA512:
6
- metadata.gz: 86a1695d69a497b6ec0fd5b57c942084330a70b35b8bf5fd3340db03ce186be98350eec974c1aaec075e4ef30c8b79628d8c3028d3c64fdb288619f27ff58fda
7
- data.tar.gz: b771e752c640319a5de1bbe63f9dec8079e62a9f8d371c8e82b90898e9af303528739c62c852f83cda58513296edd4ffe9372e090f189914149ab417d26bb254
6
+ metadata.gz: a1f76527ad518404c64c36c697db32fdebdb4b6381d86513e3aca4658486db238c2ab08a4df4df9ebdd6f0997fb9a5620c8d4d77bf69e9dbd70bb3235009d8a7
7
+ data.tar.gz: b1697c33f4ca2dad32a05e716c060ec14baaf848e9b39f918a43a8917682fe7baf76c80ea583ffa44f648fe14c778d4b39fbb1d4237adae6718b78d14c6ea10f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.33.0] — 2026-08-06
2
+
3
+ ### Added
4
+
5
+ - **Durable memory — facts that outlive sessions.** `Ask::Agent::Memory`
6
+ stores namespaced entries on the same `Ask::State::Adapter` as sessions
7
+ and checkpoints (no new dependencies, no ask-rag mandate):
8
+ - Storage: one key per entry (`memory:<namespace>:<id>`) plus a JSON
9
+ index key for enumeration — pure KV, works with every backend
10
+ (SQLite/Redis/Postgres/MySQL/custom adapters) and with the in-process
11
+ Memory store.
12
+ - `Memory#write` (dedupes identical content), `#search` (keyword
13
+ substring match, ranked by matched terms, punctuation-stripped
14
+ queries), `#list`, `#delete`, `#count`. Namespaces isolate tenants and
15
+ agent roles.
16
+ - **Session integration**: `Session.new(memory: memory)` injects
17
+ `memory_write` (stamps the session id as provenance) and
18
+ `memory_search` tools, and **injects relevant memories as a system
19
+ message at run start** — session B starts knowing what session A
20
+ learned. Opt-in; sessions without `memory:` are unaffected.
21
+
1
22
  ## [0.32.0] — 2026-08-06
2
23
 
3
24
  ### Added
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module Ask
8
+ module Agent
9
+ # Durable, namespaced memory on any {Ask::State::Adapter} — the same
10
+ # storage layer as sessions and checkpoints.
11
+ #
12
+ # Entries are plain facts ("the deploy window is Tuesday", "the user
13
+ # prefers concise answers") that outlive a session: session A writes
14
+ # them, session B (same adapter + namespace) retrieves them via keyword
15
+ # search and has them injected into context. The abstraction is
16
+ # domain-agnostic — nothing here assumes a coding agent.
17
+ #
18
+ # Storage shape (pure KV, no list primitives — works with every backend
19
+ # including custom get/set/delete adapters):
20
+ # memory:<namespace>:<id> — one key per entry
21
+ # memory:<namespace>:index — JSON array of entry ids (write order)
22
+ #
23
+ # store = Ask::State::Providers::SQLite.new(path: "agent.db")
24
+ # memory = Ask::Agent::Memory.new(state: store, namespace: "user:42")
25
+ # memory.write("Deploy window is Tuesday")
26
+ # memory.search("when can we deploy?") # => [Entry]
27
+ #
28
+ # Namespaces isolate memory: a support agent's facts never leak into a
29
+ # finance agent's, and tenants share one backend safely.
30
+ class Memory
31
+ Entry = Data.define(:id, :content, :metadata, :created_at) do
32
+ def to_h = { id: id, content: content, metadata: metadata, created_at: created_at.iso8601 }
33
+ end
34
+
35
+ KEY_PREFIX = "memory:"
36
+ INDEX_SUFFIX = ":index"
37
+
38
+ # @param state [Ask::State::Adapter] backing store
39
+ # @param namespace [String] isolation scope (user id, project id, ...)
40
+ def initialize(state:, namespace:)
41
+ @state = state
42
+ @namespace = namespace.to_s
43
+ @mutex = Mutex.new
44
+ end
45
+
46
+ # @return [Ask::State::Adapter] the underlying adapter
47
+ attr_reader :state
48
+
49
+ # @return [String] the namespace this memory is scoped to
50
+ attr_reader :namespace
51
+
52
+ # Save a fact. Writing an identical content again is a no-op (returns
53
+ # the existing entry).
54
+ #
55
+ # @param content [String] the fact to remember
56
+ # @param metadata [Hash] optional provenance (session id, tags, ...)
57
+ # @return [Entry]
58
+ # @raise [ArgumentError] on empty content
59
+ def write(content, metadata: {})
60
+ content = content.to_s
61
+ raise ArgumentError, "content is required" if content.strip.empty?
62
+
63
+ @mutex.synchronize do
64
+ existing = entries.find { |e| e.content == content }
65
+ return existing if existing
66
+
67
+ entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: metadata, created_at: Time.now)
68
+ @state.set(entry_key(entry.id), entry.to_h)
69
+ @state.set(index_key, (load_index + [entry.id]).to_json)
70
+ entry
71
+ end
72
+ end
73
+
74
+ # Keyword search over the namespace's entries: entries matching any
75
+ # query term (case-insensitive substring), ranked by matched-term
76
+ # count, newest first on ties.
77
+ #
78
+ # @param query [String]
79
+ # @param limit [Integer] max results
80
+ # @return [Array<Entry>]
81
+ def search(query, limit: 5)
82
+ terms = query.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").split(/\s+/).reject(&:empty?)
83
+ return [] if terms.empty?
84
+
85
+ scored = entries.filter_map do |entry|
86
+ text = entry.content.downcase
87
+ hits = terms.count { |term| text.include?(term) }
88
+ [hits, entry] if hits.positive?
89
+ end
90
+ scored.sort_by { |hits, entry| [-hits, entry.created_at] }.first(limit).map(&:last)
91
+ end
92
+
93
+ # @param limit [Integer]
94
+ # @return [Array<Entry>] entries, newest first
95
+ def list(limit: 50)
96
+ entries.last(limit).reverse
97
+ end
98
+
99
+ # Remove an entry by id.
100
+ #
101
+ # @param id [String]
102
+ # @return [void]
103
+ def delete(id)
104
+ @mutex.synchronize do
105
+ @state.delete(entry_key(id))
106
+ @state.set(index_key, (load_index - [id]).to_json)
107
+ end
108
+ nil
109
+ end
110
+
111
+ # @return [Integer] number of entries in this namespace
112
+ def count
113
+ entries.size
114
+ end
115
+
116
+ private
117
+
118
+ def entries
119
+ load_index.filter_map { |id| load_entry(id) }
120
+ end
121
+
122
+ def load_index
123
+ raw = @state.get(index_key)
124
+ raw ? JSON.parse(raw) : []
125
+ end
126
+
127
+ def load_entry(id)
128
+ data = @state.get(entry_key(id))
129
+ return nil unless data
130
+
131
+ data = symbolize(data)
132
+ Entry.new(
133
+ id: id,
134
+ content: data[:content].to_s,
135
+ metadata: data[:metadata] || {},
136
+ created_at: Time.parse(data[:created_at])
137
+ )
138
+ rescue ArgumentError, TypeError
139
+ nil
140
+ end
141
+
142
+ def entry_key(id)
143
+ "#{KEY_PREFIX}#{@namespace}:#{id}"
144
+ end
145
+
146
+ def index_key
147
+ "#{KEY_PREFIX}#{@namespace}#{INDEX_SUFFIX}"
148
+ end
149
+
150
+ def symbolize(obj)
151
+ case obj
152
+ when Hash
153
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
154
+ when Array
155
+ obj.map { |e| symbolize(e) }
156
+ else
157
+ obj
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask/tools/tool"
4
+ require "ask/result"
5
+
6
+ module Ask
7
+ module Agent
8
+ # Tool that searches the session's durable memory ({Memory}). Injected
9
+ # into the session by `Session.new(memory: memory)`.
10
+ class MemorySearch < Ask::Tool
11
+ description "Search durable memory for facts from previous sessions. " \
12
+ "Use this to recall user preferences, decisions, conventions, " \
13
+ "or resolved problems before answering."
14
+
15
+ param :query, type: :string, desc: "Search query", required: true
16
+ param :limit, type: :integer, desc: "Maximum number of results", required: false
17
+
18
+ # @param memory [Ask::Agent::Memory]
19
+ def initialize(memory:)
20
+ @memory = memory
21
+ super()
22
+ end
23
+
24
+ def execute(query:, limit: 5)
25
+ hits = @memory.search(query, limit: limit)
26
+ return Ask::Result.ok(data: "(no matching memories)") if hits.empty?
27
+
28
+ Ask::Result.ok(data: hits.map { |e| "- #{e.content}" }.join("\n"))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask/tools/tool"
4
+ require "ask/result"
5
+
6
+ module Ask
7
+ module Agent
8
+ # Tool that saves facts to the session's durable memory ({Memory}).
9
+ # Injected into the session by `Session.new(memory: memory)`.
10
+ class MemoryWrite < Ask::Tool
11
+ description "Save a fact to durable memory. " \
12
+ "Use this for information worth remembering across sessions: " \
13
+ "user preferences, decisions, conventions, resolved problems. " \
14
+ "The fact will be available to future sessions with the same namespace."
15
+
16
+ param :content, type: :string, desc: "The fact to remember", required: true
17
+
18
+ # @param memory [Ask::Agent::Memory]
19
+ # @param session_id [String] stamped into the entry metadata as
20
+ # provenance
21
+ def initialize(memory:, session_id:)
22
+ @memory = memory
23
+ @session_id = session_id
24
+ super()
25
+ end
26
+
27
+ def execute(content:)
28
+ entry = @memory.write(
29
+ content,
30
+ metadata: { session_id: @session_id, written_at: Time.now.iso8601 }
31
+ )
32
+ Ask::Result.ok(data: "Saved to memory (#{entry.id}): #{entry.content}")
33
+ rescue ArgumentError => e
34
+ Ask::Result.error(message: e.message)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -24,7 +24,7 @@ module Ask
24
24
  agent_dir: nil, evaluator: nil, audit_log: nil,
25
25
  skills_disclosure: true, approval: nil,
26
26
  tool_call_repair: nil, checkpoints: false,
27
- todos: false, plan_mode: false, **chat_options)
27
+ todos: false, plan_mode: false, memory: nil, **chat_options)
28
28
  @id = id || SecureRandom.uuid
29
29
  @agent_dir = agent_dir
30
30
  @max_turns = max_turns
@@ -54,6 +54,10 @@ module Ask
54
54
  @todo_list = TodoList.new if @todos_enabled
55
55
  @todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
56
56
 
57
+ # Durable memory (memory_write / memory_search tools). An instance
58
+ # with its own namespace and state adapter; nil disables memory.
59
+ @memory = memory
60
+
57
61
  # Plan mode — research phase gated to read-only tools until a human
58
62
  # approves the model's plan (submitted via the exit_plan_mode tool).
59
63
  @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
@@ -137,6 +141,9 @@ module Ask
137
141
  # @return [Ask::Agent::TodoList, nil] session task list (only when
138
142
  # todos are enabled)
139
143
  attr_reader :todo_list
144
+ # @return [Ask::Agent::Memory, nil] durable memory (only when passed
145
+ # via the +memory:+ option)
146
+ attr_reader :memory
140
147
 
141
148
  def run(message, tools: nil, reset: true)
142
149
  raise "Session deleted" if @deleted
@@ -154,6 +161,9 @@ module Ask
154
161
 
155
162
  active_tools = @tools
156
163
 
164
+ # Retrieve relevant memories from previous sessions into context.
165
+ inject_memories(message) if reset && @memory
166
+
157
167
  if active_tools.empty? && !@_no_tools_instructed
158
168
  @chat.add_message(role: :system, content: "You have no tools available. Do not claim you can look up information or use tools of any kind. Just respond based on your existing knowledge.")
159
169
  @_no_tools_instructed = true
@@ -474,6 +484,19 @@ module Ask
474
484
 
475
485
  # --- Plan mode ---
476
486
 
487
+ # Retrieve memories relevant to the incoming message and inject them
488
+ # as a system message, so a new session starts with what earlier
489
+ # sessions learned.
490
+ def inject_memories(message)
491
+ hits = @memory.search(message.to_s, limit: 5)
492
+ return if hits.empty?
493
+
494
+ @chat.add_message(
495
+ role: :system,
496
+ content: "Relevant memories from previous sessions:\n" + hits.map { |e| "- #{e.content}" }.join("\n")
497
+ )
498
+ end
499
+
477
500
  # @return [Boolean] whether the session is in plan mode (research
478
501
  # phase; non-read-only tools are blocked until the plan is approved)
479
502
  def plan_mode? = @plan_mode
@@ -724,6 +747,10 @@ module Ask
724
747
  on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
725
748
  ) unless resolved.any? { |t| t.name == "exit_plan_mode" }
726
749
  end
750
+ if @memory
751
+ resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
752
+ resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
753
+ end
727
754
  resolved
728
755
  end
729
756
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.32.0"
5
+ VERSION = "0.33.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -43,6 +43,9 @@ module Ask
43
43
  autoload :TodoList, "ask/agent/todo_list"
44
44
  autoload :TodoWrite, "ask/agent/todo_write"
45
45
  autoload :ExitPlanMode, "ask/agent/exit_plan_mode"
46
+ autoload :Memory, "ask/agent/memory"
47
+ autoload :MemoryWrite, "ask/agent/memory_write"
48
+ autoload :MemorySearch, "ask/agent/memory_search"
46
49
 
47
50
  module Middleware
48
51
  autoload :Base, "ask/agent/middleware/base"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.0
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -178,6 +178,9 @@ files:
178
178
  - lib/ask/agent/exit_plan_mode.rb
179
179
  - lib/ask/agent/hooks.rb
180
180
  - lib/ask/agent/loop.rb
181
+ - lib/ask/agent/memory.rb
182
+ - lib/ask/agent/memory_search.rb
183
+ - lib/ask/agent/memory_write.rb
181
184
  - lib/ask/agent/meta_agent.rb
182
185
  - lib/ask/agent/middleware/base.rb
183
186
  - lib/ask/agent/middleware/default_settings.rb