Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions feeds/skills/.system/files/netclaw-memory/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
name: netclaw-memory
description: "REQUIRED when the user asks what you remember, recall, or know from past conversations, previous sessions, or cross-session memory. Also before using memory tools: find_memories, get_memories, store_memory, update_memory."
description: "REQUIRED when the user asks what you remember, recall, or know from past conversations, previous sessions, cross-session memory, memory classes, or memory types. Also before using memory tools: find_memories, get_memories, store_memory, update_memory."
metadata:
author: netclaw
version: "1.5.0"
version: "1.6.2"
---

# Netclaw Memory
Expand Down Expand Up @@ -40,6 +40,9 @@ Both gates must pass for memory to function.
- **Explicit tools** are a manual-control layer on top of automatic recall.
- Memory is SQLite-backed and cross-session only within the active
domain/boundary policy envelope.
- Memory IDs shown by automatic recall, `find_memories`, and `get_memories`
(e.g. `doc-…` / `rec-…`) are stable, opaque handles. Copy them **verbatim**
into `get_memories` or `update_memory` — do not rewrite or reformat them.

## When to Use Explicit Tools

Expand Down Expand Up @@ -84,6 +87,11 @@ Automatic observation note:

Use only to correct or supersede an existing memory.

Use the memory ID exactly as shown by automatic recall, `find_memories`, or
`get_memories`. For documents, prefer `new_content` when replacing a full
hydrated memory. Use `old_text` + `new_text` only when making a precise
find-and-replace edit. To delete a memory, pass `delete: true`.

## Memory Classes

| Class | Recall | Expiry |
Expand Down
4 changes: 2 additions & 2 deletions src/Netclaw.Actors.Tests/Memory/MemoryEvalSeedSuiteTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ await _store.UpsertDocumentAsync(new SQLiteMemoryDocument(
MaxItems: 3), TestContext.Current.CancellationToken);

Assert.False(result.Degraded);
Assert.Contains(result.Items, i => i.Id == "doc-ops");
Assert.Contains(result.Items, i => i.Id.Value == "doc-ops");
Assert.True(result.Items.Count <= 3);
}

Expand Down Expand Up @@ -95,7 +95,7 @@ await _store.UpsertDocumentAsync(new SQLiteMemoryDocument(
MaxItems: 3), TestContext.Current.CancellationToken);

Assert.False(result.Degraded);
Assert.DoesNotContain(result.Items, i => i.Id == "doc-secret");
Assert.DoesNotContain(result.Items, i => i.Id.Value == "doc-secret");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ await _store.ApplyCurationBatchAsync(
["where should I stay near Stir Trek"],
3), TestContext.Current.CancellationToken);

Assert.DoesNotContain(auto.Items, x => x.Id == "rec-hotel-evidence");
Assert.DoesNotContain(auto.Items, x => x.Id.Value == "rec-hotel-evidence");

