talon-agent 4.3.2 → 4.4.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/package.json +1 -1
- package/src/app.ts +1 -1
- package/src/backend/claude-sdk/doctor.ts +1 -1
- package/src/backend/claude-sdk/state.ts +1 -1
- package/src/backend/codex/doctor.ts +1 -1
- package/src/backend/codex/init.ts +1 -1
- package/src/backend/codex/state.ts +1 -1
- package/src/backend/openai-agents/doctor.ts +1 -1
- package/src/backend/openai-agents/init.ts +1 -1
- package/src/backend/openai-agents/state.ts +1 -1
- package/src/backend/remote-server/factory.ts +1 -1
- package/src/backend/remote-server/mcp.ts +1 -1
- package/src/backend/remote-server/server-bindings.ts +1 -1
- package/src/backend/remote-server/state.ts +1 -1
- package/src/backend/shared/system-prompt.ts +1 -1
- package/src/bootstrap.ts +3 -3
- package/src/cli/chat.ts +1 -1
- package/src/cli/config.ts +2 -2
- package/src/cli/doctor.ts +1 -1
- package/src/cli/index.ts +8 -0
- package/src/cli/memory.ts +166 -0
- package/src/cli/setup.ts +1 -1
- package/src/core/agent-runtime/backend-registry.ts +2 -2
- package/src/core/agent-runtime/model-ref.ts +2 -2
- package/src/core/auth/expiry-monitor.ts +1 -1
- package/src/{util/config.ts → core/config/index.ts} +8 -8
- package/src/core/{doctor.ts → doctor/index.ts} +11 -11
- package/src/core/engine/backend-controller/legacy.ts +1 -1
- package/src/core/engine/backend-controller/pool.ts +1 -1
- package/src/core/engine/backend-controller/rebind.ts +1 -1
- package/src/core/engine/backend-controller/state.ts +1 -1
- package/src/core/engine/gateway-actions/plugins.ts +1 -1
- package/src/core/engine/model-audit.ts +1 -1
- package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
- package/src/core/frontend-runtime/capabilities.ts +1 -1
- package/src/core/models/active-model.ts +1 -1
- package/src/core/plugin/builtins.ts +2 -2
- package/src/core/plugin/manage.ts +1 -1
- package/src/core/plugin/native-runtimes.ts +1 -1
- package/src/frontend/discord/admin.ts +1 -1
- package/src/frontend/discord/callbacks/components/index.ts +1 -1
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/callbacks/components/types.ts +1 -1
- package/src/frontend/discord/callbacks/modals.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +2 -2
- package/src/frontend/discord/commands/definitions.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/commands/router.ts +1 -1
- package/src/frontend/discord/commands/session.ts +1 -1
- package/src/frontend/discord/commands/settings.ts +1 -1
- package/src/frontend/discord/handlers/delivery.ts +1 -1
- package/src/frontend/discord/handlers/messages.ts +1 -1
- package/src/frontend/discord/handlers/queue.ts +1 -1
- package/src/frontend/discord/handlers/state.ts +1 -1
- package/src/frontend/discord/index.ts +1 -1
- package/src/frontend/discord/middleware.ts +1 -1
- package/src/frontend/discord/render.ts +1 -1
- package/src/frontend/discord/runtime.ts +1 -1
- package/src/frontend/native/extensions.ts +4 -1
- package/src/frontend/native/index.ts +1 -1
- package/src/frontend/native/runtime.ts +1 -1
- package/src/frontend/native/settings.ts +1 -1
- package/src/frontend/shared/model-commands.ts +1 -1
- package/src/frontend/shared/plan-usage-report.ts +1 -1
- package/src/frontend/shared/reasoning-levels.ts +1 -1
- package/src/frontend/shared/session-status.ts +1 -1
- package/src/frontend/teams/index.ts +1 -1
- package/src/frontend/teams/runtime.ts +1 -1
- package/src/frontend/telegram/actions/chat-info.ts +1 -1
- package/src/frontend/telegram/actions/coerce.ts +15 -0
- package/src/frontend/telegram/actions/index.ts +2 -2
- package/src/frontend/telegram/actions/media.ts +1 -1
- package/src/frontend/telegram/actions/messaging.ts +3 -4
- package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
- package/src/frontend/telegram/actions/moderation/index.ts +74 -0
- package/src/frontend/telegram/actions/moderation/members.ts +77 -0
- package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
- package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
- package/src/frontend/telegram/actions/moderation/types.ts +16 -0
- package/src/frontend/telegram/actions/rich-messages.ts +51 -0
- package/src/frontend/telegram/actions/{shared.ts → send.ts} +9 -59
- package/src/frontend/telegram/admin/background.ts +66 -0
- package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
- package/src/frontend/telegram/admin/health.ts +81 -0
- package/src/frontend/telegram/admin/sessions.ts +105 -0
- package/src/frontend/telegram/admin.ts +57 -262
- package/src/frontend/telegram/callbacks/auth.ts +1 -1
- package/src/frontend/telegram/callbacks/effort.ts +1 -1
- package/src/frontend/telegram/callbacks/index.ts +3 -3
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
- package/src/frontend/telegram/callbacks/model/control.ts +29 -0
- package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
- package/src/frontend/telegram/callbacks/model/select.ts +100 -0
- package/src/frontend/telegram/callbacks/model/types.ts +39 -0
- package/src/frontend/telegram/callbacks/model/views.ts +116 -0
- package/src/frontend/telegram/callbacks/model.ts +61 -331
- package/src/frontend/telegram/callbacks/pulse.ts +1 -1
- package/src/frontend/telegram/callbacks/{shared.ts → query.ts} +3 -2
- package/src/frontend/telegram/callbacks/settings.ts +1 -1
- package/src/frontend/telegram/callbacks/whatsapp.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +108 -77
- package/src/frontend/telegram/commands/index.ts +1 -1
- package/src/frontend/telegram/commands/settings.ts +65 -43
- package/src/frontend/telegram/commands/state.ts +1 -1
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
- package/src/frontend/telegram/handlers/context.ts +1 -1
- package/src/frontend/telegram/handlers/delivery.ts +2 -2
- package/src/frontend/telegram/handlers/messages.ts +1 -1
- package/src/frontend/telegram/handlers/queue.ts +1 -1
- package/src/frontend/telegram/handlers/state.ts +1 -1
- package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
- package/src/frontend/telegram/index.ts +1 -1
- package/src/frontend/telegram/middleware.ts +167 -202
- package/src/frontend/telegram/model-menu.ts +1 -1
- package/src/frontend/terminal/builtins/context.ts +184 -0
- package/src/frontend/terminal/builtins/help.ts +35 -0
- package/src/frontend/terminal/builtins/index.ts +23 -0
- package/src/frontend/terminal/builtins/model.ts +198 -0
- package/src/frontend/terminal/builtins/session.ts +101 -0
- package/src/frontend/terminal/builtins/status.ts +202 -0
- package/src/frontend/terminal/command-registry.ts +80 -0
- package/src/frontend/terminal/commands.ts +11 -674
- package/src/frontend/terminal/index.ts +1 -1
- package/src/frontend/terminal/input/history.ts +64 -0
- package/src/frontend/terminal/input/keys.ts +143 -0
- package/src/frontend/terminal/input/parts.ts +33 -0
- package/src/frontend/terminal/input/paste.ts +53 -0
- package/src/frontend/terminal/input/render.ts +42 -0
- package/src/frontend/terminal/input/state.ts +60 -0
- package/src/frontend/terminal/input.ts +27 -342
- package/src/frontend/whatsapp/connection.ts +1 -1
- package/src/frontend/whatsapp/index.ts +2 -2
- package/src/frontend/whatsapp/pairing-service.ts +1 -1
- package/src/frontend/whatsapp/runtime.ts +1 -1
- package/src/plugins/github/provision.ts +1 -1
- package/src/plugins/mempalace/provision.ts +1 -1
- package/src/plugins/playwright/provision.ts +1 -1
- package/src/storage/memory.ts +505 -0
- package/src/storage/repositories/memory-repo.ts +308 -0
- package/src/storage/sql/memory.sql +95 -0
- package/src/storage/sql/schema.sql +81 -0
- package/src/storage/sql/statements.generated.ts +146 -0
- package/src/frontend/telegram/actions/moderation.ts +0 -255
- /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
- /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed memory store — one row per claim, with FTS5 retrieval and a
|
|
3
|
+
* full audit trail (docs/memory-persona-plan.md §3.1–3.3).
|
|
4
|
+
*
|
|
5
|
+
* Three ideas shape the API:
|
|
6
|
+
*
|
|
7
|
+
* - **Kinds are lifecycles, not labels.** `directive` is durable human
|
|
8
|
+
* intent, `fact` is durable and supersedable, `state` is keyed,
|
|
9
|
+
* `episode` decays fast, `relationship` / `reflection` are the
|
|
10
|
+
* persona layer.
|
|
11
|
+
* - **Keyed state replaces.** `replaceStateKey("heartbeat.health", …)`
|
|
12
|
+
* supersedes the live row for that key instead of appending another
|
|
13
|
+
* dated section — the fix for accretion.
|
|
14
|
+
* - **Nothing is deleted.** A superseded row keeps its id and points at
|
|
15
|
+
* its replacement; a dropped row keeps its id and gets a
|
|
16
|
+
* `dropped_at` stamp (the graveyard). Both stay readable by id, so
|
|
17
|
+
* every change is diffable and revertible, and every mutation writes
|
|
18
|
+
* a `memory_history` row.
|
|
19
|
+
*
|
|
20
|
+
* SQLite-backed (see repositories/memory-repo.ts for the statements;
|
|
21
|
+
* this module holds the domain API, validation and the transactional
|
|
22
|
+
* lifecycle rules — no SQL here). Every write runs inside
|
|
23
|
+
* `inTransaction`, so a row and its audit entry land together or not
|
|
24
|
+
* at all.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ftsQuote } from "../native/sqlguard.js";
|
|
28
|
+
import { inTransaction } from "./db.js";
|
|
29
|
+
import * as repo from "./repositories/memory-repo.js";
|
|
30
|
+
|
|
31
|
+
export type {
|
|
32
|
+
MemoryHistoryRow,
|
|
33
|
+
MemoryInput,
|
|
34
|
+
MemoryKind,
|
|
35
|
+
MemoryRow,
|
|
36
|
+
MemorySource,
|
|
37
|
+
MemoryTrust,
|
|
38
|
+
} from "./repositories/memory-repo.js";
|
|
39
|
+
import type {
|
|
40
|
+
MemoryHistoryRow,
|
|
41
|
+
MemoryInput,
|
|
42
|
+
MemoryKind,
|
|
43
|
+
MemoryOp,
|
|
44
|
+
MemoryRow,
|
|
45
|
+
MemorySource,
|
|
46
|
+
MemoryTrust,
|
|
47
|
+
} from "./repositories/memory-repo.js";
|
|
48
|
+
|
|
49
|
+
export const MEMORY_KINDS: readonly MemoryKind[] = [
|
|
50
|
+
"directive",
|
|
51
|
+
"fact",
|
|
52
|
+
"state",
|
|
53
|
+
"episode",
|
|
54
|
+
"relationship",
|
|
55
|
+
"reflection",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const MEMORY_TRUSTS: readonly MemoryTrust[] = [
|
|
59
|
+
"operator",
|
|
60
|
+
"agent",
|
|
61
|
+
"user_claim",
|
|
62
|
+
"group_chat",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Trust tiers that can never be pinned: a claim someone made about
|
|
67
|
+
* themselves, or something overheard in a group, must not be promoted
|
|
68
|
+
* to the never-truncated tier (plan §5).
|
|
69
|
+
*/
|
|
70
|
+
const UNPINNABLE_TRUSTS: readonly MemoryTrust[] = ["user_claim", "group_chat"];
|
|
71
|
+
|
|
72
|
+
export const MAX_TEXT_LENGTH = 4_000;
|
|
73
|
+
export const MAX_SUBJECT_LENGTH = 200;
|
|
74
|
+
const MAX_KEY_LENGTH = 100;
|
|
75
|
+
const KEY_PATTERN = /^[a-z0-9_.-]+$/;
|
|
76
|
+
|
|
77
|
+
/** How many near-duplicate candidates an assert reports back. */
|
|
78
|
+
const SIMILAR_LIMIT = 5;
|
|
79
|
+
|
|
80
|
+
/** How many terms of a new claim are probed against the existing rows. */
|
|
81
|
+
const SIMILAR_TERM_LIMIT = 24;
|
|
82
|
+
|
|
83
|
+
const DEFAULT_LIST_LIMIT = 100;
|
|
84
|
+
const DEFAULT_SEARCH_LIMIT = 20;
|
|
85
|
+
|
|
86
|
+
// ── Validation ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export function isMemoryKind(value: unknown): value is MemoryKind {
|
|
89
|
+
return MEMORY_KINDS.includes(value as MemoryKind);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isMemoryTrust(value: unknown): value is MemoryTrust {
|
|
93
|
+
return MEMORY_TRUSTS.includes(value as MemoryTrust);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Validate and trim a claim's text — the same rule for every write path. */
|
|
97
|
+
function validateText(text: string): string {
|
|
98
|
+
const trimmed = text.trim();
|
|
99
|
+
if (!trimmed) throw new Error("Memory text must not be empty");
|
|
100
|
+
if (trimmed.length > MAX_TEXT_LENGTH)
|
|
101
|
+
throw new Error(`Memory text too long (max ${MAX_TEXT_LENGTH} chars)`);
|
|
102
|
+
return trimmed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateSubject(subject: string): string {
|
|
106
|
+
const trimmed = subject.trim();
|
|
107
|
+
if (!trimmed) throw new Error("Memory subject must not be empty");
|
|
108
|
+
if (trimmed.length > MAX_SUBJECT_LENGTH)
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Memory subject too long (max ${MAX_SUBJECT_LENGTH} chars)`,
|
|
111
|
+
);
|
|
112
|
+
return trimmed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Keys are the state namespace: lowercase, dotted, no spaces. */
|
|
116
|
+
function validateStateKey(key: string): string {
|
|
117
|
+
const trimmed = key.trim();
|
|
118
|
+
if (!trimmed) throw new Error("State memory requires a key");
|
|
119
|
+
if (trimmed.length > MAX_KEY_LENGTH)
|
|
120
|
+
throw new Error(`Memory key too long (max ${MAX_KEY_LENGTH} chars)`);
|
|
121
|
+
if (!KEY_PATTERN.test(trimmed))
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Invalid memory key "${trimmed}" (allowed: lowercase letters, digits, . _ -)`,
|
|
124
|
+
);
|
|
125
|
+
return trimmed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Normalize a caller's input into the exact row the repository inserts. */
|
|
129
|
+
function normalize(input: MemoryInput): repo.MemoryInsert {
|
|
130
|
+
if (!isMemoryKind(input.kind))
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Unknown memory kind "${String(input.kind)}" (expected one of ${MEMORY_KINDS.join(", ")})`,
|
|
133
|
+
);
|
|
134
|
+
if (!isMemoryTrust(input.trust))
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Unknown memory trust "${String(input.trust)}" (expected one of ${MEMORY_TRUSTS.join(", ")})`,
|
|
137
|
+
);
|
|
138
|
+
const confidence = input.confidence ?? 1;
|
|
139
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Memory confidence must be between 0 and 1 (got ${confidence})`,
|
|
142
|
+
);
|
|
143
|
+
if (input.kind !== "state" && input.key !== undefined)
|
|
144
|
+
throw new Error(`Only state memories carry a key (kind=${input.kind})`);
|
|
145
|
+
const key =
|
|
146
|
+
input.kind === "state" ? validateStateKey(input.key ?? "") : undefined;
|
|
147
|
+
const subject = validateSubject(input.subject);
|
|
148
|
+
const text = validateText(input.text);
|
|
149
|
+
if (input.pinned && UNPINNABLE_TRUSTS.includes(input.trust))
|
|
150
|
+
throw new Error(`A ${input.trust} memory can never be pinned`);
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
return {
|
|
153
|
+
kind: input.kind,
|
|
154
|
+
subject,
|
|
155
|
+
...(key !== undefined ? { key } : {}),
|
|
156
|
+
text,
|
|
157
|
+
source: input.source ?? {},
|
|
158
|
+
trust: input.trust,
|
|
159
|
+
confidence,
|
|
160
|
+
createdAt: now,
|
|
161
|
+
lastSeenAt: now,
|
|
162
|
+
hitCount: 0,
|
|
163
|
+
salience: input.salience ?? 0,
|
|
164
|
+
pinned: input.pinned ?? false,
|
|
165
|
+
contentHash: repo.contentHash(input.kind, subject, key, text),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Internals ───────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** Insert a normalized row and open its audit trail with one entry. */
|
|
172
|
+
function insertWithHistory(
|
|
173
|
+
row: repo.MemoryInsert,
|
|
174
|
+
op: MemoryOp,
|
|
175
|
+
reason?: string,
|
|
176
|
+
): number {
|
|
177
|
+
const id = repo.insert(row);
|
|
178
|
+
repo.insertHistory({
|
|
179
|
+
memoryId: id,
|
|
180
|
+
op,
|
|
181
|
+
afterText: row.text,
|
|
182
|
+
...(reason ? { reason } : {}),
|
|
183
|
+
at: row.createdAt,
|
|
184
|
+
});
|
|
185
|
+
return id;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The FTS5 expression that finds near-duplicates of a new claim.
|
|
190
|
+
*
|
|
191
|
+
* A restatement is rarely word-for-word, so the terms are OR-ed rather
|
|
192
|
+
* than AND-ed (which is what a plain `ftsQuote` of the whole text would
|
|
193
|
+
* give) and bm25 does the ranking. Every term still goes through the
|
|
194
|
+
* shared `ftsQuote` core, so nothing in the text is parsed as syntax.
|
|
195
|
+
* Short words carry no signal and the tail of a long claim adds none,
|
|
196
|
+
* so both are dropped.
|
|
197
|
+
*/
|
|
198
|
+
function similarityQuery(text: string): string {
|
|
199
|
+
return text
|
|
200
|
+
.toLowerCase()
|
|
201
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
202
|
+
.filter((term) => term.length > 2)
|
|
203
|
+
.slice(0, SIMILAR_TERM_LIMIT)
|
|
204
|
+
.map((term) => ftsQuote(term))
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.join(" OR ");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load a row that a mutation is about to change, or explain why it
|
|
211
|
+
* can't. Only a live row is mutable: a dropped one is in the graveyard
|
|
212
|
+
* and a superseded one has a successor, so editing it would fork the
|
|
213
|
+
* chain that `/memory diff` and `/memory undo` walk. Always called
|
|
214
|
+
* inside the mutation's own transaction — read-then-write is one unit.
|
|
215
|
+
*/
|
|
216
|
+
function requireLive(id: number, what: string): MemoryRow {
|
|
217
|
+
const row = repo.get(id);
|
|
218
|
+
if (!row) throw new Error(`No memory with id ${id}`);
|
|
219
|
+
if (row.droppedAt !== undefined)
|
|
220
|
+
throw new Error(`Memory ${id} is dropped; cannot ${what} it`);
|
|
221
|
+
if (row.supersededBy !== undefined)
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Memory ${id} is superseded by #${row.supersededBy}; cannot ${what} it`,
|
|
224
|
+
);
|
|
225
|
+
return row;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The row a supersede/merge/replace creates, inheriting the old row's frame. */
|
|
229
|
+
function successorOf(old: MemoryRow, text: string): repo.MemoryInsert {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
return {
|
|
232
|
+
kind: old.kind,
|
|
233
|
+
subject: old.subject,
|
|
234
|
+
...(old.key !== undefined ? { key: old.key } : {}),
|
|
235
|
+
text,
|
|
236
|
+
source: old.source,
|
|
237
|
+
trust: old.trust,
|
|
238
|
+
confidence: old.confidence,
|
|
239
|
+
createdAt: now,
|
|
240
|
+
lastSeenAt: now,
|
|
241
|
+
hitCount: 0,
|
|
242
|
+
salience: old.salience,
|
|
243
|
+
pinned: old.pinned,
|
|
244
|
+
contentHash: repo.contentHash(old.kind, old.subject, old.key, text),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Point `oldId` at its replacement and record the supersede. */
|
|
249
|
+
function markSuperseded(
|
|
250
|
+
old: MemoryRow,
|
|
251
|
+
newId: number,
|
|
252
|
+
text: string,
|
|
253
|
+
reason: string | undefined,
|
|
254
|
+
): void {
|
|
255
|
+
repo.setSupersededBy(old.id, newId);
|
|
256
|
+
repo.insertHistory({
|
|
257
|
+
memoryId: old.id,
|
|
258
|
+
op: "supersede",
|
|
259
|
+
beforeText: old.text,
|
|
260
|
+
afterText: text,
|
|
261
|
+
...(reason ? { reason } : {}),
|
|
262
|
+
at: Date.now(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Writes ──────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Record a new claim.
|
|
270
|
+
*
|
|
271
|
+
* Returns the new id together with the live near-duplicates of the same
|
|
272
|
+
* kind + subject (best FTS matches first). Nothing is auto-superseded:
|
|
273
|
+
* the caller decides whether this was a restatement worth folding into
|
|
274
|
+
* the existing row — the "supersede instead of append" offer of plan §3.2.
|
|
275
|
+
*/
|
|
276
|
+
export function assertMemory(input: MemoryInput): {
|
|
277
|
+
id: number;
|
|
278
|
+
similar: MemoryRow[];
|
|
279
|
+
} {
|
|
280
|
+
const row = normalize(input);
|
|
281
|
+
return inTransaction(() => {
|
|
282
|
+
const id = insertWithHistory(row, "assert");
|
|
283
|
+
const match = similarityQuery(row.text);
|
|
284
|
+
const similar = match
|
|
285
|
+
? repo.similar(match, row.kind, row.subject, id, SIMILAR_LIMIT)
|
|
286
|
+
: [];
|
|
287
|
+
return { id, similar };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Replace a claim's text. The new row inherits the old row's frame
|
|
293
|
+
* (kind, subject, key, source, trust, confidence, salience, pinned) and
|
|
294
|
+
* the old row stays readable, pointing at its replacement.
|
|
295
|
+
*/
|
|
296
|
+
export function supersedeMemory(
|
|
297
|
+
id: number,
|
|
298
|
+
text: string,
|
|
299
|
+
reason?: string,
|
|
300
|
+
): number {
|
|
301
|
+
const valid = validateText(text);
|
|
302
|
+
return inTransaction(() => {
|
|
303
|
+
const old = requireLive(id, "supersede");
|
|
304
|
+
const next = successorOf(old, valid);
|
|
305
|
+
const newId = insertWithHistory(next, "assert", reason);
|
|
306
|
+
markSuperseded(old, newId, next.text, reason);
|
|
307
|
+
return newId;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Send a row to the graveyard: a soft delete that keeps the id, the
|
|
313
|
+
* text and the audit trail. Pinned rows need an explicit reason — the
|
|
314
|
+
* one guard against a bad reconcile turn dropping human intent.
|
|
315
|
+
*/
|
|
316
|
+
export function dropMemory(id: number, reason?: string): void {
|
|
317
|
+
const why = reason?.trim();
|
|
318
|
+
inTransaction(() => {
|
|
319
|
+
const row = requireLive(id, "drop");
|
|
320
|
+
if (row.pinned && !why)
|
|
321
|
+
throw new Error(`Memory ${id} is pinned; dropping it requires a reason`);
|
|
322
|
+
const at = Date.now();
|
|
323
|
+
repo.setDropped(id, at);
|
|
324
|
+
repo.insertHistory({
|
|
325
|
+
memoryId: id,
|
|
326
|
+
op: "drop",
|
|
327
|
+
beforeText: row.text,
|
|
328
|
+
...(why ? { reason: why } : {}),
|
|
329
|
+
at,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Fold several rows of the same kind into one. The survivor inherits
|
|
336
|
+
* the first id's frame; every input row is superseded by it.
|
|
337
|
+
*/
|
|
338
|
+
export function mergeMemory(
|
|
339
|
+
ids: readonly number[],
|
|
340
|
+
text: string,
|
|
341
|
+
reason?: string,
|
|
342
|
+
): number {
|
|
343
|
+
if (ids.length === 0) throw new Error("Merge needs at least one memory id");
|
|
344
|
+
const valid = validateText(text);
|
|
345
|
+
return inTransaction(() => {
|
|
346
|
+
const rows = ids.map((id) => requireLive(id, "merge"));
|
|
347
|
+
const first = rows[0]!;
|
|
348
|
+
const odd = rows.find((row) => row.kind !== first.kind);
|
|
349
|
+
if (odd)
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Cannot merge across kinds (${first.kind} vs ${odd.kind} at id ${odd.id})`,
|
|
352
|
+
);
|
|
353
|
+
const next = successorOf(first, valid);
|
|
354
|
+
const newId = insertWithHistory(next, "merge", reason);
|
|
355
|
+
for (const row of rows) markSuperseded(row, newId, next.text, reason);
|
|
356
|
+
return newId;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Promote a row to the never-truncated tier. */
|
|
361
|
+
export function pinMemory(id: number): void {
|
|
362
|
+
setPinned(id, true, "pin");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Return a pinned row to the ranked pool. */
|
|
366
|
+
export function unpinMemory(id: number): void {
|
|
367
|
+
setPinned(id, false, "unpin");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function setPinned(id: number, pinned: boolean, op: MemoryOp): void {
|
|
371
|
+
inTransaction(() => {
|
|
372
|
+
const row = requireLive(id, op);
|
|
373
|
+
if (pinned && UNPINNABLE_TRUSTS.includes(row.trust))
|
|
374
|
+
throw new Error(`A ${row.trust} memory can never be pinned`);
|
|
375
|
+
repo.setPinned(row.id, pinned);
|
|
376
|
+
repo.insertHistory({
|
|
377
|
+
memoryId: row.id,
|
|
378
|
+
op,
|
|
379
|
+
beforeText: row.text,
|
|
380
|
+
afterText: row.text,
|
|
381
|
+
at: Date.now(),
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Options for `replaceStateKey` — everything has a sensible default. */
|
|
387
|
+
export type ReplaceStateOptions = {
|
|
388
|
+
/** Defaults to the key's prefix before the first dot. */
|
|
389
|
+
subject?: string;
|
|
390
|
+
trust?: MemoryTrust;
|
|
391
|
+
confidence?: number;
|
|
392
|
+
salience?: number;
|
|
393
|
+
reason?: string;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The keyed-state rule: a write REPLACES the row for that key. The
|
|
398
|
+
* previous live row (if any) is superseded in the same transaction, so
|
|
399
|
+
* `heartbeat.health` is one row overwritten rather than a new dated
|
|
400
|
+
* section per run. Returns the new row's id.
|
|
401
|
+
*/
|
|
402
|
+
export function replaceStateKey(
|
|
403
|
+
key: string,
|
|
404
|
+
text: string,
|
|
405
|
+
source: MemorySource = {},
|
|
406
|
+
opts: ReplaceStateOptions = {},
|
|
407
|
+
): number {
|
|
408
|
+
const validKey = validateStateKey(key);
|
|
409
|
+
const row = normalize({
|
|
410
|
+
kind: "state",
|
|
411
|
+
subject: opts.subject ?? validKey.split(".")[0]!,
|
|
412
|
+
key: validKey,
|
|
413
|
+
text,
|
|
414
|
+
source,
|
|
415
|
+
trust: opts.trust ?? "agent",
|
|
416
|
+
...(opts.confidence !== undefined ? { confidence: opts.confidence } : {}),
|
|
417
|
+
...(opts.salience !== undefined ? { salience: opts.salience } : {}),
|
|
418
|
+
});
|
|
419
|
+
return inTransaction(() => {
|
|
420
|
+
const previous = repo.liveStateByKey(validKey);
|
|
421
|
+
const id = insertWithHistory(row, "replace_state", opts.reason);
|
|
422
|
+
if (previous) markSuperseded(previous, id, row.text, opts.reason);
|
|
423
|
+
return id;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Record a retrieval hit: one more use, seen just now.
|
|
429
|
+
*
|
|
430
|
+
* Deliberately writes no `memory_history` row — a touch changes no
|
|
431
|
+
* content, and the retriever calls it per hit, so auditing it would
|
|
432
|
+
* bury the entries that describe real changes.
|
|
433
|
+
*/
|
|
434
|
+
export function touchMemory(id: number): void {
|
|
435
|
+
inTransaction(() => {
|
|
436
|
+
const row = requireLive(id, "touch");
|
|
437
|
+
repo.touch(row.id, Date.now());
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── Reads ───────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/** Any row by id — including superseded and dropped ones. */
|
|
444
|
+
export function getMemory(id: number): MemoryRow | undefined {
|
|
445
|
+
return repo.get(id);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Filters for `listMemories`; live rows only unless asked otherwise. */
|
|
449
|
+
export type MemoryListOptions = {
|
|
450
|
+
kind?: MemoryKind;
|
|
451
|
+
subject?: string;
|
|
452
|
+
includeSuperseded?: boolean;
|
|
453
|
+
includeDropped?: boolean;
|
|
454
|
+
limit?: number;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/** Ranked listing: pinned first, then salience, then recency. */
|
|
458
|
+
export function listMemories(opts: MemoryListOptions = {}): MemoryRow[] {
|
|
459
|
+
return repo.list({
|
|
460
|
+
...(opts.kind !== undefined ? { kind: opts.kind } : {}),
|
|
461
|
+
...(opts.subject !== undefined ? { subject: opts.subject } : {}),
|
|
462
|
+
...(opts.includeSuperseded ? { includeSuperseded: true } : {}),
|
|
463
|
+
...(opts.includeDropped ? { includeDropped: true } : {}),
|
|
464
|
+
limit: opts.limit ?? DEFAULT_LIST_LIMIT,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Full-text search over live rows, best match first (bm25). Free-form
|
|
470
|
+
* input is quoted into a literal FTS5 expression by the shared
|
|
471
|
+
* `ftsQuote` core, so operators and punctuation in a query are matched
|
|
472
|
+
* as text rather than parsed as syntax.
|
|
473
|
+
*/
|
|
474
|
+
export function searchMemories(
|
|
475
|
+
query: string,
|
|
476
|
+
opts: { kind?: MemoryKind; limit?: number } = {},
|
|
477
|
+
): MemoryRow[] {
|
|
478
|
+
const match = ftsQuote(query);
|
|
479
|
+
if (!match) return [];
|
|
480
|
+
return repo.searchFts(match, opts.kind, opts.limit ?? DEFAULT_SEARCH_LIMIT);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The audit trail for one row, oldest entry first. */
|
|
484
|
+
export function memoryHistory(id: number): MemoryHistoryRow[] {
|
|
485
|
+
return repo.historyFor(id);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Formatting ──────────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* One row as a single line — shared by the CLI and (from PR 5) the
|
|
492
|
+
* `/memory` command, so both surfaces describe a memory identically.
|
|
493
|
+
*/
|
|
494
|
+
export function formatMemory(row: MemoryRow): string {
|
|
495
|
+
const markers = [
|
|
496
|
+
row.pinned ? "pinned" : "",
|
|
497
|
+
row.supersededBy !== undefined ? `superseded by #${row.supersededBy}` : "",
|
|
498
|
+
row.droppedAt !== undefined ? "dropped" : "",
|
|
499
|
+
].filter(Boolean);
|
|
500
|
+
const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : "";
|
|
501
|
+
// A state row's key is the more specific label, and carries its
|
|
502
|
+
// subject as the prefix anyway (heartbeat.health → heartbeat).
|
|
503
|
+
const label = row.key ?? row.subject;
|
|
504
|
+
return `#${row.id} [${row.kind}] ${label}: ${row.text}${suffix}`;
|
|
505
|
+
}
|