recursive-llm-ts 4.9.0 → 5.0.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.
- package/README.md +3 -1
- package/bin/rlm-go +0 -0
- package/dist/bridge-interface.d.ts +149 -0
- package/go/cmd/rlm/main.go +39 -6
- package/go/go.mod +13 -3
- package/go/go.sum +53 -2
- package/go/rlm/compression.go +59 -0
- package/go/rlm/context_overflow.go +21 -36
- package/go/rlm/context_savings_test.go +387 -0
- package/go/rlm/json_extraction.go +140 -0
- package/go/rlm/lcm_agentic_map.go +317 -0
- package/go/rlm/lcm_context_loop.go +309 -0
- package/go/rlm/lcm_delegation.go +257 -0
- package/go/rlm/lcm_episodes.go +313 -0
- package/go/rlm/lcm_episodes_test.go +384 -0
- package/go/rlm/lcm_files.go +424 -0
- package/go/rlm/lcm_map.go +348 -0
- package/go/rlm/lcm_store.go +615 -0
- package/go/rlm/lcm_summarizer.go +239 -0
- package/go/rlm/lcm_test.go +1407 -0
- package/go/rlm/rlm.go +124 -1
- package/go/rlm/store_backend.go +121 -0
- package/go/rlm/store_backend_test.go +428 -0
- package/go/rlm/store_sqlite.go +575 -0
- package/go/rlm/structured.go +6 -83
- package/go/rlm/token_tracking_test.go +25 -11
- package/go/rlm/tokenizer.go +216 -0
- package/go/rlm/tokenizer_test.go +305 -0
- package/go/rlm/types.go +23 -1
- package/go/rlm.test +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
package rlm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"database/sql"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
_ "modernc.org/sqlite"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// SQLiteBackend is a SQLite-based StoreBackend implementation.
|
|
15
|
+
type SQLiteBackend struct {
|
|
16
|
+
db *sql.DB
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// NewSQLiteBackend creates a new SQLite backend and runs schema migrations.
|
|
20
|
+
func NewSQLiteBackend(dbPath string) (*SQLiteBackend, error) {
|
|
21
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
22
|
+
if err != nil {
|
|
23
|
+
return nil, fmt.Errorf("open sqlite database: %w", err)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
backend := &SQLiteBackend{db: db}
|
|
27
|
+
|
|
28
|
+
if _, err := backend.db.Exec(`PRAGMA journal_mode = WAL;`); err != nil {
|
|
29
|
+
_ = backend.db.Close()
|
|
30
|
+
return nil, fmt.Errorf("enable WAL mode: %w", err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if _, err := backend.db.Exec(`PRAGMA foreign_keys = ON;`); err != nil {
|
|
34
|
+
_ = backend.db.Close()
|
|
35
|
+
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if err := backend.migrate(); err != nil {
|
|
39
|
+
_ = backend.db.Close()
|
|
40
|
+
return nil, err
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return backend, nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (b *SQLiteBackend) migrate() error {
|
|
47
|
+
if _, err := b.db.Exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
role TEXT,
|
|
51
|
+
content TEXT,
|
|
52
|
+
tokens INTEGER,
|
|
53
|
+
timestamp TEXT,
|
|
54
|
+
file_ids TEXT DEFAULT '[]',
|
|
55
|
+
metadata TEXT DEFAULT '{}'
|
|
56
|
+
);
|
|
57
|
+
`); err != nil {
|
|
58
|
+
return fmt.Errorf("create messages table: %w", err)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if _, err := b.db.Exec(`
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
|
63
|
+
`); err != nil {
|
|
64
|
+
return fmt.Errorf("create messages role index: %w", err)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if _, err := b.db.Exec(`
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
69
|
+
`); err != nil {
|
|
70
|
+
return fmt.Errorf("create messages timestamp index: %w", err)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if _, err := b.db.Exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS summaries (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
kind TEXT,
|
|
77
|
+
content TEXT,
|
|
78
|
+
tokens INTEGER,
|
|
79
|
+
level INTEGER,
|
|
80
|
+
created_at TEXT,
|
|
81
|
+
message_ids TEXT DEFAULT '[]',
|
|
82
|
+
child_ids TEXT DEFAULT '[]',
|
|
83
|
+
parent_id TEXT DEFAULT '',
|
|
84
|
+
file_ids TEXT DEFAULT '[]'
|
|
85
|
+
);
|
|
86
|
+
`); err != nil {
|
|
87
|
+
return fmt.Errorf("create summaries table: %w", err)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if _, err := b.db.Exec(`
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_summaries_kind ON summaries(kind);
|
|
92
|
+
`); err != nil {
|
|
93
|
+
return fmt.Errorf("create summaries kind index: %w", err)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if _, err := b.db.Exec(`
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_summaries_parent_id ON summaries(parent_id);
|
|
98
|
+
`); err != nil {
|
|
99
|
+
return fmt.Errorf("create summaries parent_id index: %w", err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if _, err := b.db.Exec(`
|
|
103
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
104
|
+
id,
|
|
105
|
+
content,
|
|
106
|
+
content='messages',
|
|
107
|
+
content_rowid='rowid'
|
|
108
|
+
);
|
|
109
|
+
`); err != nil {
|
|
110
|
+
return fmt.Errorf("create messages_fts table: %w", err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if _, err := b.db.Exec(`
|
|
114
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai
|
|
115
|
+
AFTER INSERT ON messages
|
|
116
|
+
BEGIN
|
|
117
|
+
INSERT INTO messages_fts(rowid, id, content)
|
|
118
|
+
VALUES (new.rowid, new.id, new.content);
|
|
119
|
+
END;
|
|
120
|
+
`); err != nil {
|
|
121
|
+
return fmt.Errorf("create messages_ai trigger: %w", err)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func (b *SQLiteBackend) PersistMessage(msg *StoreMessage) error {
|
|
128
|
+
if msg == nil {
|
|
129
|
+
return fmt.Errorf("message is nil")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fileIDsJSON, err := marshalJSON(msg.FileIDs, "[]")
|
|
133
|
+
if err != nil {
|
|
134
|
+
return fmt.Errorf("marshal message file_ids: %w", err)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
metadataJSON, err := marshalJSON(msg.Metadata, "{}")
|
|
138
|
+
if err != nil {
|
|
139
|
+
return fmt.Errorf("marshal message metadata: %w", err)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
timestamp := msg.Timestamp
|
|
143
|
+
if timestamp.IsZero() {
|
|
144
|
+
timestamp = time.Now()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_, err = b.db.Exec(`
|
|
148
|
+
INSERT OR REPLACE INTO messages (id, role, content, tokens, timestamp, file_ids, metadata)
|
|
149
|
+
VALUES (?, ?, ?, ?, ?, ?, ?);
|
|
150
|
+
`, msg.ID, string(msg.Role), msg.Content, msg.Tokens, timestamp.Format(time.RFC3339Nano), fileIDsJSON, metadataJSON)
|
|
151
|
+
if err != nil {
|
|
152
|
+
return fmt.Errorf("persist message %s: %w", msg.ID, err)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func (b *SQLiteBackend) GetMessage(id string) (*StoreMessage, error) {
|
|
159
|
+
row := b.db.QueryRow(`
|
|
160
|
+
SELECT id, role, content, tokens, timestamp, file_ids, metadata
|
|
161
|
+
FROM messages
|
|
162
|
+
WHERE id = ?;
|
|
163
|
+
`, id)
|
|
164
|
+
|
|
165
|
+
msg, err := scanMessage(row)
|
|
166
|
+
if err == sql.ErrNoRows {
|
|
167
|
+
return nil, nil
|
|
168
|
+
}
|
|
169
|
+
if err != nil {
|
|
170
|
+
return nil, fmt.Errorf("get message %s: %w", id, err)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return msg, nil
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func (b *SQLiteBackend) GetAllMessages() ([]*StoreMessage, error) {
|
|
177
|
+
rows, err := b.db.Query(`
|
|
178
|
+
SELECT id, role, content, tokens, timestamp, file_ids, metadata
|
|
179
|
+
FROM messages
|
|
180
|
+
ORDER BY timestamp ASC;
|
|
181
|
+
`)
|
|
182
|
+
if err != nil {
|
|
183
|
+
return nil, fmt.Errorf("query all messages: %w", err)
|
|
184
|
+
}
|
|
185
|
+
defer rows.Close()
|
|
186
|
+
|
|
187
|
+
messages := make([]*StoreMessage, 0)
|
|
188
|
+
for rows.Next() {
|
|
189
|
+
msg, err := scanMessage(rows)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return nil, fmt.Errorf("scan message: %w", err)
|
|
192
|
+
}
|
|
193
|
+
messages = append(messages, msg)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if err := rows.Err(); err != nil {
|
|
197
|
+
return nil, fmt.Errorf("iterate all messages: %w", err)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return messages, nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func (b *SQLiteBackend) MessageCount() (int, error) {
|
|
204
|
+
var count int
|
|
205
|
+
if err := b.db.QueryRow(`SELECT COUNT(*) FROM messages;`).Scan(&count); err != nil {
|
|
206
|
+
return 0, fmt.Errorf("count messages: %w", err)
|
|
207
|
+
}
|
|
208
|
+
return count, nil
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func (b *SQLiteBackend) PersistSummary(node *SummaryNode) error {
|
|
212
|
+
if node == nil {
|
|
213
|
+
return fmt.Errorf("summary node is nil")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
messageIDsJSON, err := marshalJSON(node.MessageIDs, "[]")
|
|
217
|
+
if err != nil {
|
|
218
|
+
return fmt.Errorf("marshal summary message_ids: %w", err)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
childIDsJSON, err := marshalJSON(node.ChildIDs, "[]")
|
|
222
|
+
if err != nil {
|
|
223
|
+
return fmt.Errorf("marshal summary child_ids: %w", err)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fileIDsJSON, err := marshalJSON(node.FileIDs, "[]")
|
|
227
|
+
if err != nil {
|
|
228
|
+
return fmt.Errorf("marshal summary file_ids: %w", err)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
createdAt := node.CreatedAt
|
|
232
|
+
if createdAt.IsZero() {
|
|
233
|
+
createdAt = time.Now()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_, err = b.db.Exec(`
|
|
237
|
+
INSERT OR REPLACE INTO summaries (id, kind, content, tokens, level, created_at, message_ids, child_ids, parent_id, file_ids)
|
|
238
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
239
|
+
`, node.ID, string(node.Kind), node.Content, node.Tokens, node.Level, createdAt.Format(time.RFC3339Nano), messageIDsJSON, childIDsJSON, node.ParentID, fileIDsJSON)
|
|
240
|
+
if err != nil {
|
|
241
|
+
return fmt.Errorf("persist summary %s: %w", node.ID, err)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return nil
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func (b *SQLiteBackend) GetSummary(id string) (*SummaryNode, error) {
|
|
248
|
+
row := b.db.QueryRow(`
|
|
249
|
+
SELECT id, kind, content, tokens, level, created_at, message_ids, child_ids, parent_id, file_ids
|
|
250
|
+
FROM summaries
|
|
251
|
+
WHERE id = ?;
|
|
252
|
+
`, id)
|
|
253
|
+
|
|
254
|
+
summary, err := scanSummary(row)
|
|
255
|
+
if err == sql.ErrNoRows {
|
|
256
|
+
return nil, nil
|
|
257
|
+
}
|
|
258
|
+
if err != nil {
|
|
259
|
+
return nil, fmt.Errorf("get summary %s: %w", id, err)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return summary, nil
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func (b *SQLiteBackend) GetAllSummaries() ([]*SummaryNode, error) {
|
|
266
|
+
rows, err := b.db.Query(`
|
|
267
|
+
SELECT id, kind, content, tokens, level, created_at, message_ids, child_ids, parent_id, file_ids
|
|
268
|
+
FROM summaries
|
|
269
|
+
ORDER BY created_at ASC;
|
|
270
|
+
`)
|
|
271
|
+
if err != nil {
|
|
272
|
+
return nil, fmt.Errorf("query all summaries: %w", err)
|
|
273
|
+
}
|
|
274
|
+
defer rows.Close()
|
|
275
|
+
|
|
276
|
+
summaries := make([]*SummaryNode, 0)
|
|
277
|
+
for rows.Next() {
|
|
278
|
+
summary, err := scanSummary(rows)
|
|
279
|
+
if err != nil {
|
|
280
|
+
return nil, fmt.Errorf("scan summary: %w", err)
|
|
281
|
+
}
|
|
282
|
+
summaries = append(summaries, summary)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if err := rows.Err(); err != nil {
|
|
286
|
+
return nil, fmt.Errorf("iterate all summaries: %w", err)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return summaries, nil
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func (b *SQLiteBackend) UpdateSummaryParent(summaryID, parentID string) error {
|
|
293
|
+
_, err := b.db.Exec(`
|
|
294
|
+
UPDATE summaries
|
|
295
|
+
SET parent_id = ?
|
|
296
|
+
WHERE id = ?;
|
|
297
|
+
`, parentID, summaryID)
|
|
298
|
+
if err != nil {
|
|
299
|
+
return fmt.Errorf("update summary parent for %s: %w", summaryID, err)
|
|
300
|
+
}
|
|
301
|
+
return nil
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func (b *SQLiteBackend) GrepMessages(pattern string, summaryScope *string, maxResults int) ([]*StoreMessage, error) {
|
|
305
|
+
if maxResults <= 0 {
|
|
306
|
+
maxResults = 100
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
scopeSet, err := b.scopeMessageSet(summaryScope)
|
|
310
|
+
if err != nil {
|
|
311
|
+
return nil, err
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if isSimpleFTSPattern(pattern) {
|
|
315
|
+
ftsQuery := "\"" + strings.ReplaceAll(pattern, "\"", "\"\"") + "\""
|
|
316
|
+
rows, ftsErr := b.db.Query(`
|
|
317
|
+
SELECT m.id, m.role, m.content, m.tokens, m.timestamp, m.file_ids, m.metadata
|
|
318
|
+
FROM messages_fts f
|
|
319
|
+
JOIN messages m ON m.rowid = f.rowid
|
|
320
|
+
WHERE messages_fts MATCH ?
|
|
321
|
+
ORDER BY m.timestamp ASC;
|
|
322
|
+
`, ftsQuery)
|
|
323
|
+
if ftsErr == nil {
|
|
324
|
+
defer rows.Close()
|
|
325
|
+
|
|
326
|
+
results := make([]*StoreMessage, 0, maxResults)
|
|
327
|
+
for rows.Next() {
|
|
328
|
+
msg, err := scanMessage(rows)
|
|
329
|
+
if err != nil {
|
|
330
|
+
return nil, fmt.Errorf("scan fts message: %w", err)
|
|
331
|
+
}
|
|
332
|
+
if scopeSet != nil {
|
|
333
|
+
if _, ok := scopeSet[msg.ID]; !ok {
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
results = append(results, msg)
|
|
338
|
+
if len(results) >= maxResults {
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if err := rows.Err(); err != nil {
|
|
343
|
+
return nil, fmt.Errorf("iterate fts messages: %w", err)
|
|
344
|
+
}
|
|
345
|
+
return results, nil
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Fallback: filter in Go with regex over stored messages.
|
|
350
|
+
messages, err := b.GetAllMessages()
|
|
351
|
+
if err != nil {
|
|
352
|
+
return nil, err
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
regexPattern := pattern
|
|
356
|
+
if isSimpleFTSPattern(pattern) {
|
|
357
|
+
regexPattern = regexp.QuoteMeta(pattern)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
re, err := regexp.Compile(regexPattern)
|
|
361
|
+
if err != nil {
|
|
362
|
+
return nil, fmt.Errorf("compile grep regex %q: %w", pattern, err)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
results := make([]*StoreMessage, 0, maxResults)
|
|
366
|
+
for _, msg := range messages {
|
|
367
|
+
if scopeSet != nil {
|
|
368
|
+
if _, ok := scopeSet[msg.ID]; !ok {
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if !re.MatchString(msg.Content) {
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
results = append(results, msg)
|
|
376
|
+
if len(results) >= maxResults {
|
|
377
|
+
break
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return results, nil
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func (b *SQLiteBackend) Close() error {
|
|
385
|
+
if b == nil || b.db == nil {
|
|
386
|
+
return nil
|
|
387
|
+
}
|
|
388
|
+
return b.db.Close()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
type rowScanner interface {
|
|
392
|
+
Scan(dest ...interface{}) error
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
func scanMessage(scanner rowScanner) (*StoreMessage, error) {
|
|
396
|
+
var (
|
|
397
|
+
msg StoreMessage
|
|
398
|
+
role string
|
|
399
|
+
timestamp string
|
|
400
|
+
fileIDsJSON string
|
|
401
|
+
metadataJSON string
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if err := scanner.Scan(
|
|
405
|
+
&msg.ID,
|
|
406
|
+
&role,
|
|
407
|
+
&msg.Content,
|
|
408
|
+
&msg.Tokens,
|
|
409
|
+
×tamp,
|
|
410
|
+
&fileIDsJSON,
|
|
411
|
+
&metadataJSON,
|
|
412
|
+
); err != nil {
|
|
413
|
+
return nil, err
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
msg.Role = MessageRole(role)
|
|
417
|
+
parsedTime, err := parseTime(timestamp)
|
|
418
|
+
if err != nil {
|
|
419
|
+
return nil, fmt.Errorf("parse message timestamp: %w", err)
|
|
420
|
+
}
|
|
421
|
+
msg.Timestamp = parsedTime
|
|
422
|
+
|
|
423
|
+
if err := unmarshalStringSlice(fileIDsJSON, &msg.FileIDs); err != nil {
|
|
424
|
+
return nil, fmt.Errorf("unmarshal message file_ids: %w", err)
|
|
425
|
+
}
|
|
426
|
+
if err := unmarshalStringMap(metadataJSON, &msg.Metadata); err != nil {
|
|
427
|
+
return nil, fmt.Errorf("unmarshal message metadata: %w", err)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return &msg, nil
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
func scanSummary(scanner rowScanner) (*SummaryNode, error) {
|
|
434
|
+
var (
|
|
435
|
+
node SummaryNode
|
|
436
|
+
kind string
|
|
437
|
+
createdAt string
|
|
438
|
+
messageIDsJSON string
|
|
439
|
+
childIDsJSON string
|
|
440
|
+
fileIDsJSON string
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if err := scanner.Scan(
|
|
444
|
+
&node.ID,
|
|
445
|
+
&kind,
|
|
446
|
+
&node.Content,
|
|
447
|
+
&node.Tokens,
|
|
448
|
+
&node.Level,
|
|
449
|
+
&createdAt,
|
|
450
|
+
&messageIDsJSON,
|
|
451
|
+
&childIDsJSON,
|
|
452
|
+
&node.ParentID,
|
|
453
|
+
&fileIDsJSON,
|
|
454
|
+
); err != nil {
|
|
455
|
+
return nil, err
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
node.Kind = SummaryKind(kind)
|
|
459
|
+
parsedTime, err := parseTime(createdAt)
|
|
460
|
+
if err != nil {
|
|
461
|
+
return nil, fmt.Errorf("parse summary created_at: %w", err)
|
|
462
|
+
}
|
|
463
|
+
node.CreatedAt = parsedTime
|
|
464
|
+
|
|
465
|
+
if err := unmarshalStringSlice(messageIDsJSON, &node.MessageIDs); err != nil {
|
|
466
|
+
return nil, fmt.Errorf("unmarshal summary message_ids: %w", err)
|
|
467
|
+
}
|
|
468
|
+
if err := unmarshalStringSlice(childIDsJSON, &node.ChildIDs); err != nil {
|
|
469
|
+
return nil, fmt.Errorf("unmarshal summary child_ids: %w", err)
|
|
470
|
+
}
|
|
471
|
+
if err := unmarshalStringSlice(fileIDsJSON, &node.FileIDs); err != nil {
|
|
472
|
+
return nil, fmt.Errorf("unmarshal summary file_ids: %w", err)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return &node, nil
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
func marshalJSON(v interface{}, defaultJSON string) (string, error) {
|
|
479
|
+
data, err := json.Marshal(v)
|
|
480
|
+
if err != nil {
|
|
481
|
+
return "", err
|
|
482
|
+
}
|
|
483
|
+
if string(data) == "null" {
|
|
484
|
+
return defaultJSON, nil
|
|
485
|
+
}
|
|
486
|
+
return string(data), nil
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
func unmarshalStringSlice(raw string, out *[]string) error {
|
|
490
|
+
if strings.TrimSpace(raw) == "" {
|
|
491
|
+
*out = []string{}
|
|
492
|
+
return nil
|
|
493
|
+
}
|
|
494
|
+
if err := json.Unmarshal([]byte(raw), out); err != nil {
|
|
495
|
+
return err
|
|
496
|
+
}
|
|
497
|
+
if *out == nil {
|
|
498
|
+
*out = []string{}
|
|
499
|
+
}
|
|
500
|
+
return nil
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
func unmarshalStringMap(raw string, out *map[string]string) error {
|
|
504
|
+
if strings.TrimSpace(raw) == "" {
|
|
505
|
+
*out = map[string]string{}
|
|
506
|
+
return nil
|
|
507
|
+
}
|
|
508
|
+
if err := json.Unmarshal([]byte(raw), out); err != nil {
|
|
509
|
+
return err
|
|
510
|
+
}
|
|
511
|
+
if *out == nil {
|
|
512
|
+
*out = map[string]string{}
|
|
513
|
+
}
|
|
514
|
+
return nil
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
func parseTime(value string) (time.Time, error) {
|
|
518
|
+
if value == "" {
|
|
519
|
+
return time.Time{}, nil
|
|
520
|
+
}
|
|
521
|
+
if ts, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
522
|
+
return ts, nil
|
|
523
|
+
}
|
|
524
|
+
return time.Parse(time.RFC3339, value)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
func isSimpleFTSPattern(pattern string) bool {
|
|
528
|
+
if strings.TrimSpace(pattern) == "" {
|
|
529
|
+
return false
|
|
530
|
+
}
|
|
531
|
+
// If regex metacharacters appear, skip FTS and use Go regex fallback.
|
|
532
|
+
meta := regexp.MustCompile(`[\\.\^\$\|\?\*\+\(\)\{\}\[\]]`)
|
|
533
|
+
return !meta.MatchString(pattern)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
func (b *SQLiteBackend) scopeMessageSet(summaryScope *string) (map[string]struct{}, error) {
|
|
537
|
+
if summaryScope == nil || strings.TrimSpace(*summaryScope) == "" {
|
|
538
|
+
return nil, nil
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
visited := map[string]struct{}{}
|
|
542
|
+
messageSet := map[string]struct{}{}
|
|
543
|
+
|
|
544
|
+
if err := b.collectScopedMessages(*summaryScope, visited, messageSet); err != nil {
|
|
545
|
+
return nil, err
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return messageSet, nil
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func (b *SQLiteBackend) collectScopedMessages(summaryID string, visited map[string]struct{}, messageSet map[string]struct{}) error {
|
|
552
|
+
if _, ok := visited[summaryID]; ok {
|
|
553
|
+
return nil
|
|
554
|
+
}
|
|
555
|
+
visited[summaryID] = struct{}{}
|
|
556
|
+
|
|
557
|
+
summary, err := b.GetSummary(summaryID)
|
|
558
|
+
if err != nil {
|
|
559
|
+
return fmt.Errorf("collect scoped messages for %s: %w", summaryID, err)
|
|
560
|
+
}
|
|
561
|
+
if summary == nil {
|
|
562
|
+
return nil
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for _, id := range summary.MessageIDs {
|
|
566
|
+
messageSet[id] = struct{}{}
|
|
567
|
+
}
|
|
568
|
+
for _, childID := range summary.ChildIDs {
|
|
569
|
+
if err := b.collectScopedMessages(childID, visited, messageSet); err != nil {
|
|
570
|
+
return err
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return nil
|
|
575
|
+
}
|
package/go/rlm/structured.go
CHANGED
|
@@ -514,17 +514,8 @@ func generateFieldQuery(fieldName string, schema *JSONSchema) string {
|
|
|
514
514
|
|
|
515
515
|
// parseAndValidateJSON extracts JSON from response and validates against schema
|
|
516
516
|
func parseAndValidateJSON(result string, schema *JSONSchema) (map[string]interface{}, error) {
|
|
517
|
-
// Remove markdown code blocks if present
|
|
518
|
-
result =
|
|
519
|
-
if strings.HasPrefix(result, "```") {
|
|
520
|
-
// Extract content between ``` markers
|
|
521
|
-
lines := strings.Split(result, "\n")
|
|
522
|
-
if len(lines) > 2 {
|
|
523
|
-
// Remove first line (```json or ```) and last line (```)
|
|
524
|
-
result = strings.Join(lines[1:len(lines)-1], "\n")
|
|
525
|
-
result = strings.TrimSpace(result)
|
|
526
|
-
}
|
|
527
|
-
}
|
|
517
|
+
// Remove markdown code blocks if present (shared utility)
|
|
518
|
+
result = StripMarkdownCodeBlock(result)
|
|
528
519
|
|
|
529
520
|
// For non-object schemas (arrays, primitives), handle special cases
|
|
530
521
|
if schema.Type != "object" {
|
|
@@ -602,7 +593,7 @@ func parseAndValidateJSON(result string, schema *JSONSchema) (map[string]interfa
|
|
|
602
593
|
}
|
|
603
594
|
|
|
604
595
|
// If full-string parse failed, use balanced-brace extraction to find JSON objects
|
|
605
|
-
jsonCandidates :=
|
|
596
|
+
jsonCandidates := ExtractAllBalancedJSON(result)
|
|
606
597
|
|
|
607
598
|
if len(jsonCandidates) == 0 {
|
|
608
599
|
return nil, fmt.Errorf("no JSON object found in response: %s", truncateForError(result))
|
|
@@ -621,77 +612,9 @@ func parseAndValidateJSON(result string, schema *JSONSchema) (map[string]interfa
|
|
|
621
612
|
return nil, fmt.Errorf("no valid JSON object matching schema found in response")
|
|
622
613
|
}
|
|
623
614
|
|
|
624
|
-
// extractBalancedJSON
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
func extractBalancedJSON(s string) []string {
|
|
628
|
-
var results []string
|
|
629
|
-
inString := false
|
|
630
|
-
escaped := false
|
|
631
|
-
|
|
632
|
-
for i := 0; i < len(s); i++ {
|
|
633
|
-
c := s[i]
|
|
634
|
-
|
|
635
|
-
if c == '{' && !inString {
|
|
636
|
-
// Found start of a potential JSON object; track balanced braces
|
|
637
|
-
depth := 0
|
|
638
|
-
inStr := false
|
|
639
|
-
esc := false
|
|
640
|
-
j := i
|
|
641
|
-
|
|
642
|
-
for j < len(s) {
|
|
643
|
-
ch := s[j]
|
|
644
|
-
|
|
645
|
-
if esc {
|
|
646
|
-
esc = false
|
|
647
|
-
j++
|
|
648
|
-
continue
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if ch == '\\' && inStr {
|
|
652
|
-
esc = true
|
|
653
|
-
j++
|
|
654
|
-
continue
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
if ch == '"' {
|
|
658
|
-
inStr = !inStr
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
if !inStr {
|
|
662
|
-
if ch == '{' {
|
|
663
|
-
depth++
|
|
664
|
-
} else if ch == '}' {
|
|
665
|
-
depth--
|
|
666
|
-
if depth == 0 {
|
|
667
|
-
candidate := s[i : j+1]
|
|
668
|
-
results = append(results, candidate)
|
|
669
|
-
i = j // outer loop will increment past this
|
|
670
|
-
break
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
j++
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Track string state in the outer scan (for skipping { inside strings)
|
|
680
|
-
if escaped {
|
|
681
|
-
escaped = false
|
|
682
|
-
continue
|
|
683
|
-
}
|
|
684
|
-
if c == '\\' && inString {
|
|
685
|
-
escaped = true
|
|
686
|
-
continue
|
|
687
|
-
}
|
|
688
|
-
if c == '"' {
|
|
689
|
-
inString = !inString
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return results
|
|
694
|
-
}
|
|
615
|
+
// extractBalancedJSON is an alias for ExtractAllBalancedJSON (shared in json_extraction.go).
|
|
616
|
+
// Kept as package-level reference for backward compatibility with tests.
|
|
617
|
+
var extractBalancedJSON = ExtractAllBalancedJSON
|
|
695
618
|
|
|
696
619
|
// truncateForError truncates a string for use in error messages
|
|
697
620
|
func truncateForError(s string) string {
|