1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
-- Embedded Lua source loaded by src/bridge/ts.rs.
-- Defines std.ts.register_tools(opts?) — LLM-facing tool registration helper.
--
-- opts (all optional):
-- allowed : array of op names (default: {"append", "query", "last"})
-- prefix : tool name prefix (default: "ts_")
--
-- Returns: array of registered tool names.
--
-- Notes on sum/avg:
-- The `value` column is stored as a JSON-encoded payload. SQLite's
-- CAST(value AS REAL) treats JSON objects as 0.0, so `sum`/`avg` produce
-- meaningful results only when the series contains numeric values. Use
-- number-only series for aggregate operations.
--
-- Tag key restriction:
-- Tag keys must match [a-zA-Z0-9_]+ (ASCII alphanumeric and underscore).
-- This restriction guards against SQL injection via json_extract paths.
std.ts.register_tools = function(opts)
opts = opts or {}
local allowed = opts.allowed or { "append", "query", "last" }
local prefix = opts.prefix or "ts_"
local defs = {
append = {
description = "Append a data point to a named time-series stream backed by "
.. "the agent's local SQLite database (persists across runs, agent-private). "
.. "`value` can be a number (e.g. a metric) or a table (e.g. a structured "
.. "MCP envelope). Both are stored as JSON and round-trip without loss. "
.. "`tags` is an optional flat object used to label the point; keys must "
.. "match [a-zA-Z0-9_]+. `at` is an optional Unix timestamp in milliseconds; "
.. "defaults to the current wall-clock time.",
input_schema = {
type = "object",
properties = {
series = {
type = "string",
description = "Logical stream name (e.g. \"cpu_load\", \"agent_events\").",
},
value = {
description = "Data point payload: a number or a table (JSON-encoded).",
},
tags = {
type = "object",
description = "Optional flat label object. Keys: [a-zA-Z0-9_]+ only.",
additionalProperties = { type = "string" },
},
at = {
type = "integer",
description = "Optional Unix timestamp in milliseconds. Defaults to now.",
},
},
required = { "series", "value" },
},
handler = function(input)
std.ts.append(input.series, input.value, input.tags, input.at)
return { ok = true }
end,
},
query = {
description = "Query a time-series stream from the agent's local SQLite database. "
.. "Returns an array of rows. Raw mode (no `agg`): each row is "
.. "{ ts, value, tags }. Single-aggregate mode (`agg` without `bucket_ms`): "
.. "returns one row { value } with the scalar result. Time-bucketed mode "
.. "(`agg` + `bucket_ms`): each row is { bucket_ts, value }. "
.. "Supported agg values: \"count\", \"sum\", \"avg\", \"last\". "
.. "sum/avg interpret `value` as a number via CAST; rows with object values "
.. "contribute 0.0. Tag filtering uses a conjunction of json_extract checks "
.. "(AND semantics); rows without tags never match a tag filter.",
input_schema = {
type = "object",
properties = {
series = {
type = "string",
description = "Stream name to query.",
},
opts = {
type = "object",
description = "Query options.",
properties = {
from = {
type = "integer",
description = "Start of time range (Unix ms, inclusive). "
.. "Default: beginning of time.",
},
to = {
type = "integer",
description = "End of time range (Unix ms, inclusive). "
.. "Default: end of time.",
},
tags = {
type = "object",
description = "AND-filter: all key-value pairs must match "
.. "(json_extract per key). Keys: [a-zA-Z0-9_]+.",
additionalProperties = { type = "string" },
},
agg = {
type = "string",
description = "Aggregation function: "
.. "\"count\" | \"sum\" | \"avg\" | \"last\".",
enum = { "count", "sum", "avg", "last" },
},
bucket_ms = {
type = "integer",
description = "Bucket width in milliseconds (> 0). "
.. "Requires `agg`. Enables time-bucketed aggregation.",
},
limit = {
type = "integer",
description = "Maximum number of rows to return (>= 0).",
},
offset = {
type = "integer",
description = "Number of rows to skip (>= 0).",
},
},
},
},
required = { "series" },
},
handler = function(input)
local rows = std.ts.query(input.series, input.opts)
return { rows = rows }
end,
},
last = {
description = "Return the most-recent data point in a time-series stream from "
.. "the agent's local SQLite database. Returns nil if no matching row exists, "
.. "or { ts, value, tags } for the latest row. `tags` applies the same "
.. "AND-conjunction filter as std.ts.query.",
input_schema = {
type = "object",
properties = {
series = {
type = "string",
description = "Stream name to query.",
},
tags = {
type = "object",
description = "Optional AND-filter for tag matching. "
.. "Keys: [a-zA-Z0-9_]+.",
additionalProperties = { type = "string" },
},
},
required = { "series" },
},
handler = function(input)
local row = std.ts.last(input.series, input.tags)
return { row = row }
end,
},
}
local registered = {}
for _, op in ipairs(allowed) do
local d = defs[op]
if d then
local name = prefix .. op
tool.register(
name,
{ description = d.description, input_schema = d.input_schema },
d.handler
)
table.insert(registered, name)
end
end
return registered
end