ask-agent 0.32.0 → 0.35.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: 2040f37b4e9b023374eb6169abd1f1c28fdda335a8032254a15b0d813a7cd0c9
4
+ data.tar.gz: 7212cbd265d8b054423cfad5ed76717eb36f1ff980c2a394cba9b0b4c256c3e9
5
5
  SHA512:
6
- metadata.gz: 86a1695d69a497b6ec0fd5b57c942084330a70b35b8bf5fd3340db03ce186be98350eec974c1aaec075e4ef30c8b79628d8c3028d3c64fdb288619f27ff58fda
7
- data.tar.gz: b771e752c640319a5de1bbe63f9dec8079e62a9f8d371c8e82b90898e9af303528739c62c852f83cda58513296edd4ffe9372e090f189914149ab417d26bb254
6
+ metadata.gz: 1cb0c3e65bd08e9c45f93bdc378149810d7c31594f63cf69799e128b9f32496f3e71ca521cbe43d6729a9d39210bce0469a7eb6686336a45f2677fc473053cad
7
+ data.tar.gz: 2b6d47cfb37af4c3a2d850a551621af6d44542dd88f9483a695cfcd83b58f2bb893a7403ac91b3a01f3659936ad8f4df5c8d7cc8eb7656c22e6587db0bba0081
data/CHANGELOG.md CHANGED
@@ -1,3 +1,56 @@
1
+ ## [0.35.0] — 2026-08-07
2
+
3
+ ### Added
4
+
5
+ - **Memory learning — automatic extraction (v2 of durable memory).**
6
+ `Session.new(memory: memory, memory_learning: true)` extracts durable
7
+ facts from the transcript when the session ends — the model no longer has
8
+ to remember to call `memory_write`:
9
+ - `Ask::Agent::MemoryExtractor` reads the memory-relevant messages (user
10
+ + assistant, capped, oldest dropped), sends them to the model with a
11
+ configurable structured-output prompt, and writes the returned facts
12
+ into the store — deduped (exact + near-duplicate via search), stamped
13
+ with provenance (`extracted: true`, source session id), and capped
14
+ (`max_candidates:`, default 10).
15
+ - Extraction is best-effort: unparseable responses and failed calls
16
+ yield an empty result and never break the session.
17
+ - `Memory.new(max_entries:)` prunes the oldest entries once a namespace
18
+ exceeds the cap — bounded memory, not a growing dump.
19
+ - Requires `memory:`; `memory_learning:` without it raises.
20
+
21
+ ## [0.34.1] — 2026-08-07
22
+
23
+ ### Added
24
+
25
+ - **`account_id` session option.** `Ask::Agent::Chat.new(..., account_id:)` merges the value into the provider config (e.g. `ChatGPT-Account-Id` for the OpenAI Codex provider).
26
+
27
+ ## [0.34.0] — 2026-08-07
28
+
29
+ ### Added
30
+
31
+ - **Runtime model/provider/key overrides.** `Ask::Agent.new(name, model:, provider:, api_key:, api_base:)` and `Ask::Agent::Chat.new(..., api_key:, api_base:)` — caller-supplied options win over the definition's config, and an explicit `api_key`/`api_base` is merged into the provider config ahead of Ask::Auth resolution. This is the BYOK / per-user credential injection seam (a session can be built against a specific provider and key without touching global configuration).
32
+
33
+ ## [0.33.0] — 2026-08-06
34
+
35
+ ### Added
36
+
37
+ - **Durable memory — facts that outlive sessions.** `Ask::Agent::Memory`
38
+ stores namespaced entries on the same `Ask::State::Adapter` as sessions
39
+ and checkpoints (no new dependencies, no ask-rag mandate):
40
+ - Storage: one key per entry (`memory:<namespace>:<id>`) plus a JSON
41
+ index key for enumeration — pure KV, works with every backend
42
+ (SQLite/Redis/Postgres/MySQL/custom adapters) and with the in-process
43
+ Memory store.
44
+ - `Memory#write` (dedupes identical content), `#search` (keyword
45
+ substring match, ranked by matched terms, punctuation-stripped
46
+ queries), `#list`, `#delete`, `#count`. Namespaces isolate tenants and
47
+ agent roles.
48
+ - **Session integration**: `Session.new(memory: memory)` injects
49
+ `memory_write` (stamps the session id as provenance) and
50
+ `memory_search` tools, and **injects relevant memories as a system
51
+ message at run start** — session B starts knowing what session A
52
+ learned. Opt-in; sessions without `memory:` are unaffected.
53
+
1
54
  ## [0.32.0] — 2026-08-06
