@loopengine-co/records 0.0.0-stage → 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Yan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # @loopengine-co/records
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [loopengine](https://github.com/loopengine-co/loopengine) ability:
4
+ named lists of records an agent keeps between runs (punch-ins, a daily
5
+ pick, what's already been done), with tools to add, find and remove
6
+ records, plus a skill on keeping them tidy. Removing records asks a
7
+ person first by default. No settings or accounts needed.
8
+
9
+ ```sh
10
+ npx loopengine add-ability @loopengine-co/records --agent <your-agent>
11
+ ```
12
+
13
+ ## What's in it
14
+
15
+ - **Tools**
16
+ - `records_add(list, records, unique_by?)`: saves one or more records
17
+ (objects of fields) to a list, created on first use. Each gets an
18
+ `id` and an `added_at` time. `unique_by` names fields that must not
19
+ repeat, e.g. `["date", "person"]`: a repeat is skipped and reported
20
+ with the existing record's id.
21
+ - `records_find(list, where?, range?, max_results?, oldest_first?)`:
22
+ records whose fields equal `where` (a list of values matches any),
23
+ and whose `range.field` is between `from` and `to` (compared as text,
24
+ so ISO dates work). Newest first.
25
+ - `records_remove(list, ids? | where?)`: removes records by id or by
26
+ fields, and returns them. Never empties a list without either.
27
+ - `records_lists()`: the agent's lists, with record counts and when
28
+ each last changed.
29
+ - **Skill**: `keeping-records`: consistent fields and dates, `unique_by`
30
+ for one-per-day rules, and answering from the records, not memory.
31
+ - **actauth rules**: adding, finding and listing are `allow`; removing is
32
+ `ask`.
33
+
34
+ ## Example: a daily verse
35
+
36
+ Two lists. Each morning a scheduled workflow finds the verses already
37
+ used, picks a new one and saves it:
38
+
39
+ ```json
40
+ records_add {"list": "verses", "records": [{"date": "2026-11-12", "reference": "Philippians 4:13", "text": "I can do all things through Christ which strengtheneth me."}], "unique_by": ["date"]}
41
+ ```
42
+
43
+ When someone in the group says they've memorised it:
44
+
45
+ ```json
46
+ records_add {"list": "memorised", "records": [{"date": "2026-11-12", "person": "Alice", "reference": "Philippians 4:13", "time": "08:40"}], "unique_by": ["date", "person"]}
47
+ ```
48
+
49
+ And in the evening's report:
50
+
51
+ ```json
52
+ records_find {"list": "memorised", "where": {"date": "2026-11-12"}}
53
+ ```
54
+
55
+ ## Where records are kept
56
+
57
+ Each list is a JSON file, `agents/<agent>/records/<list>.json`, in the
58
+ project the server runs from (on LoopEngine Cloud, the workspace's
59
+ volume, so it survives restarts and upgrades). Each agent has its own
60
+ lists. Writes replace the file in one step, and changes to a list wait
61
+ for each other, so records aren't lost when two people write at once.
62
+
63
+ A list holds up to 10,000 records, each up to 10,000 characters.
64
+
65
+ ## Permissions
66
+
67
+ | Tool | Default | Why |
68
+ | --- | --- | --- |
69
+ | `records_add`, `records_find`, `records_lists` | allow | Only the agent's own lists |
70
+ | `records_remove` | ask | Deletes what people recorded |
71
+
72
+ Change these in the agent's Permissions tab.
@@ -0,0 +1,22 @@
1
+ # Saving, finding and listing records only touch the agent's own lists,
2
+ # so they're allowed. Removing records deletes what people recorded
3
+ # (punch-ins, history), so it asks a person first.
4
+ - name: records-add-allowed
5
+ scope: "*/*"
6
+ tool: records_add
7
+ decision: allow
8
+
9
+ - name: records-find-allowed
10
+ scope: "*/*"
11
+ tool: records_find
12
+ decision: allow
13
+
14
+ - name: records-lists-allowed
15
+ scope: "*/*"
16
+ tool: records_lists
17
+ decision: allow
18
+
19
+ - name: records-remove-needs-approval
20
+ scope: "*/*"
21
+ tool: records_remove
22
+ decision: ask
@@ -0,0 +1,12 @@
1
+ {
2
+ "loopengineVersion": ">=0.1.62 <1.0.0",
3
+ "tools": [
4
+ "tools/records_add.ts",
5
+ "tools/records_find.ts",
6
+ "tools/records_remove.ts",
7
+ "tools/records_lists.ts"
8
+ ],
9
+ "skills": ["skills/keeping-records"],
10
+ "actauth": "actauth/rules.yml",
11
+ "env": []
12
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@loopengine-co/records",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "A loopengine ability: keep named lists of records (punch-ins, daily picks, what's been done) that last between runs, with tools to add, find and remove records (removing asks first by default), plus a skill on keeping them tidy.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "loopengine",
8
+ "loopengine-ability",
9
+ "records",
10
+ "storage",
11
+ "memory",
12
+ "tracking"
13
+ ],
14
+ "files": [
15
+ "loopengine.ability.json",
16
+ "tools",
17
+ "skills",
18
+ "actauth",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "homepage": "https://loopengine.co",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/loopengine-co/records.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/loopengine-co/records/issues"
32
+ }
33
+ }
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: keeping-records
3
+ description: How to use the @loopengine-co/records tools to keep lists that last between runs (punch-ins, daily picks, what's already been done), with consistent fields, no duplicates, and answers drawn from the records rather than memory.
4
+ ---
5
+
6
+ # Keeping records
7
+
8
+ The @loopengine-co/records tools keep named lists of records for this
9
+ agent, saved between runs and conversations. Use them for anything you
10
+ must remember exactly: who punched in, what was chosen each day, which
11
+ items were already handled.
12
+
13
+ ## Lists and fields
14
+
15
+ - One list per kind of thing, named plainly: `punch-ins`, `verses`,
16
+ `answered-posts`. Check `records_lists` before inventing a new name, so
17
+ the same thing doesn't end up in two lists.
18
+ - Keep the same field names and formats in every record of a list. Dates
19
+ as `YYYY-MM-DD` in the workspace's time zone, times as `HH:MM`, people
20
+ by the name the chat shows. Then `where` and `range` find them reliably.
21
+ - Save what later questions need, and link records with a shared field:
22
+ a punch-in can carry the `reference` of the verse it was for.
23
+
24
+ ## No duplicates
25
+
26
+ When something may only happen once (one punch-in per person per day,
27
+ one verse per day), pass `unique_by` to `records_add`, e.g.
28
+ `["date", "person"]`. A repeat is skipped and reported with the existing
29
+ record's id, even if two people write at the same moment. Tell the
30
+ person it was already recorded; don't add it again.
31
+
32
+ ## Read before you answer
33
+
34
+ Answer questions about the past from `records_find`, never from memory
35
+ or the chat: "who punched in today", "which verses have we used", "how
36
+ many days did Bob memorise this month" (a `range` on `date`). Before
37
+ picking something new that mustn't repeat, find what's already in the
38
+ list.
39
+
40
+ ## Removing
41
+
42
+ `records_remove` asks a person first by default. Remove only what's
43
+ clearly wrong (a punch-in by mistake, a duplicate), by id where you can.
44
+ It returns the removed records, so say what was removed.
45
+
46
+ ## Never
47
+
48
+ - Never claim something was recorded without a successful `records_add`.
49
+ - Never edit a list's file by hand or keep records anywhere else.
50
+ - Never remove records just to tidy up, or to make a report look better.
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
4
+ import type { ToolDefinition } from 'loopengine'
5
+
6
+ // ---- Shared helpers --------------------------------------------------
7
+ // Duplicated verbatim across every tool file in this ability: add-ability
8
+ // copies each tool file standalone, with no shared-module support, so
9
+ // this block is the contract between them. Keep the copies identical.
10
+ //
11
+ // Each list is one JSON file, agents/<agent>/records/<list>.json (from
12
+ // the project folder the server runs in), holding an array of records.
13
+ // Every record gets an `id` and an `added_at` time. Writes go to a
14
+ // temporary file that's then renamed over the list, so a crash never
15
+ // leaves half a file, and changes to one list wait for each other (one
16
+ // lock per list, shared by every tool file in the process).
17
+ const MAX_RECORDS = 10_000
18
+ const MAX_RECORD_BYTES = 10_000
19
+
20
+ type StoredRecord = { id: string; added_at: string } & Record<string, unknown>
21
+
22
+ function listName(action: string, value: unknown): string {
23
+ const name = String(value ?? '').trim()
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) {
25
+ throw new Error(`${action}: list must be a name of letters, digits, - and _ (up to 64), e.g. "punch-ins"`)
26
+ }
27
+ return name
28
+ }
29
+
30
+ function recordsDir(agentName: string): string {
31
+ if (!/^[A-Za-z0-9_-]+$/.test(agentName)) throw new Error(`records: unexpected agent name "${agentName}"`)
32
+ return join(process.cwd(), 'agents', agentName, 'records')
33
+ }
34
+
35
+ function readList(agentName: string, list: string): StoredRecord[] {
36
+ const file = join(recordsDir(agentName), `${list}.json`)
37
+ if (!existsSync(file)) return []
38
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
39
+ if (!Array.isArray(parsed)) throw new Error(`records: ${file} isn't a list of records`)
40
+ return parsed as StoredRecord[]
41
+ }
42
+
43
+ function writeList(agentName: string, list: string, records: StoredRecord[]): void {
44
+ const dir = recordsDir(agentName)
45
+ mkdirSync(dir, { recursive: true })
46
+ const file = join(dir, `${list}.json`)
47
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
48
+ writeFileSync(temp, `${JSON.stringify(records, null, 2)}\n`)
49
+ renameSync(temp, file)
50
+ }
51
+
52
+ function listNames(agentName: string): string[] {
53
+ const dir = recordsDir(agentName)
54
+ try {
55
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort()
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ // One queue per list, on globalThis so the separately loaded tool files
62
+ // share it.
63
+ const LOCKS_KEY = Symbol.for('loopengine-records.locks')
64
+ const locks: Map<string, Promise<unknown>> = ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] as Map<string, Promise<unknown>> | undefined) ??
65
+ ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] = new Map<string, Promise<unknown>>())
66
+
67
+ async function withList<T>(agentName: string, list: string, change: () => T): Promise<T> {
68
+ const key = `${agentName}/${list}`
69
+ const previous = locks.get(key) ?? Promise.resolve()
70
+ const run = previous.catch(() => undefined).then(change)
71
+ locks.set(key, run)
72
+ try {
73
+ return await run
74
+ } finally {
75
+ if (locks.get(key) === run) locks.delete(key)
76
+ }
77
+ }
78
+
79
+ // `where`: field -> value (equal) or a list of values (any of them).
80
+ function matches(record: Record<string, unknown>, where: Record<string, unknown>): boolean {
81
+ return Object.entries(where).every(([field, wanted]) => {
82
+ const value = record[field]
83
+ return Array.isArray(wanted) ? wanted.some((w) => sameValue(value, w)) : sameValue(value, wanted)
84
+ })
85
+ }
86
+
87
+ function sameValue(a: unknown, b: unknown): boolean {
88
+ return a === b || (a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b))
89
+ }
90
+
91
+ function whereOf(action: string, value: unknown): Record<string, unknown> {
92
+ if (value === undefined || value === null) return {}
93
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${action}: where must be an object of field: value, e.g. {"date": "2026-11-12"}`)
94
+ return value as Record<string, unknown>
95
+ }
96
+ // ---- End of shared helpers -------------------------------------------
97
+
98
+ export const recordsAdd: ToolDefinition = {
99
+ name: 'records_add',
100
+ description:
101
+ 'Save one or more records to a named list, e.g. today\'s verse to "verses", or a punch-in to "punch-ins". A record is an object of fields (text, numbers, true/false), such as {"date": "2026-11-12", "person": "Alice"}. Each gets an id and an added_at time. unique_by names fields that must not repeat: a record whose values for them match one already saved (or one earlier in the same call) is skipped and reported, e.g. unique_by ["date", "person"] for one punch-in per person per day. Lists are created on first use and kept between runs.',
102
+ input_schema: {
103
+ type: 'object',
104
+ properties: {
105
+ list: { type: 'string', description: 'The list\'s name: letters, digits, - and _, e.g. "punch-ins".' },
106
+ records: { type: 'array', items: { type: 'object' }, description: 'The records to save, each an object of fields.' },
107
+ unique_by: { type: 'array', items: { type: 'string' }, description: 'Fields whose values together must not repeat in the list.' },
108
+ },
109
+ required: ['list', 'records'],
110
+ },
111
+ execute: async (input, ctx) => {
112
+ const action = 'records_add'
113
+ const list = listName(action, input.list)
114
+ const incoming = Array.isArray(input.records) ? input.records : input.records && typeof input.records === 'object' ? [input.records] : []
115
+ if (!incoming.length) throw new Error(`${action}: records must be a list of one or more objects`)
116
+ if (incoming.length > 500) throw new Error(`${action}: up to 500 records at a time`)
117
+ const uniqueBy = Array.isArray(input.unique_by) ? input.unique_by.map(String).filter(Boolean) : []
118
+ const fresh = incoming.map((r, i) => {
119
+ if (!r || typeof r !== 'object' || Array.isArray(r)) throw new Error(`${action}: record ${i + 1} isn't an object of fields`)
120
+ const fields = Object.fromEntries(Object.entries(r as Record<string, unknown>).filter(([k]) => k !== 'id' && k !== 'added_at'))
121
+ if (!Object.keys(fields).length) throw new Error(`${action}: record ${i + 1} has no fields`)
122
+ if (JSON.stringify(fields).length > MAX_RECORD_BYTES) throw new Error(`${action}: record ${i + 1} is over ${MAX_RECORD_BYTES} characters`)
123
+ for (const field of uniqueBy) if (!(field in fields)) throw new Error(`${action}: record ${i + 1} has no "${field}", which unique_by needs`)
124
+ return fields
125
+ })
126
+ return withList(ctx.agentName, list, () => {
127
+ const records = readList(ctx.agentName, list)
128
+ const added: StoredRecord[] = []
129
+ const skipped: { record: Record<string, unknown>; existing_id: string }[] = []
130
+ for (const fields of fresh) {
131
+ const key = Object.fromEntries(uniqueBy.map((f) => [f, fields[f]]))
132
+ const existing = uniqueBy.length ? records.find((r) => matches(r, key)) : undefined
133
+ if (existing) {
134
+ skipped.push({ record: fields, existing_id: existing.id })
135
+ continue
136
+ }
137
+ const record = { id: randomBytes(5).toString('hex'), added_at: new Date().toISOString(), ...fields } as StoredRecord
138
+ records.push(record)
139
+ added.push(record)
140
+ }
141
+ if (records.length > MAX_RECORDS) throw new Error(`${action}: "${list}" would hold over ${MAX_RECORDS} records; remove old ones first`)
142
+ if (added.length) writeList(ctx.agentName, list, records)
143
+ return JSON.stringify({ list, added, ...(skipped.length ? { skipped } : {}), total: records.length })
144
+ })
145
+ },
146
+ }
@@ -0,0 +1,137 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
4
+ import type { ToolDefinition } from 'loopengine'
5
+
6
+ // ---- Shared helpers --------------------------------------------------
7
+ // Duplicated verbatim across every tool file in this ability: add-ability
8
+ // copies each tool file standalone, with no shared-module support, so
9
+ // this block is the contract between them. Keep the copies identical.
10
+ //
11
+ // Each list is one JSON file, agents/<agent>/records/<list>.json (from
12
+ // the project folder the server runs in), holding an array of records.
13
+ // Every record gets an `id` and an `added_at` time. Writes go to a
14
+ // temporary file that's then renamed over the list, so a crash never
15
+ // leaves half a file, and changes to one list wait for each other (one
16
+ // lock per list, shared by every tool file in the process).
17
+ const MAX_RECORDS = 10_000
18
+ const MAX_RECORD_BYTES = 10_000
19
+
20
+ type StoredRecord = { id: string; added_at: string } & Record<string, unknown>
21
+
22
+ function listName(action: string, value: unknown): string {
23
+ const name = String(value ?? '').trim()
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) {
25
+ throw new Error(`${action}: list must be a name of letters, digits, - and _ (up to 64), e.g. "punch-ins"`)
26
+ }
27
+ return name
28
+ }
29
+
30
+ function recordsDir(agentName: string): string {
31
+ if (!/^[A-Za-z0-9_-]+$/.test(agentName)) throw new Error(`records: unexpected agent name "${agentName}"`)
32
+ return join(process.cwd(), 'agents', agentName, 'records')
33
+ }
34
+
35
+ function readList(agentName: string, list: string): StoredRecord[] {
36
+ const file = join(recordsDir(agentName), `${list}.json`)
37
+ if (!existsSync(file)) return []
38
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
39
+ if (!Array.isArray(parsed)) throw new Error(`records: ${file} isn't a list of records`)
40
+ return parsed as StoredRecord[]
41
+ }
42
+
43
+ function writeList(agentName: string, list: string, records: StoredRecord[]): void {
44
+ const dir = recordsDir(agentName)
45
+ mkdirSync(dir, { recursive: true })
46
+ const file = join(dir, `${list}.json`)
47
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
48
+ writeFileSync(temp, `${JSON.stringify(records, null, 2)}\n`)
49
+ renameSync(temp, file)
50
+ }
51
+
52
+ function listNames(agentName: string): string[] {
53
+ const dir = recordsDir(agentName)
54
+ try {
55
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort()
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ // One queue per list, on globalThis so the separately loaded tool files
62
+ // share it.
63
+ const LOCKS_KEY = Symbol.for('loopengine-records.locks')
64
+ const locks: Map<string, Promise<unknown>> = ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] as Map<string, Promise<unknown>> | undefined) ??
65
+ ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] = new Map<string, Promise<unknown>>())
66
+
67
+ async function withList<T>(agentName: string, list: string, change: () => T): Promise<T> {
68
+ const key = `${agentName}/${list}`
69
+ const previous = locks.get(key) ?? Promise.resolve()
70
+ const run = previous.catch(() => undefined).then(change)
71
+ locks.set(key, run)
72
+ try {
73
+ return await run
74
+ } finally {
75
+ if (locks.get(key) === run) locks.delete(key)
76
+ }
77
+ }
78
+
79
+ // `where`: field -> value (equal) or a list of values (any of them).
80
+ function matches(record: Record<string, unknown>, where: Record<string, unknown>): boolean {
81
+ return Object.entries(where).every(([field, wanted]) => {
82
+ const value = record[field]
83
+ return Array.isArray(wanted) ? wanted.some((w) => sameValue(value, w)) : sameValue(value, wanted)
84
+ })
85
+ }
86
+
87
+ function sameValue(a: unknown, b: unknown): boolean {
88
+ return a === b || (a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b))
89
+ }
90
+
91
+ function whereOf(action: string, value: unknown): Record<string, unknown> {
92
+ if (value === undefined || value === null) return {}
93
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${action}: where must be an object of field: value, e.g. {"date": "2026-11-12"}`)
94
+ return value as Record<string, unknown>
95
+ }
96
+ // ---- End of shared helpers -------------------------------------------
97
+
98
+ export const recordsFind: ToolDefinition = {
99
+ name: 'records_find',
100
+ description:
101
+ 'Find records in a list: those whose fields equal the values in where (a list of values matches any of them), and, with range, those whose field is between from and to (compared as text, so ISO dates like "2026-11-01" work). Newest first. Returns how many match and the records. With no where or range, the latest records. records_lists shows the lists there are.',
102
+ input_schema: {
103
+ type: 'object',
104
+ properties: {
105
+ list: { type: 'string', description: 'The list\'s name.' },
106
+ where: { type: 'object', description: 'Field: value to match, e.g. {"date": "2026-11-12"} or {"person": ["Alice", "Bob"]}.' },
107
+ range: {
108
+ type: 'object',
109
+ description: 'A field between two values, inclusive, e.g. {"field": "date", "from": "2026-11-01", "to": "2026-11-30"}. Either end can be left out.',
110
+ properties: { field: { type: 'string' }, from: { type: 'string' }, to: { type: 'string' } },
111
+ required: ['field'],
112
+ },
113
+ max_results: { type: 'number', description: 'How many records to return, 1 to 1000. Default 100.' },
114
+ oldest_first: { type: 'boolean', description: 'Oldest first instead of newest first.' },
115
+ },
116
+ required: ['list'],
117
+ },
118
+ execute: async (input, ctx) => {
119
+ const action = 'records_find'
120
+ const list = listName(action, input.list)
121
+ const where = whereOf(action, input.where)
122
+ const range = input.range && typeof input.range === 'object' ? (input.range as { field?: unknown; from?: unknown; to?: unknown }) : undefined
123
+ if (range && !range.field) throw new Error(`${action}: range needs a field`)
124
+ const inRange = (r: Record<string, unknown>) => {
125
+ if (!range) return true
126
+ const value = r[String(range.field)]
127
+ if (value === undefined || value === null) return false
128
+ const text = String(value)
129
+ return (range.from === undefined || text >= String(range.from)) && (range.to === undefined || text <= String(range.to))
130
+ }
131
+ const found = readList(ctx.agentName, list).filter((r) => matches(r, where) && inRange(r))
132
+ if (!input.oldest_first) found.reverse()
133
+ const limit = Math.max(1, Math.min(1000, Math.floor(Number(input.max_results) || 100)))
134
+ return JSON.stringify({ list, matched: found.length, records: found.slice(0, limit) })
135
+ },
136
+ safe: true,
137
+ }
@@ -0,0 +1,114 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
4
+ import type { ToolDefinition } from 'loopengine'
5
+
6
+ // ---- Shared helpers --------------------------------------------------
7
+ // Duplicated verbatim across every tool file in this ability: add-ability
8
+ // copies each tool file standalone, with no shared-module support, so
9
+ // this block is the contract between them. Keep the copies identical.
10
+ //
11
+ // Each list is one JSON file, agents/<agent>/records/<list>.json (from
12
+ // the project folder the server runs in), holding an array of records.
13
+ // Every record gets an `id` and an `added_at` time. Writes go to a
14
+ // temporary file that's then renamed over the list, so a crash never
15
+ // leaves half a file, and changes to one list wait for each other (one
16
+ // lock per list, shared by every tool file in the process).
17
+ const MAX_RECORDS = 10_000
18
+ const MAX_RECORD_BYTES = 10_000
19
+
20
+ type StoredRecord = { id: string; added_at: string } & Record<string, unknown>
21
+
22
+ function listName(action: string, value: unknown): string {
23
+ const name = String(value ?? '').trim()
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) {
25
+ throw new Error(`${action}: list must be a name of letters, digits, - and _ (up to 64), e.g. "punch-ins"`)
26
+ }
27
+ return name
28
+ }
29
+
30
+ function recordsDir(agentName: string): string {
31
+ if (!/^[A-Za-z0-9_-]+$/.test(agentName)) throw new Error(`records: unexpected agent name "${agentName}"`)
32
+ return join(process.cwd(), 'agents', agentName, 'records')
33
+ }
34
+
35
+ function readList(agentName: string, list: string): StoredRecord[] {
36
+ const file = join(recordsDir(agentName), `${list}.json`)
37
+ if (!existsSync(file)) return []
38
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
39
+ if (!Array.isArray(parsed)) throw new Error(`records: ${file} isn't a list of records`)
40
+ return parsed as StoredRecord[]
41
+ }
42
+
43
+ function writeList(agentName: string, list: string, records: StoredRecord[]): void {
44
+ const dir = recordsDir(agentName)
45
+ mkdirSync(dir, { recursive: true })
46
+ const file = join(dir, `${list}.json`)
47
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
48
+ writeFileSync(temp, `${JSON.stringify(records, null, 2)}\n`)
49
+ renameSync(temp, file)
50
+ }
51
+
52
+ function listNames(agentName: string): string[] {
53
+ const dir = recordsDir(agentName)
54
+ try {
55
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort()
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ // One queue per list, on globalThis so the separately loaded tool files
62
+ // share it.
63
+ const LOCKS_KEY = Symbol.for('loopengine-records.locks')
64
+ const locks: Map<string, Promise<unknown>> = ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] as Map<string, Promise<unknown>> | undefined) ??
65
+ ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] = new Map<string, Promise<unknown>>())
66
+
67
+ async function withList<T>(agentName: string, list: string, change: () => T): Promise<T> {
68
+ const key = `${agentName}/${list}`
69
+ const previous = locks.get(key) ?? Promise.resolve()
70
+ const run = previous.catch(() => undefined).then(change)
71
+ locks.set(key, run)
72
+ try {
73
+ return await run
74
+ } finally {
75
+ if (locks.get(key) === run) locks.delete(key)
76
+ }
77
+ }
78
+
79
+ // `where`: field -> value (equal) or a list of values (any of them).
80
+ function matches(record: Record<string, unknown>, where: Record<string, unknown>): boolean {
81
+ return Object.entries(where).every(([field, wanted]) => {
82
+ const value = record[field]
83
+ return Array.isArray(wanted) ? wanted.some((w) => sameValue(value, w)) : sameValue(value, wanted)
84
+ })
85
+ }
86
+
87
+ function sameValue(a: unknown, b: unknown): boolean {
88
+ return a === b || (a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b))
89
+ }
90
+
91
+ function whereOf(action: string, value: unknown): Record<string, unknown> {
92
+ if (value === undefined || value === null) return {}
93
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${action}: where must be an object of field: value, e.g. {"date": "2026-11-12"}`)
94
+ return value as Record<string, unknown>
95
+ }
96
+ // ---- End of shared helpers -------------------------------------------
97
+
98
+ export const recordsLists: ToolDefinition = {
99
+ name: 'records_lists',
100
+ description: 'The lists this agent keeps, each with how many records it holds and when the last one was added.',
101
+ input_schema: { type: 'object', properties: {} },
102
+ execute: async (_input, ctx) => {
103
+ const lists = listNames(ctx.agentName).map((name) => {
104
+ try {
105
+ const records = readList(ctx.agentName, name)
106
+ return { list: name, records: records.length, last_added_at: records.at(-1)?.added_at ?? null }
107
+ } catch (err) {
108
+ return { list: name, error: err instanceof Error ? err.message : String(err) }
109
+ }
110
+ })
111
+ return JSON.stringify({ lists })
112
+ },
113
+ safe: true,
114
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
4
+ import type { ToolDefinition } from 'loopengine'
5
+
6
+ // ---- Shared helpers --------------------------------------------------
7
+ // Duplicated verbatim across every tool file in this ability: add-ability
8
+ // copies each tool file standalone, with no shared-module support, so
9
+ // this block is the contract between them. Keep the copies identical.
10
+ //
11
+ // Each list is one JSON file, agents/<agent>/records/<list>.json (from
12
+ // the project folder the server runs in), holding an array of records.
13
+ // Every record gets an `id` and an `added_at` time. Writes go to a
14
+ // temporary file that's then renamed over the list, so a crash never
15
+ // leaves half a file, and changes to one list wait for each other (one
16
+ // lock per list, shared by every tool file in the process).
17
+ const MAX_RECORDS = 10_000
18
+ const MAX_RECORD_BYTES = 10_000
19
+
20
+ type StoredRecord = { id: string; added_at: string } & Record<string, unknown>
21
+
22
+ function listName(action: string, value: unknown): string {
23
+ const name = String(value ?? '').trim()
24
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) {
25
+ throw new Error(`${action}: list must be a name of letters, digits, - and _ (up to 64), e.g. "punch-ins"`)
26
+ }
27
+ return name
28
+ }
29
+
30
+ function recordsDir(agentName: string): string {
31
+ if (!/^[A-Za-z0-9_-]+$/.test(agentName)) throw new Error(`records: unexpected agent name "${agentName}"`)
32
+ return join(process.cwd(), 'agents', agentName, 'records')
33
+ }
34
+
35
+ function readList(agentName: string, list: string): StoredRecord[] {
36
+ const file = join(recordsDir(agentName), `${list}.json`)
37
+ if (!existsSync(file)) return []
38
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown
39
+ if (!Array.isArray(parsed)) throw new Error(`records: ${file} isn't a list of records`)
40
+ return parsed as StoredRecord[]
41
+ }
42
+
43
+ function writeList(agentName: string, list: string, records: StoredRecord[]): void {
44
+ const dir = recordsDir(agentName)
45
+ mkdirSync(dir, { recursive: true })
46
+ const file = join(dir, `${list}.json`)
47
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
48
+ writeFileSync(temp, `${JSON.stringify(records, null, 2)}\n`)
49
+ renameSync(temp, file)
50
+ }
51
+
52
+ function listNames(agentName: string): string[] {
53
+ const dir = recordsDir(agentName)
54
+ try {
55
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort()
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ // One queue per list, on globalThis so the separately loaded tool files
62
+ // share it.
63
+ const LOCKS_KEY = Symbol.for('loopengine-records.locks')
64
+ const locks: Map<string, Promise<unknown>> = ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] as Map<string, Promise<unknown>> | undefined) ??
65
+ ((globalThis as Record<symbol, unknown>)[LOCKS_KEY] = new Map<string, Promise<unknown>>())
66
+
67
+ async function withList<T>(agentName: string, list: string, change: () => T): Promise<T> {
68
+ const key = `${agentName}/${list}`
69
+ const previous = locks.get(key) ?? Promise.resolve()
70
+ const run = previous.catch(() => undefined).then(change)
71
+ locks.set(key, run)
72
+ try {
73
+ return await run
74
+ } finally {
75
+ if (locks.get(key) === run) locks.delete(key)
76
+ }
77
+ }
78
+
79
+ // `where`: field -> value (equal) or a list of values (any of them).
80
+ function matches(record: Record<string, unknown>, where: Record<string, unknown>): boolean {
81
+ return Object.entries(where).every(([field, wanted]) => {
82
+ const value = record[field]
83
+ return Array.isArray(wanted) ? wanted.some((w) => sameValue(value, w)) : sameValue(value, wanted)
84
+ })
85
+ }
86
+
87
+ function sameValue(a: unknown, b: unknown): boolean {
88
+ return a === b || (a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b))
89
+ }
90
+
91
+ function whereOf(action: string, value: unknown): Record<string, unknown> {
92
+ if (value === undefined || value === null) return {}
93
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${action}: where must be an object of field: value, e.g. {"date": "2026-11-12"}`)
94
+ return value as Record<string, unknown>
95
+ }
96
+ // ---- End of shared helpers -------------------------------------------
97
+
98
+ export const recordsRemove: ToolDefinition = {
99
+ name: 'records_remove',
100
+ description:
101
+ 'Remove records from a list: by their ids, or every record whose fields equal the values in where. Give ids or where, not neither: it never empties a list by itself. Returns the removed records, so they can be added back.',
102
+ input_schema: {
103
+ type: 'object',
104
+ properties: {
105
+ list: { type: 'string', description: 'The list\'s name.' },
106
+ ids: { type: 'array', items: { type: 'string' }, description: 'The ids of the records to remove.' },
107
+ where: { type: 'object', description: 'Field: value; every matching record is removed, e.g. {"date": "2026-11-12", "person": "Alice"}.' },
108
+ },
109
+ required: ['list'],
110
+ },
111
+ execute: async (input, ctx) => {
112
+ const action = 'records_remove'
113
+ const list = listName(action, input.list)
114
+ const ids = Array.isArray(input.ids) ? input.ids.map(String) : []
115
+ const where = whereOf(action, input.where)
116
+ if (!ids.length && !Object.keys(where).length) throw new Error(`${action}: give ids or where for the records to remove`)
117
+ return withList(ctx.agentName, list, () => {
118
+ const records = readList(ctx.agentName, list)
119
+ const removed = records.filter((r) => (ids.length ? ids.includes(r.id) : true) && matches(r, where))
120
+ if (removed.length) writeList(ctx.agentName, list, records.filter((r) => !removed.includes(r)))
121
+ return JSON.stringify({ list, removed, total: records.length - removed.length })
122
+ })
123
+ },
124
+ }