var tool = new SqliteFindMemoriesTool(_store, _timeProvider);
var search = await tool.ExecuteAsync(
Expand Down Expand Up @@ -572,7 +572,7 @@ await _store.ApplyCurationBatchAsync(
var intentionalEvidenceHitRate = search.Contains("Hotel options", StringComparison.Ordinal) ? 1.0 : 0.0;
var gateCorrectness = acceptedFact.Count == 1 ? 1.0 : 0.0;
var explicitWriteTruthfulness = acceptedFact.Count == 1 ? 1.0 : 0.0;
var evidenceLeakage = auto.Items.Any(x => x.Id == "rec-report-evidence") ? 1.0 : 0.0;
var evidenceLeakage = auto.Items.Any(x => x.Id.Value == "rec-report-evidence") ? 1.0 : 0.0;

Assert.Contains("stale=true", staleDebug);

Expand Down
93 changes: 93 additions & 0 deletions src/Netclaw.Actors.Tests/Memory/MemoryTypedIdTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// -----------------------------------------------------------------------
// <copyright file="MemoryTypedIdTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Netclaw.Actors.Memory;

namespace Netclaw.Actors.Tests.Memory;

public class MemoryTypedIdTests
{
[Theory]
[InlineData("doc:abc123", MemoryKind.Document, "abc123")]
[InlineData("rec:xyz789", MemoryKind.Record, "xyz789")]
[InlineData("DOC:upper", MemoryKind.Document, "upper")]
[InlineData("REC:UPPER", MemoryKind.Record, "UPPER")]
[InlineData("doc-abc123", MemoryKind.Document, "doc-abc123")]
[InlineData("rec-xyz789", MemoryKind.Record, "rec-xyz789")]
[InlineData("DOC-upper", MemoryKind.Document, "DOC-upper")]
[InlineData("REC-UPPER", MemoryKind.Record, "REC-UPPER")]
public void Parse_accepts_canonical_and_legacy_raw_ids(string raw, MemoryKind expectedKind, string expectedId)
{
var parsed = MemoryTypedId.Parse(raw);
Assert.Equal(expectedKind, parsed.Kind);
Assert.Equal(expectedId, parsed.Id.Value);
}

[Fact]
public void Parse_rejects_unrecognized_prefixes()
{
var parsed = MemoryTypedId.Parse("unknown-abc123");
Assert.Equal(MemoryKind.Unknown, parsed.Kind);
Assert.Equal("unknown-abc123", parsed.Id.Value);
}

[Theory]
[InlineData("")]
[InlineData("no-prefix")]
[InlineData("anchor:netclaw")]
public void Parse_unknown_for_invalid_prefixes(string raw)
{
var parsed = MemoryTypedId.Parse(raw);
Assert.Equal(MemoryKind.Unknown, parsed.Kind);
}

[Fact]
public void ToString_returns_storage_id_verbatim()
{
var id = new MemoryTypedId(MemoryKind.Document, "doc-abc123");
Assert.Equal("doc-abc123", id.ToString());
}

[Fact]
public void NewDocumentId_returns_dash_format()
{
var id = MemoryTypedId.NewDocumentId();
Assert.StartsWith("doc-", id.Value);
Assert.Equal(36, id.Value.Length);
}

[Fact]
public void NewRecordId_returns_dash_format()
{
var id = MemoryTypedId.NewRecordId();
Assert.StartsWith("rec-", id.Value);
Assert.Equal(36, id.Value.Length);
}

[Fact]
public void Generated_storage_id_round_trips_to_the_same_key()
{
// The id we surface to the model is the storage id verbatim; parsing what the model
// sends back must yield the exact same primary key.
var generated = MemoryTypedId.NewDocumentId();
var parsed = MemoryTypedId.Parse(generated.Value);

Assert.Equal(MemoryKind.Document, parsed.Kind);
Assert.Equal(generated.Value, parsed.Id.Value);
}

[Fact]
public void Legacy_colon_envelope_resolves_to_the_same_key_as_the_dash_id()
{
// Both the bare storage id and a legacy "doc:{storageId}" envelope must map to the
// one real key — this is what makes the single-lookup resolver unambiguous.
var dash = MemoryTypedId.Parse("doc-abc123");
var enveloped = MemoryTypedId.Parse("doc:doc-abc123");

Assert.Equal("doc-abc123", dash.Id.Value);
Assert.Equal("doc-abc123", enveloped.Id.Value);
Assert.Equal(dash.Id.Value, enveloped.Id.Value);
}
}
46 changes: 46 additions & 0 deletions src/Netclaw.Actors.Tests/Memory/SQLiteMemoryStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,31 @@ await _store.UpsertDocumentAsync(new SQLiteMemoryDocument(
Assert.Contains(personalResults, x => x.Id == "doc-personal");
}

[Fact]
public async Task ResolveMemoryHandleAsync_maps_each_id_form_to_its_exact_storage_key()
{
await _store.InitializeAsync(TestContext.Current.CancellationToken);

var anchor = _store.CreateDefaultAnchor("distinct-memory");
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Two distinct rows whose keys differ only by the legacy prefix. Because the parsed id is
// used as the exact primary key, each id form resolves to exactly one row — no ambiguity.
await _store.UpsertDocumentAsync(CreateDocument("abc", anchor, "Bare ID", now), TestContext.Current.CancellationToken);
await _store.UpsertDocumentAsync(CreateDocument("doc-abc", anchor, "Legacy ID", now), TestContext.Current.CancellationToken);

var bare = await _store.ResolveMemoryHandleAsync("doc:abc", TrustBoundary.TrustedInstanceValue, TrustAudience.Personal, TestContext.Current.CancellationToken);
var dash = await _store.ResolveMemoryHandleAsync("doc-abc", TrustBoundary.TrustedInstanceValue, TrustAudience.Personal, TestContext.Current.CancellationToken);
var envelope = await _store.ResolveMemoryHandleAsync("doc:doc-abc", TrustBoundary.TrustedInstanceValue, TrustAudience.Personal, TestContext.Current.CancellationToken);

Assert.True(bare.Resolved);
Assert.Equal("abc", bare.StorageId!.Value.Value);
Assert.True(dash.Resolved);
Assert.Equal("doc-abc", dash.StorageId!.Value.Value);
// The colon envelope over the dash key resolves to the same row as the dash key.
Assert.True(envelope.Resolved);
Assert.Equal("doc-abc", envelope.StorageId!.Value.Value);
}

public ValueTask InitializeAsync() => ValueTask.CompletedTask;

public async ValueTask DisposeAsync()
Expand Down Expand Up @@ -276,4 +301,25 @@ private static async Task TryDeleteDirectoryAsync(string path)
// Best effort cleanup: file handles can remain briefly open on Windows CI.
// Leaving temp dirs behind is preferable to failing the test run.
}

private static SQLiteMemoryDocument CreateDocument(string id, SQLiteMemoryAnchor anchor, string title, long now)
=> new(
DocumentId: id,
Anchor: anchor,
MemoryClass: MemoryClass.DurableFact.ToWireValue(),
Title: title,
MarkdownBody: $"Content for {title}.",
AliasesJson: null,
FacetsJson: null,
SlotsJson: null,
UpdateSemantics: MemoryUpdateSemantics.MergeDocument.ToWireValue(),
Sensitivity: MemorySensitivity.Normal.ToWireValue(),
RecallMode: MemoryRecallMode.Auto.ToWireValue(),
Confidence: 0.9,
FreshnessAtMs: now,
ExpiresAtMs: null,
CreatedAtMs: now,
UpdatedAtMs: now,
Boundary: TrustBoundary.TrustedInstanceValue,
Audience: TrustAudience.Team.ToWireValue());
}
Loading
Loading