2
55
 
3
56
  ### Added
@@ -33,7 +33,7 @@ module Ask
33
33
 
34
34
  attr_writer :test_provider
35
35
 
36
- def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, prompt_caching: nil, **)
36
+ def initialize(model:, tools: [], temperature: nil, schema: nil, provider: nil, prompt_caching: nil, api_key: nil, api_base: nil, account_id: nil, **)
37
37
  @model_id = model.respond_to?(:id) ? model.id : model.to_s
38
38
  @model_info = Ask::ModelCatalog.find(@model_id)
39
39
  @tools = tools
@@ -41,6 +41,9 @@ module Ask
41
41
  @schema = schema
42
42
  @messages = []
43
43
  @provider_override = provider
44
+ @api_key = api_key
45
+ @api_base = api_base
46
+ @account_id = account_id
44
47
  @provider = nil
45
48
 
46
49
  # Read configured middleware, transforms, and caching from global config
@@ -130,12 +133,13 @@ module Ask
130
133
  cred_names << [base_s.to_sym, :api_key]
131
134
  end
132
135
 
133
- key = Ask::Auth.resolve(*cred_names) rescue nil
136
+ key = @api_key || (Ask::Auth.resolve(*cred_names) rescue nil)
134
137
 
135
- base_url = Ask::Auth.resolve(:"#{slug}_api_base") rescue nil
138
+ base_url = @api_base || (Ask::Auth.resolve(:"#{slug}_api_base") rescue nil)
136
139
  config = { api_key: key }
137
140
  config[:"#{slug}_api_key"] = key
138
141
  config[:"#{slug}_api_base"] = base_url if base_url
142
+ config[:account_id] = @account_id if @account_id
139
143
  Ask::LLM::Config.new(config)
140
144
  end
