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 +7 -0
- data/README.md +129 -0
- data/bin/notilens +369 -0
- data/lib/notilens/config.rb +42 -0
- data/lib/notilens/notify.rb +48 -0
- data/lib/notilens/state.rb +50 -0
- data/lib/notilens/version.rb +3 -0
- data/lib/notilens.rb +213 -0
- data/notilens.gemspec +20 -0
- metadata +53 -0
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
|
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: []
|