@poncho-ai/harness 0.24.0 → 0.26.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/src/memory.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  slugifyStorageComponent,
9
9
  STORAGE_SCHEMA_VERSION,
10
10
  } from "./agent-identity.js";
11
+ import { createRawKVStore, type RawKVStore } from "./kv-store.js";
11
12
 
12
13
  export interface MainMemory {
13
14
  content: string;
@@ -27,7 +28,7 @@ export interface MemoryConfig {
27
28
 
28
29
  export interface MemoryStore {
29
30
  getMainMemory(): Promise<MainMemory>;
30
- updateMainMemory(input: { content: string; mode?: "replace" | "append" }): Promise<MainMemory>;
31
+ updateMainMemory(input: { content: string }): Promise<MainMemory>;
31
32
  }
32
33
 
33
34
  type MainMemoryPayload = {
@@ -89,19 +90,10 @@ class InMemoryMemoryStore implements MemoryStore {
89
90
  return this.mainMemory;
90
91
  }
91
92
 
92
- async updateMainMemory(input: {
93
- content: string;
94
- mode?: "replace" | "append";
95
- }): Promise<MainMemory> {
96
- const now = Date.now();
97
- const existing = await this.getMainMemory();
98
- const nextContent =
99
- input.mode === "append" && existing.content
100
- ? `${existing.content}\n\n${input.content}`.trim()
101
- : input.content;
93
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
102
94
  this.mainMemory = {
103
- content: nextContent.trim(),
104
- updatedAt: now,
95
+ content: input.content.trim(),
96
+ updatedAt: Date.now(),
105
97
  };
106
98
  return this.mainMemory;
107
99
  }
@@ -166,18 +158,10 @@ class FileMainMemoryStore implements MemoryStore {
166
158
  return this.mainMemory;
167
159
  }
168
160
 
169
- async updateMainMemory(input: {
170
- content: string;
171
- mode?: "replace" | "append";
172
- }): Promise<MainMemory> {
161
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
173
162
  await this.ensureLoaded();
174
- const existing = await this.getMainMemory();
175
- const nextContent =
176
- input.mode === "append" && existing.content
177
- ? `${existing.content}\n\n${input.content}`.trim()
178
- : input.content;
179
163
  this.mainMemory = {
180
- content: nextContent.trim(),
164
+ content: input.content.trim(),
181
165
  updatedAt: Date.now(),
182
166
  };
183
167
  await this.persist();
@@ -185,25 +169,23 @@ class FileMainMemoryStore implements MemoryStore {
185
169
  }
186
170
  }
187
171
 
188
- abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
189
- protected readonly ttl?: number;
190
- protected readonly memoryFallback: InMemoryMemoryStore;
172
+ class KVBackedMemoryStore implements MemoryStore {
173
+ private readonly kv: RawKVStore;
174
+ private readonly storageKey: string;
175
+ private readonly ttl?: number;
176
+ private readonly memoryFallback: InMemoryMemoryStore;
191
177
 
192
- constructor(ttl?: number) {
178
+ constructor(kv: RawKVStore, storageKey: string, ttl?: number) {
179
+ this.kv = kv;
180
+ this.storageKey = storageKey;
193
181
  this.ttl = ttl;
194
182
  this.memoryFallback = new InMemoryMemoryStore(ttl);
195
183
  }
196
184
 
197
- protected abstract getRaw(key: string): Promise<string | undefined>;
198
- protected abstract setRaw(key: string, value: string): Promise<void>;
199
- protected abstract setRawWithTtl(key: string, value: string, ttl: number): Promise<void>;
200
-
201
- protected async readPayload(key: string): Promise<MainMemoryPayload> {
185
+ private async readPayload(): Promise<MainMemoryPayload> {
202
186
  try {
203
- const raw = await this.getRaw(key);
204
- if (!raw) {
205
- return { main: { ...DEFAULT_MAIN_MEMORY } };
206
- }
187
+ const raw = await this.kv.get(this.storageKey);
188
+ if (!raw) return { main: { ...DEFAULT_MAIN_MEMORY } };
207
189
  const parsed = JSON.parse(raw) as MainMemoryPayload;
208
190
  const content = typeof parsed.main?.content === "string" ? parsed.main.content : "";
209
191
  const updatedAt = typeof parsed.main?.updatedAt === "number" ? parsed.main.updatedAt : 0;
@@ -214,267 +196,30 @@ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
214
196
  }
215
197
  }
216
198
 
217
- protected async writePayload(key: string, payload: MainMemoryPayload): Promise<void> {
199
+ private async writePayload(payload: MainMemoryPayload): Promise<void> {
218
200
  try {
219
201
  const serialized = JSON.stringify(payload);
220
202
  if (typeof this.ttl === "number") {
221
- await this.setRawWithTtl(key, serialized, Math.max(1, this.ttl));
203
+ await this.kv.setWithTtl(this.storageKey, serialized, Math.max(1, this.ttl));
222
204
  } else {
223
- await this.setRaw(key, serialized);
205
+ await this.kv.set(this.storageKey, serialized);
224
206
  }
225
207
  } catch {
226
- await this.memoryFallback.updateMainMemory({
227
- content: payload.main.content,
228
- mode: "replace",
229
- });
208
+ await this.memoryFallback.updateMainMemory({ content: payload.main.content });
230
209
  }
231
210
  }
232
211
 
233
212
  async getMainMemory(): Promise<MainMemory> {
234
- const payload = await this.readPayload(this.key());
213
+ const payload = await this.readPayload();
235
214
  return payload.main;
236
215
  }
237
216
 
238
- async updateMainMemory(input: {
239
- content: string;
240
- mode?: "replace" | "append";
241
- }): Promise<MainMemory> {
242
- const key = this.key();
243
- const payload = await this.readPayload(key);
244
- const nextContent =
245
- input.mode === "append" && payload.main.content
246
- ? `${payload.main.content}\n\n${input.content}`.trim()
247
- : input.content;
248
- payload.main = {
249
- content: nextContent.trim(),
250
- updatedAt: Date.now(),
251
- };
252
- await this.writePayload(key, payload);
217
+ async updateMainMemory(input: { content: string }): Promise<MainMemory> {
218
+ const payload = await this.readPayload();
219
+ payload.main = { content: input.content.trim(), updatedAt: Date.now() };
220
+ await this.writePayload(payload);
253
221
  return payload.main;
254
222
  }
255
-
256
- protected abstract key(): string;
257
- }
258
-
259
- class UpstashMemoryStore extends KeyValueMainMemoryStoreBase {
260
- private readonly baseUrl: string;
261
- private readonly token: string;
262
- private readonly storageKey: string;
263
-
264
- constructor(options: {
265
- baseUrl: string;
266
- token: string;
267
- storageKey: string;
268
- ttl?: number;
269
- }) {
270
- super(options.ttl);
271
- this.baseUrl = options.baseUrl.replace(/\/+$/, "");
272
- this.token = options.token;
273
- this.storageKey = options.storageKey;
274
- }
275
-
276
- protected key(): string {
277
- return this.storageKey;
278
- }
279
-
280
- private headers(): HeadersInit {
281
- return {
282
- Authorization: `Bearer ${this.token}`,
283
- "Content-Type": "application/json",
284
- };
285
- }
286
-
287
- protected async getRaw(key: string): Promise<string | undefined> {
288
- const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
289
- method: "POST",
290
- headers: this.headers(),
291
- });
292
- if (!response.ok) {
293
- return undefined;
294
- }
295
- const payload = (await response.json()) as { result?: string | null };
296
- return payload.result ?? undefined;
297
- }
298
-
299
- protected async setRaw(key: string, value: string): Promise<void> {
300
- await fetch(
301
- `${this.baseUrl}/set/${encodeURIComponent(key)}/${encodeURIComponent(value)}`,
302
- { method: "POST", headers: this.headers() },
303
- );
304
- }
305
-
306
- protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
307
- await fetch(
308
- `${this.baseUrl}/setex/${encodeURIComponent(key)}/${Math.max(1, ttl)}/${encodeURIComponent(
309
- value,
310
- )}`,
311
- { method: "POST", headers: this.headers() },
312
- );
313
- }
314
- }
315
-
316
- class RedisMemoryStore extends KeyValueMainMemoryStoreBase {
317
- private readonly storageKey: string;
318
- private readonly clientPromise: Promise<
319
- | {
320
- get: (key: string) => Promise<string | null>;
321
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
322
- }
323
- | undefined
324
- >;
325
-
326
- constructor(options: {
327
- url: string;
328
- storageKey: string;
329
- ttl?: number;
330
- }) {
331
- super(options.ttl);
332
- this.storageKey = options.storageKey;
333
- this.clientPromise = (async () => {
334
- try {
335
- const redisModule = (await import("redis")) as unknown as {
336
- createClient: (args: { url: string }) => {
337
- connect: () => Promise<unknown>;
338
- get: (key: string) => Promise<string | null>;
339
- set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
340
- };
341
- };
342
- const client = redisModule.createClient({ url: options.url });
343
- await client.connect();
344
- return client;
345
- } catch {
346
- return undefined;
347
- }
348
- })();
349
- }
350
-
351
- protected key(): string {
352
- return this.storageKey;
353
- }
354
-
355
- protected async getRaw(key: string): Promise<string | undefined> {
356
- const client = await this.clientPromise;
357
- if (!client) {
358
- throw new Error("Redis unavailable");
359
- }
360
- const value = await client.get(key);
361
- return value ?? undefined;
362
- }
363
-
364
- protected async setRaw(key: string, value: string): Promise<void> {
365
- const client = await this.clientPromise;
366
- if (!client) {
367
- throw new Error("Redis unavailable");
368
- }
369
- await client.set(key, value);
370
- }
371
-
372
- protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
373
- const client = await this.clientPromise;
374
- if (!client) {
375
- throw new Error("Redis unavailable");
376
- }
377
- await client.set(key, value, { EX: Math.max(1, ttl) });
378
- }
379
- }
380
-
381
- class DynamoDbMemoryStore extends KeyValueMainMemoryStoreBase {
382
- private readonly storageKey: string;
383
- private readonly table: string;
384
- private readonly clientPromise: Promise<
385
- | {
386
- send: (command: unknown) => Promise<unknown>;
387
- GetItemCommand: new (input: unknown) => unknown;
388
- PutItemCommand: new (input: unknown) => unknown;
389
- }
390
- | undefined
391
- >;
392
-
393
- constructor(options: {
394
- table: string;
395
- storageKey: string;
396
- region?: string;
397
- ttl?: number;
398
- }) {
399
- super(options.ttl);
400
- this.storageKey = options.storageKey;
401
- this.table = options.table;
402
- this.clientPromise = (async () => {
403
- try {
404
- const module = (await import("@aws-sdk/client-dynamodb")) as {
405
- DynamoDBClient: new (input: { region?: string }) => {
406
- send: (command: unknown) => Promise<unknown>;
407
- };
408
- GetItemCommand: new (input: unknown) => unknown;
409
- PutItemCommand: new (input: unknown) => unknown;
410
- };
411
- const client = new module.DynamoDBClient({ region: options.region });
412
- return {
413
- send: client.send.bind(client),
414
- GetItemCommand: module.GetItemCommand,
415
- PutItemCommand: module.PutItemCommand,
416
- };
417
- } catch {
418
- return undefined;
419
- }
420
- })();
421
- }
422
-
423
- protected key(): string {
424
- return this.storageKey;
425
- }
426
-
427
- protected async getRaw(key: string): Promise<string | undefined> {
428
- const client = await this.clientPromise;
429
- if (!client) {
430
- throw new Error("DynamoDB unavailable");
431
- }
432
- const result = (await client.send(
433
- new client.GetItemCommand({
434
- TableName: this.table,
435
- Key: { runId: { S: key } },
436
- }),
437
- )) as {
438
- Item?: {
439
- value?: { S?: string };
440
- };
441
- };
442
- return result.Item?.value?.S;
443
- }
444
-
445
- protected async setRaw(key: string, value: string): Promise<void> {
446
- const client = await this.clientPromise;
447
- if (!client) {
448
- throw new Error("DynamoDB unavailable");
449
- }
450
- await client.send(
451
- new client.PutItemCommand({
452
- TableName: this.table,
453
- Item: {
454
- runId: { S: key },
455
- value: { S: value },
456
- },
457
- }),
458
- );
459
- }
460
-
461
- protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
462
- const client = await this.clientPromise;
463
- if (!client) {
464
- throw new Error("DynamoDB unavailable");
465
- }
466
- const ttlEpoch = Math.floor(Date.now() / 1000) + Math.max(1, ttl);
467
- await client.send(
468
- new client.PutItemCommand({
469
- TableName: this.table,
470
- Item: {
471
- runId: { S: key },
472
- value: { S: value },
473
- ttl: { N: String(ttlEpoch) },
474
- },
475
- }),
476
- );
477
- }
478
223
  }
479
224
 
480
225
  export const createMemoryStore = (
@@ -484,54 +229,19 @@ export const createMemoryStore = (
484
229
  ): MemoryStore => {
485
230
  const provider = config?.provider ?? "local";
486
231
  const ttl = config?.ttl;
487
- const storageKey = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(
488
- agentId,
489
- )}:memory:main`;
490
232
  const workingDir = options?.workingDir ?? process.cwd();
233
+
491
234
  if (provider === "local") {
492
235
  return new FileMainMemoryStore(workingDir, ttl);
493
236
  }
494
237
  if (provider === "memory") {
495
238
  return new InMemoryMemoryStore(ttl);
496
239
  }
497
- if (provider === "upstash") {
498
- const urlEnv = config?.urlEnv ?? (process.env.UPSTASH_REDIS_REST_URL ? "UPSTASH_REDIS_REST_URL" : "KV_REST_API_URL");
499
- const tokenEnv = config?.tokenEnv ?? (process.env.UPSTASH_REDIS_REST_TOKEN ? "UPSTASH_REDIS_REST_TOKEN" : "KV_REST_API_TOKEN");
500
- const url = process.env[urlEnv] ?? "";
501
- const token = process.env[tokenEnv] ?? "";
502
- if (url && token) {
503
- return new UpstashMemoryStore({
504
- baseUrl: url,
505
- token,
506
- storageKey,
507
- ttl,
508
- });
509
- }
510
- return new InMemoryMemoryStore(ttl);
511
- }
512
- if (provider === "redis") {
513
- const urlEnv = config?.urlEnv ?? "REDIS_URL";
514
- const url = process.env[urlEnv] ?? "";
515
- if (url) {
516
- return new RedisMemoryStore({
517
- url,
518
- storageKey,
519
- ttl,
520
- });
521
- }
522
- return new InMemoryMemoryStore(ttl);
523
- }
524
- if (provider === "dynamodb") {
525
- const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
526
- if (table) {
527
- return new DynamoDbMemoryStore({
528
- table,
529
- storageKey,
530
- region: config?.region,
531
- ttl,
532
- });
533
- }
534
- return new InMemoryMemoryStore(ttl);
240
+
241
+ const kv = createRawKVStore(config);
242
+ if (kv) {
243
+ const storageKey = `poncho:${STORAGE_SCHEMA_VERSION}:${slugifyStorageComponent(agentId)}:memory:main`;
244
+ return new KVBackedMemoryStore(kv, storageKey, ttl);
535
245
  }
536
246
  return new InMemoryMemoryStore(ttl);
537
247
  };
@@ -590,20 +300,17 @@ export const createMemoryTools = (
590
300
  },
591
301
  }),
592
302
  defineTool({
593
- name: "memory_main_update",
303
+ name: "memory_main_write",
594
304
  description:
595
- "Update persistent main memory when new stable preferences, long-term goals, or durable facts appear. Proactively evaluate every turn whether memory should be updated, and avoid storing ephemeral details.",
305
+ "Overwrite the entire persistent main memory document. " +
306
+ "Use for initial writes or full rewrites. " +
307
+ "Prefer memory_main_edit for targeted changes to existing memory.",
596
308
  inputSchema: {
597
309
  type: "object",
598
310
  properties: {
599
- mode: {
600
- type: "string",
601
- enum: ["replace", "append"],
602
- description: "replace overwrites memory; append adds content to the end",
603
- },
604
311
  content: {
605
312
  type: "string",
606
- description: "The memory content to write",
313
+ description: "The full memory content to write",
607
314
  },
608
315
  },
609
316
  required: ["content"],
@@ -614,11 +321,56 @@ export const createMemoryTools = (
614
321
  if (!content) {
615
322
  throw new Error("content is required");
616
323
  }
617
- const mode =
618
- input.mode === "append" || input.mode === "replace"
619
- ? input.mode
620
- : "replace";
621
- const memory = await store.updateMainMemory({ content, mode });
324
+ const memory = await store.updateMainMemory({ content });
325
+ return { ok: true, memory };
326
+ },
327
+ }),
328
+ defineTool({
329
+ name: "memory_main_edit",
330
+ description:
331
+ "Edit persistent main memory by replacing an exact string match with new content. " +
332
+ "The old_str must match exactly one location in memory. " +
333
+ "Use an empty new_str to delete matched content. " +
334
+ "Proactively evaluate every turn whether memory should be updated.",
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ old_str: {
339
+ type: "string",
340
+ description:
341
+ "The exact text to find and replace (must be unique in memory). " +
342
+ "Include surrounding context if needed to ensure uniqueness.",
343
+ },
344
+ new_str: {
345
+ type: "string",
346
+ description: "The replacement text (use empty string to delete the matched content)",
347
+ },
348
+ },
349
+ required: ["old_str", "new_str"],
350
+ additionalProperties: false,
351
+ },
352
+ handler: async (input) => {
353
+ const oldStr = typeof input.old_str === "string" ? input.old_str : "";
354
+ const newStr = typeof input.new_str === "string" ? input.new_str : "";
355
+ if (!oldStr) {
356
+ throw new Error("old_str must not be empty.");
357
+ }
358
+ const current = await store.getMainMemory();
359
+ const content = current.content;
360
+ const first = content.indexOf(oldStr);
361
+ if (first === -1) {
362
+ throw new Error(
363
+ "old_str not found in memory. Make sure it matches exactly, including whitespace and line breaks.",
364
+ );
365
+ }
366
+ const last = content.lastIndexOf(oldStr);
367
+ if (first !== last) {
368
+ throw new Error(
369
+ "old_str appears multiple times in memory. Please provide more context to ensure a unique match.",
370
+ );
371
+ }
372
+ const newContent = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
373
+ const memory = await store.updateMainMemory({ content: newContent });
622
374
  return { ok: true, memory };
623
375
  },
624
376
  }),