141
145
 
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module Ask
9
+ module Agent
10
+ # Durable, namespaced memory on any {Ask::State::Adapter} — the same
11
+ # storage layer as sessions and checkpoints.
12
+ #
13
+ # Entries are plain facts ("the deploy window is Tuesday", "the user
14
+ # prefers concise answers") that outlive a session: session A writes
15
+ # them, session B (same adapter + namespace) retrieves them via keyword
16
+ # search and has them injected into context. The abstraction is
17
+ # domain-agnostic — nothing here assumes a coding agent.
18
+ #
19
+ # Storage shape (pure KV, no list primitives — works with every backend
20
+ # including custom get/set/delete adapters):
21
+ # memory:<namespace>:<id> — one key per entry
22
+ # memory:<namespace>:index — JSON array of entry ids (write order)
23
+ #
24
+ # store = Ask::State::Providers::SQLite.new(path: "agent.db")
25
+ # memory = Ask::Agent::Memory.new(state: store, namespace: "user:42")
26
+ # memory.write("Deploy window is Tuesday")
27
+ # memory.search("when can we deploy?") # => [Entry]
28
+ #
29
+ # Namespaces isolate memory: a support agent's facts never leak into a
30
+ # finance agent's, and tenants share one backend safely.
31
+ class Memory
32
+ Entry = Data.define(:id, :content, :metadata, :created_at) do
33
+ def to_h = { id: id, content: content, metadata: metadata, created_at: created_at.iso8601 }
34
+ end
35
+
36
+ KEY_PREFIX = "memory:"
37
+ INDEX_SUFFIX = ":index"
38
+
39
+ # @param state [Ask::State::Adapter] backing store
40
+ # @param namespace [String] isolation scope (user id, project id, ...)
41
+ # @param max_entries [Integer, nil] when set, the oldest entries are
42
+ # pruned once the namespace exceeds this many entries
43
+ def initialize(state:, namespace:, max_entries: nil)
44
+ @state = state
45
+ @namespace = namespace.to_s
46
+ @max_entries = max_entries
47
+ @mutex = Monitor.new
48
+ end
49
+
50
+ # @return [Ask::State::Adapter] the underlying adapter
51
+ attr_reader :state
52
+
53
+ # @return [String] the namespace this memory is scoped to
54
+ attr_reader :namespace
55
+
56
+ # Save a fact. Writing an identical content again is a no-op (returns
57
+ # the existing entry).
58
+ #
59
+ # @param content [String] the fact to remember
60
+ # @param metadata [Hash] optional provenance (session id, tags, ...)
61
+ # @return [Entry]
62
+ # @raise [ArgumentError] on empty content
63
+ def write(content, metadata: {})
64
+ content = content.to_s
65
+ raise ArgumentError, "content is required" if content.strip.empty?
66
+
67
+ @mutex.synchronize do
68
+ existing = entries.find { |e| e.content == content }
69
+ return existing if existing
70
+
71
+ entry = Entry.new(id: SecureRandom.uuid, content: content, metadata: metadata, created_at: Time.now)
72
+ @state.set(entry_key(entry.id), entry.to_h)
73
+ @state.set(index_key, (load_index + [entry.id]).to_json)
74
+ prune_oldest if @max_entries
75
+ entry
76
+ end
77
+ end
78
+
79
+ # Keyword search over the namespace's entries: entries matching any
80
+ # query term (case-insensitive substring), ranked by matched-term
81
+ # count, newest first on ties.
82
+ #
83
+ # @param query [String]
84
+ # @param limit [Integer] max results
85
+ # @return [Array<Entry>]
86
+ def search(query, limit: 5)
87
+ terms = query.to_s.downcase.gsub(/[^a-z0-9\s]/, " ").split(/\s+/).reject(&:empty?)
88
+ return [] if terms.empty?
89
+
90
+ scored = entries.filter_map do |entry|
91
+ text = entry.content.downcase
92
+ hits = terms.count { |term| text.include?(term) }
93
+ [hits, entry] if hits.positive?
94
+ end
95
+ scored.sort_by { |hits, entry| [-hits, entry.created_at] }.first(limit).map(&:last)
96
+ end
97
+
98
+ # @param limit [Integer]
99
+ # @return [Array<Entry>] entries, newest first
100
+ def list(limit: 50)
101
+ entries.last(limit).reverse
102
+ end
103
+
104
+ # Remove an entry by id.
105
+ #
106
+ # @param id [String]
107
+ # @return [void]
108
+ def delete(id)
109
+ @mutex.synchronize do
110
+ @state.delete(entry_key(id))
111
+ @state.set(index_key, (load_index - [id]).to_json)
112
+ end
113
+ nil
114
+ end
115
+
116
+ # @return [Integer] number of entries in this namespace
117
+ def count
118
+ entries.size
119
+ end
120
+
121
+ private
122
+
123
+ # Drop the oldest entries beyond the max_entries cap (called under the
124
+ # write mutex; delete re-enters it, which is safe).
125
+ def prune_oldest
126
+ current = entries
127
+ return if current.size <= @max_entries
128
+
129
+ current.first(current.size - @max_entries).each { |entry| delete(entry.id) }
130
+ end
131
+
132
+ def entries
133
+ load_index.filter_map { |id| load_entry(id) }
134
+ end
135
+
136
+ def load_index
137
+ raw = @state.get(index_key)
138
+ raw ? JSON.parse(raw) : []
139
+ end
140
+
141
+ def load_entry(id)
142
+ data = @state.get(entry_key(id))
143
+ return nil unless data
144
+
145
+ data = symbolize(data)
146
+ Entry.new(
147
+ id: id,
148
+ content: data[:content].to_s,
149
+ metadata: data[:metadata] || {},
150
+ created_at: Time.parse(data[:created_at])
151
+ )
152
+ rescue ArgumentError, TypeError
153
+ nil
154
+ end
155
+
156
+ def entry_key(id)
157
+ "#{KEY_PREFIX}#{@namespace}:#{id}"
158
+ end
159
+
160
+ def index_key
161
+ "#{KEY_PREFIX}#{@namespace}#{INDEX_SUFFIX}"
162
+ end
163
+
164
+ def symbolize(obj)
165
+ case obj
166
+ when Hash
167
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
168
+ when Array
169
+ obj.map { |e| symbolize(e) }
170
+ else
171
+ obj
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Agent
7
+ # Extracts durable facts from a finished session's transcript and writes
8
+ # them to the session's {Memory} — the "learning" half of durable memory
9
+ # (codex two-phase pattern, phase 1).
10
+ #
11
+ # One LLM call with a structured-output prompt: the model reads the
12
+ # memory-relevant messages and returns a JSON list of durable facts.
13
+ # Candidates are deduped against the store (exact + near-duplicate via
14
+ # search), written with provenance (extracted: true, source session id),
15
+ # and capped at +max_candidates+.
16
+ #
17
+ # Extraction never raises: a failed call or unparseable response yields
18
+ # an empty result hash, never a broken session.
19
+ #
20
+ # Session wiring: `Session.new(memory: memory, memory_learning: true)`.
21
+ class MemoryExtractor
22
+ DEFAULT_SYSTEM_PROMPT = <<~PROMPT.strip
23
+ You are a memory curator. Read the conversation transcript and extract
24
+ durable facts worth remembering across sessions: user preferences,
25
+ decisions, conventions, resolved problems, and standing instructions.
26
+ Skip ephemeral details, session-specific chatter, secrets, and facts
27
+ already obvious from the conversation itself.
28
+ Respond with JSON only: {"facts": ["fact one", "fact two", ...]}.
29
+ Return an empty list if nothing is worth remembering.
30
+ PROMPT
31
+
32
+ # @param model [String] model to extract with
33
+ # @param memory [Ask::Agent::Memory] store to write into
34
+ # @param chat [Ask::Agent::Chat, nil] chat to use (built from +model+
35
+ # when nil; inject a stub in tests)
36
+ # @param system_prompt [String] domain-specific extraction instructions
37
+ # @param max_candidates [Integer] cap on facts written per extraction
38
+ # @param max_transcript_messages [Integer] cap on transcript messages
39
+ # sent to the model (oldest dropped first)
40
+ def initialize(model:, memory:, chat: nil, system_prompt: DEFAULT_SYSTEM_PROMPT,
41
+ max_candidates: 10, max_transcript_messages: 60)
42
+ @model = model
43
+ @memory = memory
44
+ @chat = chat
45
+ @system_prompt = system_prompt
46
+ @max_candidates = max_candidates
47
+ @max_transcript_messages = max_transcript_messages
48
+ end
49
+
50
+ # @param transcript [Array<Ask::Message>] the finished session's messages
51
+ # @param session_id [String, nil] stamped into extracted entries as
52
+ # provenance
53
+ # @return [Hash] {extracted: [String], skipped: [String], error: [String, nil]}
54
+ def extract(transcript:, session_id: nil)
55
+ relevant = memory_relevant_messages(transcript)
56
+ return { extracted: [], skipped: [], error: nil } if relevant.empty?
57
+
58
+ facts = request_facts(relevant)
59
+ return { extracted: [], skipped: [], error: "no facts returned" } if facts.empty?
60
+
61
+ extracted = []
62
+ skipped = []
63
+ facts.first(@max_candidates).each do |fact|
64
+ if duplicate?(fact)
65
+ skipped << fact
66
+ else
67
+ @memory.write(
68
+ fact,
69
+ metadata: { extracted: true, session_id: session_id, extracted_at: Time.now.iso8601 }
70
+ )
71
+ extracted << fact
72
+ end
73
+ end
74
+ { extracted: extracted, skipped: skipped, error: nil }
75
+ rescue StandardError => e
76
+ { extracted: [], skipped: [], error: e.message }
77
+ end
78
+
79
+ private
80
+
81
+ # User and assistant messages with content, oldest dropped first beyond
82
+ # the cap. Tool and system messages never reach the model.
83
+ def memory_relevant_messages(transcript)
84
+ transcript
85
+ .select { |m| %i[user assistant].include?(m.role.to_sym) && m.content.to_s.strip.length.positive? }
86
+ .last(@max_transcript_messages)
87
+ end
88
+
89
+ def request_facts(messages)
90
+ body = messages.map { |m| "#{m.role}: #{m.content}" }.join("\n")
91
+ response = chat.ask("#{@system_prompt}\n\nTranscript:\n#{body}")
92
+ parse_facts(response.content.to_s)
93
+ end
94
+
95
+ def chat
96
+ @chat ||= Chat.new(model: @model, tools: [])
97
+ end
98
+
99
+ def parse_facts(content)
100
+ parsed = JSON.parse(content)
101
+ facts = parsed.is_a?(Hash) ? (parsed["facts"] || parsed[:facts]) : parsed
102
+ Array(facts).map(&:to_s).map(&:strip).reject(&:empty?)
103
+ rescue JSON::ParserError
104
+ # Fallback: the first JSON array in the response.
105
+ match = content.match(/\[.*\]/m)
106
+ match ? JSON.parse(match[0]).map(&:to_s).map(&:strip).reject(&:empty?) : []
107
+ end
108
+
109
+ def duplicate?(fact)
110
+ @memory.search(fact, limit: 1).any? { |entry| similar?(entry.content, fact) }
111
+ end
112
+
113
+ def similar?(a, b)
114
+ a == b || a.include?(b) || b.include?(a)
115
+ end
116
+ end
117
+ end
118
+ 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,8 @@ 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,
28
+ memory_learning: false, **chat_options)
28
29
  @id = id || SecureRandom.uuid
