@arkimedehq/openclaw-recordare 0.2.0 → 0.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/README.md CHANGED
@@ -24,7 +24,9 @@ own [Recordare](../../README.md) service — the **full** client level:
24
24
  It does not take OpenClaw's memory slot: `memory-core` (`MEMORY.md`, `memory_search`) keeps working beside it. Recordare
25
25
  stores every turn the plugin sends (it has no consent flag, D50). The plugin never blocks or breaks a turn: every call
26
26
  is time-boxed (3 s before the turn), failures are logged without content, captured messages that could not be sent are
27
- retried in the background for about ten minutes (in memory: a Gateway restart drops them).
27
+ retried in the background for a few hours, from a queue on disk that survives a Gateway restart
28
+ (`<OpenClaw state dir>/recordare/outbox`, normally `~/.openclaw/recordare/outbox`, owner-only files; only unsent
29
+ messages are written there, and each file is deleted once Recordare has it).
28
30
 
29
31
  Requires OpenClaw ≥ 2026.9.9 (Node ≥ 24, as OpenClaw itself).
30
32
 
@@ -45,7 +47,7 @@ Requires OpenClaw ≥ 2026.9.9 (Node ≥ 24, as OpenClaw itself).
45
47
  Recordare or auto-provisioned if the client allows it); a client key is also what one memory per person needs.
46
48
 
47
49
  The memory's **mode** and **gender** are set by the admin (`PATCH api/v1/admin/memories/{id}` `{mode, gender}`), or
