notilens 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4f4a0ebf4c82fabed926e4c1f823449339cb5e9318a43b571ed9e0d7e5d959e4
4
+ data.tar.gz: 31adaa715cc3936496b80561cde5f7f1f08a551007325802b9bdcd8d9fa25c66
5
+ SHA512:
6
+ metadata.gz: 24439ee3122debf3bbd68b3d4b6af2aa4d6970ae8addfbbabcef7345692e98d8b693872bdbf5d9ab6466977e2432db0f09d9caab9e589c3aa91a0e13347c7f8a
7
+ data.tar.gz: 165602c07940bcae4cf2843d5089b23316b31be68735a4a092fdd2d927470b8c4136d85010f72985495a2b4201546253dc34fc0805e1abf77138c3a86324e538
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # NotiLens Ruby SDK
2
+
3
+ Ruby SDK and CLI for [NotiLens](https://notilens.com) — task lifecycle notifications for AI agents, Sidekiq workers, and any Ruby application.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install notilens
9
+ ```
10
+
11
+ Or in your `Gemfile`:
12
+
13
+ ```ruby
14
+ gem "notilens"
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```ruby
20
+ require "notilens"
21
+
22
+ nl = NotiLens.init("my-agent")
23
+
24
+ task_id = nl.task_start
25
+ nl.task_progress("Processing...", task_id)
26
+ nl.task_complete("Done!", task_id)
27
+ ```
28
+
29
+ ## Credentials
30
+
31
+ Resolved in order:
32
+ 1. `token:` / `secret:` keyword args to `init`
33
+ 2. `NOTILENS_TOKEN` / `NOTILENS_SECRET` env vars
34
+ 3. Saved CLI config (`notilens init --agent ...`)
35
+
36
+ ```ruby
37
+ nl = NotiLens.init("my-agent", token: "your-token", secret: "your-secret")
38
+ ```
39
+
40
+ ## SDK Reference
41
+
42
+ ### Task Lifecycle
43
+
44
+ ```ruby
45
+ task_id = nl.task_start # auto-generated ID
46
+ task_id = nl.task_start("my-task-123") # custom ID
47
+
48
+ nl.task_progress("Fetching data...", task_id)
49
+ nl.task_loop("Processing item 42", task_id)
50
+ nl.task_retry(task_id)
51
+ nl.task_stop(task_id)
52
+ nl.task_error("Quota exceeded", task_id) # non-fatal
53
+ nl.task_complete("All done!", task_id) # terminal
54
+ nl.task_fail("Unrecoverable error", task_id) # terminal
55
+ nl.task_timeout("Timed out after 5m", task_id) # terminal
56
+ nl.task_cancel("Cancelled by user", task_id) # terminal
57
+ nl.task_terminate("Force-killed", task_id) # terminal
58
+ ```
59
+
60
+ ### Output & Input Events
61
+
62
+ ```ruby
63
+ nl.output_generated("Report ready", task_id)
64
+ nl.output_failed("Rendering failed", task_id)
65
+
66
+ nl.input_required("Approve deployment?", task_id)
67
+ nl.input_approved("Approved", task_id)
68
+ nl.input_rejected("Rejected", task_id)
69
+ ```
70
+
71
+ ### Metrics
72
+
73
+ Numeric values accumulate; strings are replaced.
74
+
75
+ ```ruby
76
+ nl.metric("tokens", 512)
77
+ nl.metric("tokens", 128) # now 640
78
+
79
+ nl.reset_metrics("tokens") # reset one key
80
+ nl.reset_metrics # reset all
81
+ ```
82
+
83
+ ### Generic Events
84
+
85
+ ```ruby
86
+ nl.emit("custom.event", "Something happened")
87
+ nl.emit("custom.event", "With meta", meta: { "key" => "value" })
88
+ ```
89
+
90
+ ## CLI
91
+
92
+ ### Install
93
+
94
+ ```bash
95
+ gem install notilens
96
+ ```
97
+
98
+ ### Configure
99
+
100
+ ```bash
101
+ notilens init --agent my-agent --token TOKEN --secret SECRET
102
+ notilens agents
103
+ notilens remove-agent my-agent
104
+ ```
105
+
106
+ ### Commands
107
+
108
+ ```bash
109
+ notilens task.start --agent my-agent --task job-123
110
+ notilens task.progress "Fetching" --agent my-agent --task job-123
111
+ notilens task.loop "Item 5" --agent my-agent --task job-123
112
+ notilens task.retry --agent my-agent --task job-123
113
+ notilens task.complete "Done!" --agent my-agent --task job-123
114
+ notilens task.fail "Error" --agent my-agent --task job-123
115
+
116
+ notilens output.generate "Report ready" --agent my-agent --task job-123
117
+ notilens input.required "Approve?" --agent my-agent --task job-123
118
+
119
+ notilens metric tokens=512 cost=0.003 --agent my-agent --task job-123
120
+ notilens metric.reset --agent my-agent --task job-123
121
+
122
+ notilens emit my.event "msg" --agent my-agent
123
+ notilens version
124
+ ```
125
+
126
+ ## Requirements
127
+
128
+ - Ruby 2.7+
129
+ - No external dependencies (uses stdlib `net/http` and `json`)
data/bin/notilens ADDED
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.join(__dir__, "..", "lib")
5
+
6
+ require "notilens"
7
+ require "notilens/config"
8
+ require "notilens/state"
9
+ require "notilens/notify"
10
+ require "notilens/version"
11
+
12
+ # ── Helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ def positional_args(args)
15
+ pos = []
16
+ rest = args.dup
17
+ while rest.first && !rest.first.start_with?("--")
18
+ pos << rest.shift
19
+ end
20
+ [pos, rest]
21
+ end
22
+
23
+ def parse_flags(args)
24
+ f = {
25
+ agent: "", task_id: "", type: "", meta: {},
26
+ image_url: "", open_url: "", download_url: "",
27
+ tags: "", is_actionable: ""
28
+ }
29
+ i = 0
30
+ while i < args.length
31
+ case args[i]
32
+ when "--agent" then f[:agent] = args[i += 1].to_s
33
+ when "--task" then f[:task_id] = args[i += 1].to_s
34
+ when "--type" then f[:type] = args[i += 1].to_s
35
+ when "--image_url" then f[:image_url] = args[i += 1].to_s
36
+ when "--open_url" then f[:open_url] = args[i += 1].to_s
37
+ when "--download_url" then f[:download_url] = args[i += 1].to_s
38
+ when "--tags" then f[:tags] = args[i += 1].to_s
39
+ when "--is_actionable"then f[:is_actionable] = args[i += 1].to_s
40
+ when "--meta"
41
+ kv = args[i += 1].to_s
42
+ eq = kv.index("=")
43
+ f[:meta][kv[0...eq]] = kv[eq + 1..] if eq
44
+ end
45
+ i += 1
46
+ end
47
+ if f[:agent].empty?
48
+ $stderr.puts "❌ --agent is required"
49
+ exit 1
50
+ end
51
+ f[:task_id] = "task_#{NotiLens::State.now_ms}" if f[:task_id].empty?
52
+ f
53
+ end
54
+
55
+ def calc_duration(sf)
56
+ NotiLens::State.calc_duration(sf)
57
+ end
58
+
59
+ def send_notify(event, message, f)
60
+ conf = NotiLens::Config.get_agent(f[:agent])
61
+ if conf.nil? || conf["token"].to_s.empty? || conf["secret"].to_s.empty?
62
+ $stderr.puts "❌ Agent '#{f[:agent]}' not configured. Run: notilens init --agent #{f[:agent]} --token TOKEN --secret SECRET"
63
+ exit 1
64
+ end
65
+
66
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
67
+ state = NotiLens::State.read(sf)
68
+
69
+ meta = { "agent" => f[:agent] }
70
+ dur = state["duration_ms"].to_i
71
+ rc = state["retry_count"].to_i
72
+ lc = state["loop_count"].to_i
73
+ meta["duration_ms"] = dur if dur > 0
74
+ meta["retry_count"] = rc if rc > 0
75
+ meta["loop_count"] = lc if lc > 0
76
+ (state["metrics"] || {}).each { |k, v| meta[k] = v }
77
+ f[:meta].each { |k, v| meta[k] = v }
78
+
79
+ title = "#{f[:agent]} | #{f[:task_id]} | #{event}"
80
+
81
+ ev_type = %w[info success warning urgent].include?(f[:type]) \
82
+ ? f[:type] : NotiLens::Notify.event_type(event)
83
+
84
+ is_actionable = if f[:is_actionable] != ""
85
+ f[:is_actionable].downcase == "true"
86
+ else
87
+ NotiLens::Notify.actionable_default?(event)
88
+ end
89
+
90
+ payload = {
91
+ "event" => event,
92
+ "title" => title,
93
+ "message" => message,
94
+ "type" => ev_type,
95
+ "agent" => f[:agent],
96
+ "task_id" => f[:task_id],
97
+ "is_actionable" => is_actionable,
98
+ "image_url" => f[:image_url],
99
+ "open_url" => f[:open_url],
100
+ "download_url" => f[:download_url],
101
+ "tags" => f[:tags],
102
+ "ts" => Time.now.to_f,
103
+ "meta" => meta
104
+ }
105
+
106
+ NotiLens::Notify.send(conf["token"], conf["secret"], payload)
107
+ sleep 0.3
108
+ end
109
+
110
+ # ── Commands ──────────────────────────────────────────────────────────────────
111
+
112
+ command = ARGV[0]
113
+ rest = ARGV[1..]
114
+
115
+ case command
116
+
117
+ when "init"
118
+ agent = token = secret = ""
119
+ i = 0
120
+ while i < rest.length
121
+ case rest[i]
122
+ when "--agent" then agent = rest[i += 1].to_s
123
+ when "--token" then token = rest[i += 1].to_s
124
+ when "--secret" then secret = rest[i += 1].to_s
125
+ end
126
+ i += 1
127
+ end
128
+ if agent.empty? || token.empty? || secret.empty?
129
+ $stderr.puts "Usage: notilens init --agent <name> --token <token> --secret <secret>"
130
+ exit 1
131
+ end
132
+ NotiLens::Config.save_agent(agent, token, secret)
133
+ puts "✔ Agent '#{agent}' saved"
134
+
135
+ when "agents"
136
+ agents = NotiLens::Config.list_agents
137
+ agents.empty? ? puts("No agents configured.") : agents.each { |a| puts " #{a}" }
138
+
139
+ when "remove-agent"
140
+ if rest.empty?
141
+ $stderr.puts "Usage: notilens remove-agent <agent>"
142
+ exit 1
143
+ end
144
+ NotiLens::Config.remove_agent(rest[0]) \
145
+ ? puts("✔ Agent '#{rest[0]}' removed") \
146
+ : $stderr.puts("Agent '#{rest[0]}' not found")
147
+
148
+ when "task.start"
149
+ f = parse_flags(rest)
150
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
151
+ NotiLens::State.write(sf, {
152
+ "agent" => f[:agent],
153
+ "task" => f[:task_id],
154
+ "start_time" => NotiLens::State.now_ms,
155
+ "retry_count" => 0,
156
+ "loop_count" => 0
157
+ })
158
+ send_notify("task.started", "Task started", f)
159
+ puts "▶ Started: #{f[:agent]} | #{f[:task_id]}"
160
+
161
+ when "task.progress"
162
+ pos, rest2 = positional_args(rest)
163
+ f = parse_flags(rest2)
164
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
165
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
166
+ send_notify("task.progress", pos[0].to_s, f)
167
+ puts "⏳ Progress: #{f[:agent]} | #{f[:task_id]}"
168
+
169
+ when "task.loop"
170
+ pos, rest2 = positional_args(rest)
171
+ f = parse_flags(rest2)
172
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
173
+ count = NotiLens::State.read(sf)["loop_count"].to_i + 1
174
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "loop_count" => count })
175
+ send_notify("task.loop", pos[0].to_s, f)
176
+ puts "🔄 Loop (#{count}): #{f[:agent]} | #{f[:task_id]}"
177
+
178
+ when "task.retry"
179
+ f = parse_flags(rest)
180
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
181
+ count = NotiLens::State.read(sf)["retry_count"].to_i + 1
182
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "retry_count" => count })
183
+ send_notify("task.retry", "Retrying task", f)
184
+ puts "🔁 Retry: #{f[:agent]} | #{f[:task_id]}"
185
+
186
+ when "task.stop"
187
+ f = parse_flags(rest)
188
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
189
+ dur = calc_duration(sf)
190
+ NotiLens::State.update(sf, { "duration_ms" => dur })
191
+ send_notify("task.stopped", "Task stopped", f)
192
+ puts "⏹ Stopped: #{f[:agent]} | #{f[:task_id]} (#{dur} ms)"
193
+
194
+ when "task.error"
195
+ pos, rest2 = positional_args(rest)
196
+ f = parse_flags(rest2)
197
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
198
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf), "last_error" => pos[0].to_s })
199
+ send_notify("task.error", pos[0].to_s, f)
200
+ $stderr.puts "❌ Error: #{pos[0]}"
201
+
202
+ when "task.fail"
203
+ pos, rest2 = positional_args(rest)
204
+ f = parse_flags(rest2)
205
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
206
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
207
+ send_notify("task.failed", pos[0].to_s, f)
208
+ NotiLens::State.delete(sf)
209
+ puts "💥 Failed: #{f[:agent]} | #{f[:task_id]}"
210
+
211
+ when "task.timeout"
212
+ pos, rest2 = positional_args(rest)
213
+ f = parse_flags(rest2)
214
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
215
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
216
+ send_notify("task.timeout", pos[0].to_s, f)
217
+ NotiLens::State.delete(sf)
218
+ puts "⏰ Timeout: #{f[:agent]} | #{f[:task_id]}"
219
+
220
+ when "task.cancel"
221
+ pos, rest2 = positional_args(rest)
222
+ f = parse_flags(rest2)
223
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
224
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
225
+ send_notify("task.cancelled", pos[0].to_s, f)
226
+ NotiLens::State.delete(sf)
227
+ puts "🚫 Cancelled: #{f[:agent]} | #{f[:task_id]}"
228
+
229
+ when "task.terminate"
230
+ pos, rest2 = positional_args(rest)
231
+ f = parse_flags(rest2)
232
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
233
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
234
+ send_notify("task.terminated", pos[0].to_s, f)
235
+ NotiLens::State.delete(sf)
236
+ puts "⚠ Terminated: #{f[:agent]} | #{f[:task_id]}"
237
+
238
+ when "task.complete"
239
+ pos, rest2 = positional_args(rest)
240
+ f = parse_flags(rest2)
241
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
242
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
243
+ send_notify("task.completed", pos[0].to_s, f)
244
+ NotiLens::State.delete(sf)
245
+ puts "✅ Completed: #{f[:agent]} | #{f[:task_id]}"
246
+
247
+ when "metric"
248
+ pos, rest2 = positional_args(rest)
249
+ f = parse_flags(rest2)
250
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
251
+ state = NotiLens::State.read(sf)
252
+ metrics = state["metrics"] || {}
253
+ pos.each do |kv|
254
+ eq = kv.index("=")
255
+ next unless eq
256
+ k = kv[0...eq]
257
+ v = kv[eq + 1..]
258
+ v = v.include?(".") ? v.to_f : v.to_i rescue v
259
+ if v.is_a?(Numeric) && metrics[k].is_a?(Numeric)
260
+ metrics[k] += v
261
+ else
262
+ metrics[k] = v
263
+ end
264
+ end
265
+ NotiLens::State.update(sf, { "metrics" => metrics })
266
+ puts "📊 Metrics: #{metrics.map { |k, v| "#{k}=#{v}" }.join(", ")}"
267
+
268
+ when "metric.reset"
269
+ pos, rest2 = positional_args(rest)
270
+ f = parse_flags(rest2)
271
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
272
+ if pos[0]
273
+ state = NotiLens::State.read(sf)
274
+ metrics = state["metrics"] || {}
275
+ metrics.delete(pos[0])
276
+ NotiLens::State.update(sf, { "metrics" => metrics })
277
+ puts "📊 Metric '#{pos[0]}' reset"
278
+ else
279
+ NotiLens::State.update(sf, { "metrics" => {} })
280
+ puts "📊 All metrics reset"
281
+ end
282
+
283
+ when "output.generate"
284
+ pos, rest2 = positional_args(rest)
285
+ send_notify("output.generated", pos[0].to_s, parse_flags(rest2))
286
+
287
+ when "output.fail"
288
+ pos, rest2 = positional_args(rest)
289
+ send_notify("output.failed", pos[0].to_s, parse_flags(rest2))
290
+
291
+ when "input.required"
292
+ pos, rest2 = positional_args(rest)
293
+ send_notify("input.required", pos[0].to_s, parse_flags(rest2))
294
+
295
+ when "input.approve"
296
+ pos, rest2 = positional_args(rest)
297
+ send_notify("input.approved", pos[0].to_s, parse_flags(rest2))
298
+
299
+ when "input.reject"
300
+ pos, rest2 = positional_args(rest)
301
+ send_notify("input.rejected", pos[0].to_s, parse_flags(rest2))
302
+
303
+ when "emit"
304
+ if rest.length < 2
305
+ $stderr.puts "Usage: notilens emit <event> <message> --agent <agent>"
306
+ exit 1
307
+ end
308
+ event = rest[0]
309
+ msg = rest[1]
310
+ f = parse_flags(rest[2..])
311
+ sf = NotiLens::State.file(f[:agent], f[:task_id])
312
+ NotiLens::State.update(sf, { "duration_ms" => calc_duration(sf) })
313
+ send_notify(event, msg, f)
314
+ puts "📡 Event emitted: #{event}"
315
+
316
+ when "version"
317
+ puts "NotiLens v#{NotiLens::VERSION}"
318
+
319
+ else
320
+ puts <<~USAGE
321
+ Usage:
322
+ notilens init --agent <name> --token <token> --secret <secret>
323
+ notilens agents
324
+ notilens remove-agent <agent>
325
+
326
+ Task Lifecycle:
327
+ notilens task.start --agent <agent> [--task <id>]
328
+ notilens task.progress "msg" --agent <agent> [--task <id>]
329
+ notilens task.loop "msg" --agent <agent> [--task <id>]
330
+ notilens task.retry --agent <agent> [--task <id>]
331
+ notilens task.stop --agent <agent> [--task <id>]
332
+ notilens task.error "msg" --agent <agent> [--task <id>]
333
+ notilens task.fail "msg" --agent <agent> [--task <id>]
334
+ notilens task.timeout "msg" --agent <agent> [--task <id>]
335
+ notilens task.cancel "msg" --agent <agent> [--task <id>]
336
+ notilens task.terminate "msg" --agent <agent> [--task <id>]
337
+ notilens task.complete "msg" --agent <agent> [--task <id>]
338
+
339
+ Output / Input:
340
+ notilens output.generate "msg" --agent <agent> [--task <id>]
341
+ notilens output.fail "msg" --agent <agent> [--task <id>]
342
+ notilens input.required "msg" --agent <agent> [--task <id>]
343
+ notilens input.approve "msg" --agent <agent> [--task <id>]
344
+ notilens input.reject "msg" --agent <agent> [--task <id>]
345
+
346
+ Metrics:
347
+ notilens metric tokens=512 cost=0.003 --agent <agent> --task <id>
348
+ notilens metric.reset tokens --agent <agent> --task <id>
349
+ notilens metric.reset --agent <agent> --task <id>
350
+
351
+ Generic:
352
+ notilens emit <event> "msg" --agent <agent>
353
+
354
+ Options:
355
+ --agent <name>
356
+ --task <id>
357
+ --type success|warning|urgent|info
358
+ --meta key=value (repeatable)
359
+ --image_url <url>
360
+ --open_url <url>
361
+ --download_url <url>
362
+ --tags "tag1,tag2"
363
+ --is_actionable true|false
364
+
365
+ Other:
366
+ notilens version
367
+ USAGE
368
+ exit 1
369
+ end
@@ -0,0 +1,42 @@
1
+ require "json"
2
+
3
+ module NotiLens
4
+ module Config
5
+ CONFIG_PATH = File.join(Dir.home, ".notilens_config.json")
6
+
7
+ def self.load
8
+ return {} unless File.exist?(CONFIG_PATH)
9
+ JSON.parse(File.read(CONFIG_PATH))
10
+ rescue
11
+ {}
12
+ end
13
+
14
+ def self.save(cfg)
15
+ File.write(CONFIG_PATH, JSON.pretty_generate(cfg))
16
+ File.chmod(0600, CONFIG_PATH)
17
+ end
18
+
19
+ def self.save_agent(agent, token, secret)
20
+ cfg = load
21
+ cfg[agent] = { "token" => token, "secret" => secret }
22
+ save(cfg)
23
+ end
24
+
25
+ def self.get_agent(agent)
26
+ cfg = load
27
+ cfg[agent]
28
+ end
29
+
30
+ def self.remove_agent(agent)
31
+ cfg = load
32
+ return false unless cfg.key?(agent)
33
+ cfg.delete(agent)
34
+ save(cfg)
35
+ true
36
+ end
37
+
38
+ def self.list_agents
39
+ load.keys
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,48 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require_relative "version"
5
+
6
+ module NotiLens
7
+ module Notify
8
+ WEBHOOK_URL = "https://hook.notilens.com/webhook/%s/send"
9
+
10
+ SUCCESS_EVENTS = %w[task.completed output.generated input.approved].freeze
11
+ URGENT_EVENTS = %w[task.failed task.timeout task.error task.terminated output.failed].freeze
12
+ WARNING_EVENTS = %w[task.retry task.cancelled input.required input.rejected].freeze
13
+ ACTIONABLE_EVENTS = %w[
14
+ task.error task.failed task.timeout task.retry task.loop
15
+ output.failed input.required input.rejected
16
+ ].freeze
17
+
18
+ def self.event_type(event)
19
+ return "success" if SUCCESS_EVENTS.include?(event)
20
+ return "urgent" if URGENT_EVENTS.include?(event)
21
+ return "warning" if WARNING_EVENTS.include?(event)
22
+ "info"
23
+ end
24
+
25
+ def self.actionable_default?(event)
26
+ ACTIONABLE_EVENTS.include?(event)
27
+ end
28
+
29
+ def self.send(token, secret, payload)
30
+ uri = URI(WEBHOOK_URL % token)
31
+ body = payload.to_json
32
+
33
+ req = Net::HTTP::Post.new(uri)
34
+ req["Content-Type"] = "application/json"
35
+ req["X-NOTILENS-KEY"] = secret
36
+ req["User-Agent"] = "NotiLens-SDK/#{VERSION}"
37
+ req.body = body
38
+
39
+ http = Net::HTTP.new(uri.host, uri.port)
40
+ http.use_ssl = true
41
+ http.read_timeout = 10
42
+ http.open_timeout = 10
43
+ http.request(req)
44
+ rescue
45
+ nil
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ require "json"
2
+ require "tmpdir"
3
+
4
+ module NotiLens
5
+ module State
6
+ def self.file(agent, task_id)
7
+ user = ENV["USER"] || ENV["USERNAME"] || ""
8
+ agent = agent.gsub(File::SEPARATOR, "_")
9
+ task_id = task_id.gsub(File::SEPARATOR, "_")
10
+ File.join(Dir.tmpdir, "notilens_#{user}_#{agent}_#{task_id}.json")
11
+ end
12
+
13
+ def self.read(path)
14
+ return {} unless File.exist?(path)
15
+ JSON.parse(File.read(path))
16
+ rescue
17
+ {}
18
+ end
19
+
20
+ def self.write(path, data)
21
+ File.write(path, JSON.pretty_generate(data))
22
+ File.chmod(0600, path)
23
+ rescue
24
+ nil
25
+ end
26
+
27
+ def self.update(path, updates)
28
+ s = read(path)
29
+ s.merge!(updates)
30
+ write(path, s)
31
+ end
32
+
33
+ def self.delete(path)
34
+ File.delete(path) if File.exist?(path)
35
+ rescue
36
+ nil
37
+ end
38
+
39
+ def self.calc_duration(path)
40
+ s = read(path)
41
+ start = s["start_time"].to_i
42
+ return 0 if start == 0
43
+ (Time.now.to_f * 1000).to_i - start
44
+ end
45
+
46
+ def self.now_ms
47
+ (Time.now.to_f * 1000).to_i
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module NotiLens
2
+ VERSION = "0.3.0"
3
+ end
data/lib/notilens.rb ADDED
@@ -0,0 +1,213 @@
1
+ require_relative "notilens/version"
2
+ require_relative "notilens/config"
3
+ require_relative "notilens/state"
4
+ require_relative "notilens/notify"
5
+
6
+ module NotiLens
7
+ class Client
8
+ def initialize(agent, token: nil, secret: nil)
9
+ @agent = agent
10
+ @token = token || ENV["NOTILENS_TOKEN"] || ""
11
+ @secret = secret || ENV["NOTILENS_SECRET"] || ""
12
+ @metrics = {}
13
+
14
+ if @token.empty? || @secret.empty?
15
+ conf = Config.get_agent(agent) || {}
16
+ @token = conf["token"] if @token.empty?
17
+ @secret = conf["secret"] if @secret.empty?
18
+ end
19
+
20
+ if @token.to_s.empty? || @secret.to_s.empty?
21
+ raise "NotiLens: token and secret are required. Pass them directly, " \
22
+ "set NOTILENS_TOKEN/NOTILENS_SECRET env vars, or run: " \
23
+ "notilens init --agent #{agent} --token TOKEN --secret SECRET"
24
+ end
25
+ end
26
+
27
+ # ── Metrics ────────────────────────────────────────────────────────────────
28
+
29
+ def metric(key, value)
30
+ if value.is_a?(Numeric) && @metrics[key].is_a?(Numeric)
31
+ @metrics[key] += value
32
+ else
33
+ @metrics[key] = value
34
+ end
35
+ self
36
+ end
37
+
38
+ def reset_metrics(key = nil)
39
+ key ? @metrics.delete(key) : @metrics.clear
40
+ self
41
+ end
42
+
43
+ # ── Task lifecycle ─────────────────────────────────────────────────────────
44
+
45
+ def task_start(task_id = nil)
46
+ id = task_id.to_s.empty? ? "task_#{State.now_ms}" : task_id
47
+ sf = State.file(@agent, id)
48
+ State.write(sf, {
49
+ "agent" => @agent,
50
+ "task" => id,
51
+ "start_time" => State.now_ms,
52
+ "retry_count" => 0,
53
+ "loop_count" => 0
54
+ })
55
+ send_event("task.started", "Task started", id)
56
+ id
57
+ end
58
+
59
+ def task_progress(message, task_id)
60
+ sf = State.file(@agent, task_id)
61
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
62
+ send_event("task.progress", message, task_id)
63
+ end
64
+
65
+ def task_loop(message, task_id)
66
+ sf = State.file(@agent, task_id)
67
+ s = State.read(sf)
68
+ count = s["loop_count"].to_i + 1
69
+ State.update(sf, { "duration_ms" => State.calc_duration(sf), "loop_count" => count })
70
+ send_event("task.loop", message, task_id)
71
+ end
72
+
73
+ def task_retry(task_id)
74
+ sf = State.file(@agent, task_id)
75
+ s = State.read(sf)
76
+ count = s["retry_count"].to_i + 1
77
+ State.update(sf, { "duration_ms" => State.calc_duration(sf), "retry_count" => count })
78
+ send_event("task.retry", "Retrying task", task_id)
79
+ end
80
+
81
+ def task_error(message, task_id)
82
+ sf = State.file(@agent, task_id)
83
+ State.update(sf, { "duration_ms" => State.calc_duration(sf), "last_error" => message })
84
+ send_event("task.error", message, task_id)
85
+ end
86
+
87
+ def task_complete(message, task_id)
88
+ sf = State.file(@agent, task_id)
89
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
90
+ send_event("task.completed", message, task_id)
91
+ State.delete(sf)
92
+ end
93
+
94
+ def task_fail(message, task_id)
95
+ sf = State.file(@agent, task_id)
96
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
97
+ send_event("task.failed", message, task_id)
98
+ State.delete(sf)
99
+ end
100
+
101
+ def task_timeout(message, task_id)
102
+ sf = State.file(@agent, task_id)
103
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
104
+ send_event("task.timeout", message, task_id)
105
+ State.delete(sf)
106
+ end
107
+
108
+ def task_cancel(message, task_id)
109
+ sf = State.file(@agent, task_id)
110
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
111
+ send_event("task.cancelled", message, task_id)
112
+ State.delete(sf)
113
+ end
114
+
115
+ def task_stop(task_id)
116
+ sf = State.file(@agent, task_id)
117
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
118
+ send_event("task.stopped", "Task stopped", task_id)
119
+ end
120
+
121
+ def task_terminate(message, task_id)
122
+ sf = State.file(@agent, task_id)
123
+ State.update(sf, { "duration_ms" => State.calc_duration(sf) })
124
+ send_event("task.terminated", message, task_id)
125
+ State.delete(sf)
126
+ end
127
+
128
+ # ── Input events ───────────────────────────────────────────────────────────
129
+
130
+ def input_required(message, task_id)
131
+ send_event("input.required", message, task_id)
132
+ end
133
+
134
+ def input_approved(message, task_id)
135
+ send_event("input.approved", message, task_id)
136
+ end
137
+
138
+ def input_rejected(message, task_id)
139
+ send_event("input.rejected", message, task_id)
140
+ end
141
+
142
+ # ── Output events ──────────────────────────────────────────────────────────
143
+
144
+ def output_generated(message, task_id)
145
+ send_event("output.generated", message, task_id)
146
+ end
147
+
148
+ def output_failed(message, task_id)
149
+ send_event("output.failed", message, task_id)
150
+ end
151
+
152
+ # ── Generic emit ───────────────────────────────────────────────────────────
153
+
154
+ def emit(event, message, meta: {})
155
+ send_event(event, message, "", extra_meta: meta)
156
+ end
157
+
158
+ private
159
+
160
+ def send_event(event, message, task_id, extra_meta: {})
161
+ title = task_id.to_s.empty? \
162
+ ? "#{@agent} | #{event}" \
163
+ : "#{@agent} | #{task_id} | #{event}"
164
+
165
+ duration = retry_count = loop_count = 0
166
+ unless task_id.to_s.empty?
167
+ sf = State.file(@agent, task_id)
168
+ s = State.read(sf)
169
+ duration = s["duration_ms"].to_i
170
+ retry_count = s["retry_count"].to_i
171
+ loop_count = s["loop_count"].to_i
172
+ end
173
+
174
+ meta = { "agent" => @agent }
175
+ meta["duration_ms"] = duration if duration > 0
176
+ meta["retry_count"] = retry_count if retry_count > 0
177
+ meta["loop_count"] = loop_count if loop_count > 0
178
+ @metrics.each { |k, v| meta[k] = v }
179
+ meta.merge!(extra_meta)
180
+
181
+ image_url = meta.delete("image_url").to_s
182
+ open_url = meta.delete("open_url").to_s
183
+ download_url = meta.delete("download_url").to_s
184
+ tags = meta.delete("tags").to_s
185
+ is_actionable = meta.key?("is_actionable") \
186
+ ? meta.delete("is_actionable") \
187
+ : Notify.actionable_default?(event)
188
+
189
+ payload = {
190
+ "event" => event,
191
+ "title" => title,
192
+ "message" => message,
193
+ "type" => Notify.event_type(event),
194
+ "agent" => @agent,
195
+ "task_id" => task_id,
196
+ "is_actionable" => is_actionable,
197
+ "image_url" => image_url,
198
+ "open_url" => open_url,
199
+ "download_url" => download_url,
200
+ "tags" => tags,
201
+ "ts" => Time.now.to_f,
202
+ "meta" => meta
203
+ }
204
+
205
+ Notify.send(@token, @secret, payload)
206
+ end
207
+ end
208
+
209
+ # Convenience factory: NotiLens.init("agent")
210
+ def self.init(agent, token: nil, secret: nil)
211
+ Client.new(agent, token: token, secret: secret)
212
+ end
213
+ end
data/notilens.gemspec ADDED
@@ -0,0 +1,20 @@
1
+ require_relative "lib/notilens/version"
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "notilens"
5
+ s.version = NotiLens::VERSION
6
+ s.summary = "NotiLens SDK — task lifecycle notifications for AI agents and background jobs"
7
+ s.description = "Send task started/progress/complete/failed notifications from AI agents, " \
8
+ "Sidekiq workers, and any Ruby application."
9
+ s.authors = ["NotiLens"]
10
+ s.email = "hello@notilens.com"
11
+ s.homepage = "https://github.com/notilens/sdk-ruby"
12
+ s.license = "MIT"
13
+
14
+ s.files = Dir["lib/**/*.rb", "bin/*", "README.md", "notilens.gemspec"]
15
+ s.bindir = "bin"
16
+ s.executables = ["notilens"]
17
+ s.require_paths = ["lib"]
18
+
19
+ s.required_ruby_version = ">= 2.7"
20
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: notilens
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - NotiLens
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Send task started/progress/complete/failed notifications from AI agents,
14
+ Sidekiq workers, and any Ruby application.
15
+ email: hello@notilens.com
16
+ executables:
17
+ - notilens
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - bin/notilens
23
+ - lib/notilens.rb
24
+ - lib/notilens/config.rb
25
+ - lib/notilens/notify.rb
26
+ - lib/notilens/state.rb
27
+ - lib/notilens/version.rb
28
+ - notilens.gemspec
29
+ homepage: https://github.com/notilens/sdk-ruby
30
+ licenses:
31
+ - MIT
32
+ metadata: {}
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '2.7'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.3.26
49
+ signing_key:
50
+ specification_version: 4
51
+ summary: NotiLens SDK — task lifecycle notifications for AI agents and background
52
+ jobs
53
+ test_files: []