29
30
  @agent_dir = agent_dir
30
31
  @max_turns = max_turns
@@ -54,6 +55,16 @@ module Ask
54
55
  @todo_list = TodoList.new if @todos_enabled
55
56
  @todo_list&.subscribe { |entries| emit(Events::TodoUpdated.new(todos: entries)) }
56
57
 
58
+ # Durable memory (memory_write / memory_search tools). An instance
59
+ # with its own namespace and state adapter; nil disables memory.
60
+ @memory = memory
61
+ # Learning: extract durable facts from the transcript when the
62
+ # session ends (requires memory).
63
+ if memory_learning && !@memory
64
+ raise ArgumentError, "memory_learning: requires a memory: instance"
65
+ end
66
+ @memory_learning = !!memory_learning
67
+
57
68
  # Plan mode — research phase gated to read-only tools until a human
58
69
  # approves the model's plan (submitted via the exit_plan_mode tool).
59
70
  @plan_mode = plan_mode.is_a?(Hash) ? true : !!plan_mode
@@ -137,6 +148,9 @@ module Ask
137
148
  # @return [Ask::Agent::TodoList, nil] session task list (only when
138
149
  # todos are enabled)
139
150
  attr_reader :todo_list
151
+ # @return [Ask::Agent::Memory, nil] durable memory (only when passed
152
+ # via the +memory:+ option)
153
+ attr_reader :memory
140
154
 