48
- with a client key by `PATCH api/v1/me`: `personal` (your own assistant: you are "I", what arrives undeclared is
50
+ with a client key or the personal token by `PATCH api/v1/me`: `personal` (your own assistant: you are "I", what arrives undeclared is
49
51
  yours) or `entity` (an agent shared by a family, a team, a place: what arrives undeclared is "someone"'s); `gender`
50
52
  `masculine` (default) | `feminine` | `neutral` for the first person in gendered languages. The plugin has no setting
51
53
  for them.
@@ -93,6 +95,15 @@ list your ids there (or have the admin bind them to the memory's self, `POST api
93
95
  people's words are kept as theirs (role `other`, attributed to their contact), so what they say about themselves never
94
96
  becomes a fact about you.
95
97
 
98
+ **Or link your sender id with a code** (D51, personal memories). Make a one-time code — the admin console's "Link code"
99
+ on the memory, or `POST api/v1/me/link-codes` with the memory's personal token (`→ {"code": "RCD-…", "expiresAt": …}`,
100
+ ten minutes) — and send it alone to the agent in a **direct chat** from your channel account. The plugin sends it to
101
+ Recordare at once (never stored as a message) and tells the model only to say that the code was received; then confirm
102
+ the claim in the console ("To confirm" → "It is the holder") or with `GET api/v1/me/identities` and `POST
103
+ api/v1/me/identities/pending/{id}/confirm`, within seven days. From then on that sender is you; earlier messages keep
104
+ their attribution. The plugin marks group, channel and room sessions (by their session key) as groups, where a code
105
+ claims nothing.
106
+
96
107
  **Upgrading from a `users` map.** The earlier behaviour (each mapped sender a separate Recordare user, unmapped senders
97
108
  not remembered) is `memoryPer: "user"`: add it to keep your memories split per person.
98
109
 
package/README_it.md CHANGED
@@ -26,8 +26,10 @@ tenuta dal tuo servizio [Recordare](../../README_it.md) — il livello client **
26
26
  Non occupa lo slot memoria di OpenClaw: `memory-core` (`MEMORY.md`, `memory_search`) continua a funzionare accanto.
27
27
  Recordare conserva ogni turno che il plugin invia (non ha un flag di consenso, D50). Il plugin non blocca né rompe mai
28
28
  un turno: ogni chiamata ha un limite di tempo (3 s prima del turno), gli errori finiscono nel log senza contenuti, i
29
- messaggi catturati non inviati vengono ritentati in background per una decina di minuti (in memoria: un riavvio del
30
- Gateway li perde).
29
+ messaggi catturati non inviati vengono ritentati in background per qualche ora, da una coda su disco che sopravvive a un
30
+ riavvio del Gateway (`<cartella di stato di OpenClaw>/recordare/outbox`, di solito `~/.openclaw/recordare/outbox`, file
31
+ leggibili solo dal proprietario; ci finiscono solo i messaggi non inviati, e ogni file viene cancellato appena Recordare
32
+ lo ha ricevuto).
31
33
 
32
34
  Richiede OpenClaw ≥ 2026.9.9 (Node ≥ 24, come OpenClaw stesso).
33
35
 
@@ -49,7 +51,7 @@ Richiede OpenClaw ≥ 2026.9.9 (Node ≥ 24, come OpenClaw stesso).
49
51
  per persona.
50
52
 
51
53
  **Modalità** e **genere** della memoria li imposta l'amministratore (`PATCH api/v1/admin/memories/{id}`
52
- `{mode, gender}`), oppure, con una chiave client, `PATCH api/v1/me`: `personal` (il tuo assistente: tu sei l'"io",
54
+ `{mode, gender}`), oppure, con una chiave client o il token personale, `PATCH api/v1/me`: `personal` (il tuo assistente: tu sei l'"io",
53
55
  ciò che arriva senza identità dichiarata è tuo) o `entity` (un agente condiviso da una famiglia, un team, un luogo:
54
56
  ciò che arriva senza identità è di "qualcuno"); `gender` `masculine` (default) | `feminine` | `neutral` per la prima
55
57
  persona nelle lingue con il genere. Il plugin non ha impostazioni per questi valori.
@@ -97,6 +99,15 @@ non tu: elenca lì i tuoi id (oppure chiedi all'amministratore di legarli al sé
97
99
  di scrivere la prima volta). Le parole degli altri restano loro (ruolo `other`, attribuite al loro contatto), così ciò
98
100
  che dicono di sé non diventa mai un fatto su di te.
99
101
 
102
+ **Oppure collega il tuo sender id con un codice** (D51, memorie personali). Crea un codice monouso — "Codice di collegamento" sulla
103
+ memoria nella console di amministrazione, oppure `POST api/v1/me/link-codes` con il token personale della memoria
104
+ (`→ {"code": "RCD-…", "expiresAt": …}`, dieci minuti) — e invialo da solo all'agente in una **chat diretta** dal tuo
105
+ account del canale. Il plugin lo invia subito a Recordare (non viene mai salvato come messaggio) e dice al modello solo di
106
+ rispondere che il codice è arrivato; poi conferma la richiesta nella console ("Da confermare" → "È il titolare") oppure
107
+ con `GET api/v1/me/identities` e `POST api/v1/me/identities/pending/{id}/confirm`, entro sette giorni. Da allora quel
108
+ mittente sei tu; i messaggi precedenti mantengono la loro attribuzione. Il plugin considera gruppi le sessioni di gruppo,
109
+ canale e stanza (dalla chiave di sessione), dove un codice non chiede nulla.
110
+
100
111
  **Aggiornare da una mappa `users`.** Il comportamento precedente (ogni mittente mappato un utente Recordare separato, i
101
112
  non mappati non ricordati) è `memoryPer: "user"`: aggiungilo per tenere le memorie divise per persona.
102
113
 
package/dist/index.js CHANGED
@@ -7300,6 +7300,8 @@ var require_content_type = __commonJS({
7300
7300
  });
7301
7301
 
7302
7302
  // src/index.ts
7303
+ import { homedir } from "node:os";
7304
+ import { join as join2 } from "node:path";
7303
7305
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
7304
7306
  import { isCronSessionKey, isIncognitoSessionKey, isSubagentSessionKey } from "openclaw/plugin-sdk/routing";
7305
7307
 
@@ -17504,8 +17506,8 @@ async function random(size) {
17504
17506
  const evenDistCutoff = Math.pow(2, 8) - Math.pow(2, 8) % mask.length;
17505
17507
  let result = "";
17506
17508
  while (result.length < size) {
17507
- const randomBytes = await getRandomValues(size - result.length);
17508
- for (const randomByte of randomBytes) {
17509
+ const randomBytes2 = await getRandomValues(size - result.length);
17510
+ for (const randomByte of randomBytes2) {
17509
17511
  if (randomByte < evenDistCutoff) {
17510
17512
  result += mask[randomByte % mask.length];
17511
17513
  }
@@ -19070,7 +19072,7 @@ var RecordareMcp = class {
19070
19072
  },
19071
19073
  requestInit: { headers: { ...this.http.headers(user), [CONVERSATION_HEADER]: conversation } }
19072
19074
  });
19073
- const client = new Client(this.options.clientInfo ?? { name: "recordare-client", version: "0.2.0" });
19075
+ const client = new Client(this.options.clientInfo ?? { name: "recordare-client", version: "0.4.0" });
19074
19076
  await client.connect(transport);
19075
19077
  return client;
19076
19078
  }
@@ -19109,6 +19111,33 @@ var RecordareClient = class {
19109
19111
  throw err;
19110
19112
  }
19111
19113
  }
19114
+ /**
19115
+ * Erases the whole memory behind `user`, at once and for good (needs the `erase` scope): everything it holds goes.
19116
+ * `memoryId` (from `me`) confirms which memory; a user without a memory answers 404, none is created.
19117
+ */
19118
+ eraseMemory(user, memoryId) {
19119
+ return this.http.request("DELETE", `api/v1/me${qs({ confirm: memoryId })}`, { user });
19120
+ }
19121
+ /** A one-time code for the holder to send from a channel (D51; personal memories only, else 409). */
19122
+ linkCode(user) {
19123
+ return this.http.request("POST", "api/v1/me/link-codes", { user });
19124
+ }
19125
+ /** The identities that speak as the memory's "I" and the claims waiting for confirmation (D51). */
19126
+ holderIdentities(user) {
19127
+ return this.http.request("GET", "api/v1/me/identities", { user });
19128
+ }
19129
+ /** Confirms a claim: that channel identity is the holder from now on. */
19130
+ async confirmHolderClaim(user, id) {
19131
+ await this.http.request("POST", `api/v1/me/identities/pending/${encodeURIComponent(id)}/confirm`, { user });
19132
+ }
19133
+ /** Rejects a claim (not the holder). */
19134
+ async rejectHolderClaim(user, id) {
19135
+ await this.http.request("DELETE", `api/v1/me/identities/pending/${encodeURIComponent(id)}`, { user });
19136
+ }
19137
+ /** Unlinks one of the holder's identities. */
19138
+ async unlinkHolderIdentity(user, id) {
19139
+ await this.http.request("DELETE", `api/v1/me/identities/${encodeURIComponent(id)}`, { user });
19140
+ }
19112
19141
  /**
19113
19142
  * Sends messages of one conversation, split into requests of at most MAX_MESSAGES_PER_REQUEST. Idempotent: Recordare
19114
19143
  * deduplicates on each message's externalId. Recordare always stores (D50): the on/off switch is the platform's.
@@ -19133,20 +19162,44 @@ var RecordareClient = class {
19133
19162
  }
19134
19163
  return total;
19135
19164
  }
19165
+ /**
19166
+ * Sends a room transcript to the memories of the people present (continuous listening), split into requests of at
19167
+ * most MAX_MESSAGES_PER_REQUEST; needs a client key (not a personal token). Idempotent like `ingest`.
19168
+ */
19169
+ async ingestRoom(req) {
19170
+ const total = /* @__PURE__ */ new Map();
19171
+ for (let i = 0; i < req.messages.length; i += MAX_MESSAGES_PER_REQUEST) {
19172
+ const last = i + MAX_MESSAGES_PER_REQUEST >= req.messages.length;
19173
+ const part = await this.http.request("POST", "api/v1/ingest/room", {
19174
+ body: { ...req, messages: req.messages.slice(i, i + MAX_MESSAGES_PER_REQUEST), hints: req.hints && last ? req.hints : void 0 }
19175
+ });
19176
+ for (const r of part.recipients) {
19177
+ const t = total.get(r.user);
19178
+ if (!t) total.set(r.user, { ...r, conflicts: [...r.conflicts] });
19179
+ else {
19180
+ t.conversationId = r.conversationId ?? t.conversationId;
19181
+ t.accepted += r.accepted;
19182
+ t.duplicates += r.duplicates;
19183
+ t.conflicts.push(...r.conflicts);
19184
+ }
19185
+ }
19186
+ }
19187
+ return { recipients: [...total.values()] };
19188
+ }
19136
19189
  /**
19137
19190
  * The memories relevant to `query` (the message about to be answered) as a fenced block to append to the prompt — or
19138
19191
  * null. Append it at the end of the system prompt (it changes every turn: keep the stable part first for prompt
19139
- * caching); never store it as a chat message.
19192
+ * caching); never store it as a chat message. `card`: open it with the self card (who I am), off by default.
19140
19193
  */
19141
- context(user, conversation, query) {
19142
- return this.http.request("POST", "api/v1/context", { user, body: { query }, headers: { [CONVERSATION_HEADER]: conversation } });
19194
+ context(user, conversation, query, opts = {}) {
19195
+ return this.http.request("POST", "api/v1/context", { user, body: { query, ...opts }, headers: { [CONVERSATION_HEADER]: conversation } });
19143
19196
  }
19144
19197
  /**
19145
19198
  * Stores the turn and returns the memory context for it in one round trip (needs the `ingest` and `read` scopes):
19146
19199
  * the context is computed for the ingested conversation, from `query` or else the turn's last user message.
19147
19200
  */
19148
- contextWithTurn(user, turn, query) {
19149
- return this.http.request("POST", "api/v1/context", { user, body: { ingest: turn, ...query ? { query } : {} } });
19201
+ contextWithTurn(user, turn, query, opts = {}) {
19202
+ return this.http.request("POST", "api/v1/context", { user, body: { ingest: turn, ...query ? { query } : {}, ...opts } });
19150
19203
  }
19151
19204
  /** The conversation ended (session closed, /new): extraction runs now. A conversation Recordare never had is done. */
19152
19205
  async endConversation(user, conversation) {
@@ -19206,12 +19259,23 @@ var RecordareClient = class {
19206
19259
  async forgetEpisode(user, id) {
19207
19260
  await this.http.request("DELETE", `api/v1/episodes/${enc(id)}`, { user });
19208
19261
  }
19262
+ /**
19263
+ * The person forgets a period (days or months, inclusive): its episodes and raw messages (`keepRaw`: kept but hidden
19264
+ * and never extracted again), with what only they supported. Returns how much went.
19265
+ */
19266
+ forgetPeriod(user, period) {
19267
+ return this.http.request("POST", "api/v1/forget", { user, body: period });
19268
+ }
19209
19269
  digests(user, query = {}) {
19210
19270
  return this.http.request("GET", `api/v1/digests${qs(query)}`, { user });
19211
19271
  }
19212
19272
  facts(user, query = {}) {
19213
19273
  return this.http.request("GET", `api/v1/facts${qs(query)}`, { user });
19214
19274
  }
19275
+ /** Who I am and who my contacts are: current facts and notes of the self and of each contact (no LLM). */
19276
+ cards(user) {
19277
+ return this.http.request("GET", "api/v1/cards", { user });
19278
+ }
19215
19279
  notes(user, query = {}) {
19216
19280
  return this.http.request("GET", `api/v1/notes${qs(query)}`, { user });
19217
19281
  }
@@ -19230,6 +19294,54 @@ var RecordareClient = class {
19230
19294
  async decide(user, what, id, decision) {
19231
19295
  await this.http.request("POST", `api/v1/${what}/${enc(id)}/${decision}`, { user });
19232
19296
  }
19297
+ /** The person writes an episode in the platform's UI (stated, theirs). Returns its id. */
19298
+ async addEpisode(user, episode) {
19299
+ return (await this.http.request("POST", "api/v1/episodes", { user, body: episode })).id;
19300
+ }
19301
+ /** The person writes a note in the platform's UI (stated, theirs). Returns its id. */
19302
+ async addNote(user, note) {
19303
+ return (await this.http.request("POST", "api/v1/notes", { user, body: note })).id;
19304
+ }
19305
+ fact(user, id) {
19306
+ return this.http.request("GET", `api/v1/facts/${enc(id)}`, { user });
19307
+ }
19308
+ note(user, id) {
19309
+ return this.http.request("GET", `api/v1/notes/${enc(id)}`, { user });
19310
+ }
19311
+ /** The person corrects a current fact (`value` null: not known any more); the wrong one stays in its history. */
19312
+ async correctFact(user, id, fix) {
19313
+ return (await this.http.request("POST", `api/v1/facts/${enc(id)}/corrections`, { user, body: fix })).id;
19314
+ }
19315
+ /** The person corrects a current note; the wrong one stays as `corrected`. */
19316
+ async correctNote(user, id, fix) {
19317
+ return (await this.http.request("POST", `api/v1/notes/${enc(id)}/corrections`, { user, body: fix })).id;
19318
+ }
19319
+ /** The notes change feed, for a platform keeping a copy: pass `next` back as `since`; `more` when the page was full. */
19320
+ noteChanges(user, query = {}) {
19321
+ return this.http.request("GET", `api/v1/notes/changes${qs(query)}`, { user });
19322
+ }
19323
+ settings(user) {
19324
+ return this.http.request("GET", "api/v1/settings", { user });
19325
+ }
19326
+ /** Needs `memory_settings`: the memory's personal token (never a client key); pass `user` empty with it. */
19327
+ updateSettings(user, patch) {
19328
+ return this.http.request("PATCH", "api/v1/settings", { user, body: patch });
19329
+ }
19330
+ /** LLM calls and tokens spent on the memory between two local days (default: the last 30). */
19331
+ usage(user, query = {}) {
19332
+ return this.http.request("GET", `api/v1/usage${qs(query)}`, { user });
19333
+ }
19334
+ /** Starts a full export (needs `export`: the memory's personal token); poll `exportJob`, then `exportArchive`. */
19335
+ startExport(user) {
19336
+ return this.http.request("POST", "api/v1/exports", { user });
19337
+ }
19338
+ exportJob(user, id) {
19339
+ return this.http.request("GET", `api/v1/exports/${enc(id)}`, { user });
19340
+ }
19341
+ /** The finished export: one JSON archive (`format: "recordare-export/1"`). */
19342
+ exportArchive(user, id) {
19343
+ return this.http.request("GET", `api/v1/exports/${enc(id)}/archive`, { user });
19344
+ }
19233
19345
  /** Closes the MCP sessions (on shutdown). */
19234
19346
  close() {
19235
19347
  return this.mcp.close();
@@ -19371,7 +19483,7 @@ var TOOLS = [
19371
19483
  {
19372
19484
  "name": "log_episode",
19373
19485
  "title": "Note something that happened or is planned",
19374
- "description": 'Explicit capture ("note that today I serviced the car"). Resolve the date yourself (ISO).',
19486
+ "description": 'Explicit capture ("note that today I serviced the car"). Resolve the date yourself (ISO). Conversations are remembered automatically: use it only when explicitly asked, and never ask whether to remember or save something.',
19375
19487
  "inputSchema": {
19376
19488
  "type": "object",
19377
19489
  "properties": {
@@ -19421,7 +19533,7 @@ var TOOLS = [
19421
19533
  {
19422
19534
  "name": "remember",
19423
19535
  "title": "Remember something",
19424
- "description": 'Explicit "remember that\u2026" about preferences, habits, values, knowledge.',
19536
+ "description": 'Explicit "remember that\u2026" about preferences, habits, values, knowledge. Conversations are remembered automatically: use it only when explicitly asked, and never ask whether to remember or save something.',
19425
19537
  "inputSchema": {
19426
19538
  "type": "object",
19427
19539
  "properties": {
@@ -19467,7 +19579,7 @@ var TOOLS = [
19467
19579
  {
19468
19580
  "name": "search_episodes",
19469
19581
  "title": "Search what happened",
19470
- "description": 'Search your memory of what happened: what you lived, did, planned or learned, and what happened to the people you know (events, plans, changes), with dates and status. Each item has a subject: you (written in the first person), a person by name, someone, or undecided. Use `from`/`to` (ISO dates, inclusive; see resolve_period) for questions about a period. mode: "search" = most relevant, "list" = chronological in the period (overviews, counting), "latest" = most recent first ("when did I last\u2026").',
19582
+ "description": 'Search your memory of what happened: what you lived, did, planned or learned, and what happened to the people you know (events, plans, changes), with dates and status. Each item has a subject: you (written in the first person), a person by name, someone, or undecided. Use `from`/`to` (ISO dates, inclusive; see resolve_period) for questions about a period; a vague span ("lately", "these weeks") is not a period: leave them out. mode: "search" = most relevant, "list" = chronological in the period (overviews, counting), "latest" = most recent first ("when did I last\u2026"). An item or chat excerpt marked `heard` was overheard in the room by a listening device, not said to you: tell it as heard at home, and its speaker may have been misrecognised \u2014 what it says of you may be someone else\'s. A chat excerpt by `assistant` is your own earlier reply: what you told them then (advice, a recipe, a name), not something they lived.',
19471
19583
  "inputSchema": {
19472
19584
  "type": "object",
19473
19585
  "properties": {
@@ -19531,7 +19643,7 @@ var TOOLS = [
19531
19643
  {
19532
19644
  "name": "search_memory",
19533
19645
  "title": "Search what you know about yourself and others",
19534
- "description": 'Search your memory of preferences, habits, values, relationships, knowledge and current state (car, home, job\u2026) \u2014 yours and of the people you know; each item has its subject. Use `as_of` (ISO date) for "what was it on that date" questions; each fact comes with its history.',
19646
+ "description": 'Search your memory of preferences, habits, values, relationships, knowledge and current state (car, home, job\u2026) \u2014 yours and of the people you know; each item has its subject. Use `as_of` (ISO date) for "what was it on that date" questions; each fact comes with its history. A note with `pattern` is a habit you noticed yourself in repeated episodes, not something said: tell it as such.',
19535
19647
  "inputSchema": {
19536
19648
  "type": "object",
19537
19649
  "properties": {
@@ -19556,6 +19668,13 @@ var TOOLS = [
19556
19668
  }
19557
19669
  ];
19558
19670
 
19671
+ // ../../packages/client/src/links.ts
19672
+ var LINK_CODE = /^\s*RCD-?[0-9A-Z]{4}-?[0-9A-Z]{4}\.?\s*$/i;
19673
+ function isLinkCode(text) {
19674
+ return LINK_CODE.test(text);
19675
+ }
19676
+ var LINK_CODE_INSTRUCTION = "The user's last message is a one-time code that links their account on this channel to their memory (Recordare); it is handled automatically. Reply only, briefly and in the user's language, that the code was received and that they now confirm the link where they created the code. Do not repeat the code.";
19677
+
19559
19678
  // ../../packages/client/src/delivery.ts
19560
19679
  var DEFAULT_DELIVERY = { maxAttempts: 12, baseDelayMs: 5e3, maxDelayMs: 60 * 60 * 1e3 };
19561
19680
  function backoffMs(attempts, policy = DEFAULT_DELIVERY) {
@@ -19606,6 +19725,80 @@ function isSystemRun(ctx) {
19606
19725
  return ctx.trigger === "cron" || ctx.trigger === "heartbeat" || ctx.inputProvenance?.kind === "internal_system" || ctx.inputProvenance?.kind === "inter_session";
19607
19726
  }
19608
19727
 
19728
+ // src/outbox.ts
19729
+ import { randomBytes } from "node:crypto";
19730
+ import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
19731
+ import { join } from "node:path";
19732
+ var FILE = /^\d{15}-[0-9a-f]{12}\.json$/;
19733
+ function pendingId(now = Date.now()) {
19734
+ return `${String(now).padStart(15, "0")}-${randomBytes(6).toString("hex")}`;
19735
+ }
19736
+ var Outbox = class {
19737
+ constructor(dir, warn) {
19738
+ this.warn = warn;
19739
+ this.dir = dir;
19740
+ if (!dir) return;
19741
+ try {
19742
+ mkdirSync(dir, { recursive: true, mode: 448 });
19743
+ } catch (err) {
19744
+ this.disable(err);
19745
+ }
19746
+ }
19747
+ dir;
19748
+ get durable() {
19749
+ return this.dir !== null;
19750
+ }
19751
+ /** The batches left by a previous run, oldest first; unreadable files are dropped (logged). */
19752
+ load() {
19753
+ if (!this.dir) return [];
19754
+ let names;
19755
+ try {
19756
+ names = readdirSync(this.dir).filter((n) => FILE.test(n)).sort();
19757
+ } catch (err) {
19758
+ this.disable(err);
19759
+ return [];
19760
+ }
19761
+ const out = [];
19762
+ for (const name of names) {
19763
+ const path = join(this.dir, name);
19764
+ try {
19765
+ const p = JSON.parse(readFileSync(path, "utf8"));
19766
+ if (typeof p.user !== "string" || !Array.isArray(p.req?.messages)) throw new Error("not a batch");
19767
+ out.push({ ...p, id: name.slice(0, -".json".length) });
19768
+ } catch {
19769
+ this.warn(`recordare: dropped an unreadable queued batch (${name})`);
19770
+ try {
19771
+ unlinkSync(path);
19772
+ } catch {
19773
+ }
19774
+ }
19775
+ }
19776
+ return out;
19777
+ }
19778
+ /** Writes (or rewrites) a batch atomically: a temporary file renamed over the old one. */
19779
+ save(p) {
19780
+ if (!this.dir) return;
19781
+ const path = join(this.dir, `${p.id}.json`);
19782
+ try {
19783
+ writeFileSync(`${path}.tmp`, JSON.stringify(p), { mode: 384 });
19784
+ renameSync(`${path}.tmp`, path);
19785
+ } catch (err) {
19786
+ this.disable(err);
19787
+ }
19788
+ }
19789
+ remove(p) {
19790
+ if (!this.dir) return;
19791
+ try {
19792
+ unlinkSync(join(this.dir, `${p.id}.json`));
19793
+ } catch {
19794
+ }
19795
+ }
19796
+ disable(err) {
19797
+ this.warn(`recordare: retry queue kept in memory only, its folder is not usable (${err instanceof Error ? err.message : "error"})`);
19798
+ this.dir = null;
19799
+ }
19800
+ };
19801
+
19609
19802
  // src/tools.ts
19610
19803
  var OFFERED = TOOLS.filter((t) => t.name !== "log_episode");
19611
19804
  var NAMES = new RegExp(`\\b(${OFFERED.map((t) => t.name).join("|")})\\b`, "g");
@@ -19660,9 +19853,10 @@ function lastTurn(messages) {
19660
19853
  }
19661
19854
 
19662
19855
  // src/plugin.ts
19663
- var VERSION = "0.2.0";
19856
+ var VERSION = "0.4.0";
19664
19857
  var TOOL_TIMEOUT_MS = 2e4;
19665
- var RETRY = { maxAttempts: 8, baseDelayMs: 2e3, maxDelayMs: 5 * 6e4 };
19858
+ var RETRY = { maxAttempts: 16, baseDelayMs: 2e3, maxDelayMs: 60 * 6e4 };
19859
+ var RESUME_MS = 5e3;
19666
19860
  var MAX_QUEUE = 500;
19667
19861
  var MAX_TRACKED = 500;
19668
19862
  var MAX_GROUP_BUFFER = 50;
@@ -19684,7 +19878,7 @@ function withTimeout(p, ms) {
19684
19878
  })
19685
19879
  ]).finally(() => clearTimeout(timer));
19686
19880
  }
19687
- function registerRecordare(api, cfg, kinds) {
19881
+ function registerRecordare(api, cfg, kinds, outboxDir = null) {
19688
19882
  const log = api.logger;
19689
19883
  const clientInfo = { name: "openclaw-recordare", version: VERSION };
19690
19884
  const fast = new RecordareClient({ baseUrl: cfg.url, apiKey: cfg.apiKey, timeoutMs: cfg.timeoutMs, mcp: { clientInfo } });
@@ -19695,7 +19889,8 @@ function registerRecordare(api, cfg, kinds) {
19695
19889
  const groupBuffer = /* @__PURE__ */ new Map();
19696
19890
  const groupParticipants = /* @__PURE__ */ new Map();
19697
19891
  const names = /* @__PURE__ */ new Map();
19698
- const queue = [];
19892
+ const outbox = new Outbox(outboxDir, (m) => log.warn(m));
19893
+ const queue = outbox.load();
19699
19894
  let timer;
19700
19895
  let stopped = false;
19701
19896
  if (cfg.memoryPer === "agent" && !cfg.defaultUser) {
@@ -19708,18 +19903,21 @@ function registerRecordare(api, cfg, kinds) {
19708
19903
  try {
19709
19904
  const res = await slow.ingest(headerUser(p.user), p.req);
19710
19905
  if (res.conflicts.length) log.warn(`recordare: ${res.conflicts.length} message id(s) conflicted (kept the stored version)`);
19906
+ outbox.remove(p);
19711
19907
  return true;
19712
19908
  } catch (err) {
19713
19909
  p.attempts++;
19714
19910
  const next = afterFailure(err, p.attempts, RETRY);
19715
19911
  if (next.action === "park") {
19716
19912
  log.warn(`recordare: dropped ${p.req.messages.length} message(s) after ${p.attempts} attempt(s) (${next.reason}: ${errName(err)})`);
19913
+ outbox.remove(p);
19717
19914
  return true;
19718
19915
  }
19719
19916
  if (queue.length >= MAX_QUEUE) {
19720
- queue.shift();
19917
+ outbox.remove(queue.shift());
19721
19918
  log.warn("recordare: retry queue full, dropped the oldest batch");
19722
19919
  }
19920
+ outbox.save(p);
19723
19921
  queue.push(p);
19724
19922
  schedule(next.delayMs);
19725
19923
  if (p.attempts === 1) log.warn(`recordare: ingest failed (${errName(err)}), retrying in the background`);
@@ -19739,7 +19937,7 @@ function registerRecordare(api, cfg, kinds) {
19739
19937
  for (const p of batch) await deliver(p);
19740
19938
  }
19741
19939
  function send(user, req) {
19742
- return deliver({ user, req, attempts: 0 });
19940
+ return deliver({ id: pendingId(), user, req, attempts: 0 });
19743
19941
  }
19744
19942
  function who(ctx) {
19745
19943
  const key = ctx.sessionKey;
@@ -19766,6 +19964,8 @@ function registerRecordare(api, cfg, kinds) {
19766
19964
  source: "chat",
19767
19965
  channel: w.channel ? `openclaw:${w.channel}` : "openclaw",
19768
19966
  title: ctx.sessionKey,
19967
+ // Group sessions are known by their key; every other session of a person is a direct chat (D51: link codes).
19968
+ ...ctx.sessionKey ? { direct: !isGroupSession(ctx.sessionKey) } : {},
19769
19969
  participants
19770
19970
  };
19771
19971
  }
@@ -19787,6 +19987,10 @@ function registerRecordare(api, cfg, kinds) {
19787
19987
  const conversation = conversationMeta(w, ctx);
19788
19988
  const message = { externalId: `${id}:u`, role: w.speaker.role, authorRef: w.speaker.ref, content: clipUtf8(text), sentAt: now };
19789
19989
  remember(turns, ctx.runId ?? w.conversation, { user: w.user, conversation, message });
19990
+ if (cfg.capture && isLinkCode(text)) {
19991
+ void send(w.user, { conversation, messages: [message] });
19992
+ return { prependSystemContext: LINK_CODE_INSTRUCTION };
19993
+ }
19790
19994
  if (!cfg.capture) {
19791
19995
  const ctxRes = await fast.context(headerUser(w.user), w.conversation, text.slice(0, 4e3));
19792
19996
  return ctxRes.block ? { prependContext: ctxRes.block } : void 0;
@@ -19897,12 +20101,17 @@ function registerRecordare(api, cfg, kinds) {
19897
20101
  }, { name });
19898
20102
  }
19899
20103
  }
20104
+ if (queue.length) {
20105
+ log.info(`recordare: ${queue.length} undelivered batch(es) from the last run, retrying`);
20106
+ schedule(RESUME_MS);
20107
+ }
19900
20108
  return {
19901
20109
  async stop() {
19902
20110
  stopped = true;
19903
20111
  if (timer) clearTimeout(timer);
19904
20112
  timer = void 0;
19905
- await withTimeout(Promise.all(queue.splice(0).map((p) => slow.ingest(headerUser(p.user), p.req).catch(() => void 0))), 1500).catch(() => void 0);
20113
+ const last = queue.splice(0);
20114
+ await withTimeout(Promise.all(last.map((p) => slow.ingest(headerUser(p.user), p.req).then(() => outbox.remove(p), () => void 0))), 1500).catch(() => void 0);
19906
20115
  await Promise.all([fast.close(), slow.close()]).catch(() => void 0);
19907
20116
  }
19908
20117
  };
@@ -19919,11 +20128,12 @@ var index_default = definePluginEntry({
19919
20128
  api.logger.warn("recordare: not configured (set plugins.entries.recordare.config.url and apiKey); memory is off");
19920
20129
  return;
19921
20130
  }
20131
+ const stateDir = api.runtime?.state?.resolveStateDir?.(process.env) || process.env.OPENCLAW_STATE_DIR?.trim() || join2(homedir(), ".openclaw");
19922
20132
  const plugin = registerRecordare(api, cfg, {
19923
20133
  isIncognito: (k) => !!k && isIncognitoSessionKey(k),
19924
20134
  isCron: (k) => !!k && isCronSessionKey(k),
19925
20135
  isSubagent: (k) => !!k && isSubagentSessionKey(k)
19926
- });
20136
+ }, join2(stateDir, "recordare", "outbox"));
19927
20137
  api.registerService({ id: "recordare", start: () => void 0, stop: () => plugin.stop() });
19928
20138
  }
19929
20139
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkimedehq/openclaw-recordare",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Recordare for OpenClaw: long-term episodic memory kept by your own Recordare service (capture, recall before each turn, memory tools).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "author": "Andrea Genovese",