141
155
  def run(message, tools: nil, reset: true)
142
156
  raise "Session deleted" if @deleted
@@ -154,6 +168,9 @@ module Ask
154
168
 
155
169
  active_tools = @tools
156
170
 
171
+ # Retrieve relevant memories from previous sessions into context.
172
+ inject_memories(message) if reset && @memory
173
+
157
174
  if active_tools.empty? && !@_no_tools_instructed
158
175
  @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
176
  @_no_tools_instructed = true
@@ -199,6 +216,10 @@ module Ask
199
216
  @running = false
200
217
  Ask::Agent.current_session = nil if Ask::Agent.current_session.equal?(self)
201
218
  persist! if @state
219
+ # Learn from this session: extract durable facts into memory.
220
+ # Only on the initial run (not follow-ups); best-effort, never
221
+ # raises.
222
+ extract_memories if reset && @memory_learning
202
223
  # A pending tool completed while this run was busy: voice the
203
224
  # result now that the turn is over (one follow-up per completion).
204
225
  follow_up = @pending_mutex.synchronize do
@@ -474,6 +495,29 @@ module Ask
474
495
 
475
496
  # --- Plan mode ---
476
497
 
498
+ # Extract durable facts from this session's transcript into memory
499
+ # (memory_learning: true). Best-effort — extraction never breaks the
500
+ # session; failures are swallowed.
501
+ def extract_memories
502
+ extractor = MemoryExtractor.new(model: model_id_from(@chat), memory: @memory)
503
+ extractor.extract(transcript: @chat.messages, session_id: @id)
504
+ rescue StandardError
505
+ nil
506
+ end
507
+
508
+ # Retrieve memories relevant to the incoming message and inject them
509
+ # as a system message, so a new session starts with what earlier
510
+ # sessions learned.
511
+ def inject_memories(message)
512
+ hits = @memory.search(message.to_s, limit: 5)
513
+ return if hits.empty?
514
+
515
+ @chat.add_message(
516
+ role: :system,
517
+ content: "Relevant memories from previous sessions:\n" + hits.map { |e| "- #{e.content}" }.join("\n")
518
+ )
519
+ end
520
+
477
521
  # @return [Boolean] whether the session is in plan mode (research
478
522
  # phase; non-read-only tools are blocked until the plan is approved)
479
523
  def plan_mode? = @plan_mode
@@ -724,6 +768,10 @@ module Ask
724
768
  on_submit: ->(plan) { emit(Events::PlanProposed.new(plan: plan)) }
725
769
  ) unless resolved.any? { |t| t.name == "exit_plan_mode" }
726
770
  end
771
+ if @memory
772
+ resolved << MemoryWrite.new(memory: @memory, session_id: @id) unless resolved.any? { |t| t.name == "memory_write" }
773
+ resolved << MemorySearch.new(memory: @memory) unless resolved.any? { |t| t.name == "memory_search" }
774
+ end
727
775
  resolved
728
776
  end
729
777
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.32.0"
5
+ VERSION = "0.35.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -43,6 +43,10 @@ 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"
49
+ autoload :MemoryExtractor, "ask/agent/memory_extractor"
46
50
 
47
51
  module Middleware
48
52
  autoload :Base, "ask/agent/middleware/base"
@@ -77,14 +81,16 @@ module Ask
77
81
  # Create a new agent session from a named definition.
78
82
  #
79
83
  # @param name [String, Symbol] the agent name (directory name under +agents/+)
84
+ # @param opts [Hash] runtime overrides — model, provider, api_key, api_base,
85
+ # max_turns, etc. Caller-supplied options win over the definition's config.
80
86
  # @return [Session] a configured, ready-to-run session
81
- def new(name)
87
+ def new(name, **opts)
82
88
  discover!
83
89
  entry = @registry[name.to_s]
84
90
  raise UnknownAgent, "Unknown agent: #{name.inspect}. Searched agents/ and app/agents/." unless entry
85
91
 
86
92
  klass, dir = entry
87
- build_session_from_definition(klass, dir)
93
+ build_session_from_definition(klass, dir, opts)
88
94
  end
89
95
 
90
96
  # Force re-discovery of agent definitions.
@@ -179,7 +185,7 @@ module Ask
179
185
  end
180
186
  end
181
187
 
182
- def build_session_from_definition(klass, dir)
188
+ def build_session_from_definition(klass, dir, opts = {})
183
189
  config = klass._config
184
190
  session_opts = { model: config[:model] || Ask::Agent.configuration.default_model }
185
191
 
@@ -215,6 +221,10 @@ module Ask
215
221
  Ask::Agent.configuration.scheduler.every(schedule, name: File.basename(dir), &task_block)
216
222
  end
217
223
 
224
+ # Caller-supplied runtime options (model/provider/api_key/api_base/...)
225
+ # win over the definition's config.
226
+ session_opts.merge!(opts) unless opts.empty?
227
+
218
228
  Session.new(**session_opts)
219
229
  end
220
230
 
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.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -178,6 +178,10 @@ 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_extractor.rb
183
+ - lib/ask/agent/memory_search.rb
184
+ - lib/ask/agent/memory_write.rb
181
185
  - lib/ask/agent/meta_agent.rb
182
186
  - lib/ask/agent/middleware/base.rb
183
187
  - lib/ask/agent/middleware/default_settings.rb